diff --git a/CHANGELOG.md b/CHANGELOG.md index d181b63..23acc1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 resume position (and still counts as playing today for the streak). Passports surface it honestly: "14.2 h in Blues" under the badge and on the shelf cover — a true fact that only grows, never a target or a meter. +- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz + now also asks for that genre's signature Virtuoso drill (Blues Shuffle, + Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one + per genre, data-driven in `passports.json` with display labels). Drill + lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat + list still means guitar), so a keys passport never demands a guitar drill. + A drill counts as cleared on the first real completion artifact — a + top-tier clean pass in one key (`keysCleared`), any depth rung, or + mastery — rather than only the maxed-speed depth flips. Genres without a + curated drill stay songs-only. - **Career passports (backend)** — the badge-journey layer on top of career stars. New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument passport walls: genre badges computed on read from `song_stats` × the library's diff --git a/plugins/career/passports.json b/plugins/career/passports.json index 423f977..4e55d42 100644 --- a/plugins/career/passports.json +++ b/plugins/career/passports.json @@ -3,7 +3,20 @@ "songs": 5, "min_stars": 2 }, - "genres": {}, + "genres": { + "blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } }, + "rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } }, + "metal": { "virtuoso_nodes": { "guitar": ["melodic_metal_gallop"] } }, + "funk": { "virtuoso_nodes": { "guitar": ["sixteenth_pocket"] } }, + "jazz": { "virtuoso_nodes": { "guitar": ["vl_shells"] } } + }, + "drill_labels": { + "blues_shuffle": "Blues Shuffle", + "rock_power_backbeat": "Power Chords & Backbeat", + "melodic_metal_gallop": "Gallop Picking", + "sixteenth_pocket": "16th Pocket", + "vl_shells": "Shell Voicings" + }, "graded_instruments": [ "guitar", "keys" diff --git a/plugins/career/routes.py b/plugins/career/routes.py index 26ba518..fd52c80 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -276,7 +276,7 @@ def _library_genres(): key=lambda r: (-r["songs_in_library"], r["genre_key"])) -def _badge_requirement(gkey): +def _badge_requirement(gkey, instrument="guitar"): cfg = _state["passports_content"] req = dict(cfg.get("badge_requirement") or {}) req.setdefault("songs", 5) @@ -284,8 +284,15 @@ def _badge_requirement(gkey): override = (cfg.get("genres") or {}).get(gkey) if isinstance(override, dict): req.update(override) - req["virtuoso_nodes"] = [n for n in (req.get("virtuoso_nodes") or []) - if isinstance(n, str)] + # virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its + # own instrument's drills. A flat list keeps meaning guitar (back-compat; + # virtuoso's drill content is guitar-first). + nodes = req.get("virtuoso_nodes") or [] + if isinstance(nodes, dict): + nodes = nodes.get(instrument) or [] + elif instrument != "guitar": + nodes = [] + req["virtuoso_nodes"] = [n for n in nodes if isinstance(n, str)] return req @@ -298,14 +305,50 @@ def _drill_by_node(): return doc.get("received_at"), by_node +def _merge_drill_nodes(old, new): + """Gained-only merge of virtuoso byNode snapshots: a completion artifact + once relayed never un-earns via a stale snapshot (multi-browser races, + settings import, the once-per-session boot relay). Incoming wins the + descriptive fields; masteredAt / depth flips / keysCleared only grow.""" + out = dict(old) + for node_id, incoming in new.items(): + if not isinstance(incoming, dict): + continue + cur = out.get(node_id) + if not isinstance(cur, dict): + out[node_id] = incoming + continue + merged = dict(cur) + merged.update(incoming) + merged["masteredAt"] = cur.get("masteredAt") or incoming.get("masteredAt") + d_old = cur.get("depth") if isinstance(cur.get("depth"), dict) else {} + d_new = incoming.get("depth") if isinstance(incoming.get("depth"), dict) else {} + depth = dict(d_new) + for axis, val in d_old.items(): + if val and not depth.get(axis): + depth[axis] = val + if depth: + merged["depth"] = depth + keys_old = cur.get("keysCleared") if isinstance(cur.get("keysCleared"), list) else [] + keys_new = incoming.get("keysCleared") if isinstance(incoming.get("keysCleared"), list) else [] + merged["keysCleared"] = keys_old + [k for k in keys_new if k not in keys_old] + out[node_id] = merged + return out + + def _node_cleared(by_node, node_id): - """A drill counts as cleared on real completion evidence: mastered, or any - depth rung flipped true (virtuoso's gained-only false→true artifacts).""" + """A drill counts as cleared on real completion evidence: mastered, any + depth rung flipped true, or a key cleared (a top-tier clean pass in one + key — virtuoso's first gained-only artifact, and an achievable Bronze + bar; the depth rungs additionally require a maxed speed tier).""" entry = by_node.get(node_id) if not isinstance(entry, dict): return False depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {} - return bool(entry.get("masteredAt")) or any(bool(v) for v in depth.values()) + keys = entry.get("keysCleared") + return (bool(entry.get("masteredAt")) + or any(bool(v) for v in depth.values()) + or bool(isinstance(keys, list) and keys)) def _passports_view(): @@ -323,7 +366,7 @@ def _passports_view(): for gkey, meta in sorted(opened.items(), key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])): meta = meta if isinstance(meta, dict) else {} - req = _badge_requirement(gkey) + req = _badge_requirement(gkey, inst) songs = list(played.get((inst, gkey), {}).values()) for s in songs: s["qualifies"] = s["stars"] >= req["min_stars"] @@ -362,6 +405,8 @@ def _passports_view(): "badge_requirement": cfg.get("badge_requirement") or {}, "graded_instruments": sorted(graded), "instruments": list(cfg.get("instruments") or []), + # Career-side display names for virtuoso drill node ids. + "drill_labels": dict(cfg.get("drill_labels") or {}), }, "instruments": instruments, "genres": _library_genres(), @@ -536,11 +581,16 @@ def setup(app, context): # Only the fields the badge check reads are kept. if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict): raise HTTPException(400, "Expected a progress snapshot with byNode.") - snapshot = {"mode": body.get("mode"), "xp": body.get("xp"), - "byNode": body["byNode"]} - if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES: + # Bound the INCOMING snapshot before the merge — the gained-only merge + # drops junk entries, which must not become a size-guard bypass. + if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES: raise HTTPException(413, "Snapshot too large.") with _lock: + _, existing = _drill_by_node() + snapshot = {"mode": body.get("mode"), "xp": body.get("xp"), + "byNode": _merge_drill_nodes(existing, body["byNode"])} + if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES: + raise HTTPException(413, "Snapshot too large.") _save_json(_drill_file(), {"received_at": _now_iso(), "snapshot": snapshot}) return {"ok": True} diff --git a/plugins/career/screen.js b/plugins/career/screen.js index b4242b9..1beb96d 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -460,9 +460,11 @@ renderPassports(); if (!_ppBootstrapped) { _ppBootstrapped = true; - // First run on this browser: seed the server with the local drill - // snapshot if it has never received one. - if (!(view.drill_state || {}).received_at) relayDrillState(); + // Sync the local drill snapshot once per session — drill progress + // made before the career plugin existed (or a relay POST that + // failed) must not deny a gated badge until the next virtuoso + // event happens to fire. Tiny payload, single-user app. + relayDrillState(); } } @@ -562,6 +564,20 @@ const req = p.requirement || {}; const need = Math.max(0, (req.songs || 0) - p.qualifying_count); const starGl = '★'.repeat(req.min_stars || 0); + const reqNodes = (p.drills || {}).required || []; + const clearedNodes = new Set((p.drills || {}).cleared || []); + const labels = ((_pp && _pp.config) || {}).drill_labels || {}; + const pendingDrills = reqNodes.filter((n) => !clearedNodes.has(n)); + // The invite names what actually blocks the stamp: songs first, then + // the genre drill once the song bar is met. + let invite; + if (need > 0) { + invite = need === 1 ? `One more ${starGl} song mints this stamp.` + : `${need} more ${starGl} songs mint this stamp.`; + } else { + const names = pendingDrills.map((n) => labels[n] || n).join(', '); + invite = `Clear ${names || 'the genre drill'} in Virtuoso to mint this stamp.`; + } let badgeArea = ''; if (p.badge === 'shown_not_judged') { badgeArea = `