feat(career): curated genre drills — per-instrument, achievably cleared

Career v2, WS3. Bronze in blues/rock/metal/funk/jazz now also asks for
the genre's signature Virtuoso drill, data-driven in passports.json:
blues_shuffle, rock_power_backbeat, melodic_metal_gallop,
sixteenth_pocket, vl_shells — with career-side display labels the
passport page renders instead of raw node ids.

- virtuoso_nodes becomes {instrument: [node_ids]} so a keys passport
  never demands a guitar drill; a flat list keeps meaning guitar
  (virtuoso's content is guitar-first).
- _node_cleared also accepts keysCleared (a top-tier clean pass in one
  key — virtuoso's FIRST gained-only artifact). The depth rungs
  additionally require a maxed speed tier, too high a bar for Bronze.
- Genres without a curated entry stay songs-only.

Note: pre-release behavior change — v1 passports aren't in any shipped
build, so no earned badge can demote in the wild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
byrongamatos
2026-07-13 15:14:14 +02:00
co-authored by Claude Fable 5
parent 0fc6a4beed
commit 1d014a5575
5 changed files with 160 additions and 20 deletions
+10
View File
@@ -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). 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 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. 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. - **Career passports (backend)** — the badge-journey layer on top of career stars.
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
passport walls: genre badges computed on read from `song_stats` × the library's passport walls: genre badges computed on read from `song_stats` × the library's
+14 -1
View File
@@ -3,7 +3,20 @@
"songs": 5, "songs": 5,
"min_stars": 2 "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": [ "graded_instruments": [
"guitar", "guitar",
"keys" "keys"
+60 -10
View File
@@ -276,7 +276,7 @@ def _library_genres():
key=lambda r: (-r["songs_in_library"], r["genre_key"])) key=lambda r: (-r["songs_in_library"], r["genre_key"]))
def _badge_requirement(gkey): def _badge_requirement(gkey, instrument="guitar"):
cfg = _state["passports_content"] cfg = _state["passports_content"]
req = dict(cfg.get("badge_requirement") or {}) req = dict(cfg.get("badge_requirement") or {})
req.setdefault("songs", 5) req.setdefault("songs", 5)
@@ -284,8 +284,15 @@ def _badge_requirement(gkey):
override = (cfg.get("genres") or {}).get(gkey) override = (cfg.get("genres") or {}).get(gkey)
if isinstance(override, dict): if isinstance(override, dict):
req.update(override) req.update(override)
req["virtuoso_nodes"] = [n for n in (req.get("virtuoso_nodes") or []) # virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
if isinstance(n, str)] # 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 return req
@@ -298,14 +305,50 @@ def _drill_by_node():
return doc.get("received_at"), 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): def _node_cleared(by_node, node_id):
"""A drill counts as cleared on real completion evidence: mastered, or any """A drill counts as cleared on real completion evidence: mastered, any
depth rung flipped true (virtuoso's gained-only false→true artifacts).""" 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) entry = by_node.get(node_id)
if not isinstance(entry, dict): if not isinstance(entry, dict):
return False return False
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {} 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(): def _passports_view():
@@ -323,7 +366,7 @@ def _passports_view():
for gkey, meta in sorted(opened.items(), for gkey, meta in sorted(opened.items(),
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])): key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
meta = meta if isinstance(meta, dict) else {} meta = meta if isinstance(meta, dict) else {}
req = _badge_requirement(gkey) req = _badge_requirement(gkey, inst)
songs = list(played.get((inst, gkey), {}).values()) songs = list(played.get((inst, gkey), {}).values())
for s in songs: for s in songs:
s["qualifies"] = s["stars"] >= req["min_stars"] s["qualifies"] = s["stars"] >= req["min_stars"]
@@ -362,6 +405,8 @@ def _passports_view():
"badge_requirement": cfg.get("badge_requirement") or {}, "badge_requirement": cfg.get("badge_requirement") or {},
"graded_instruments": sorted(graded), "graded_instruments": sorted(graded),
"instruments": list(cfg.get("instruments") or []), "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, "instruments": instruments,
"genres": _library_genres(), "genres": _library_genres(),
@@ -536,11 +581,16 @@ def setup(app, context):
# Only the fields the badge check reads are kept. # Only the fields the badge check reads are kept.
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict): if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
raise HTTPException(400, "Expected a progress snapshot with byNode.") raise HTTPException(400, "Expected a progress snapshot with byNode.")
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"), # Bound the INCOMING snapshot before the merge — the gained-only merge
"byNode": body["byNode"]} # drops junk entries, which must not become a size-guard bypass.
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES: if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.") raise HTTPException(413, "Snapshot too large.")
with _lock: 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(), _save_json(_drill_file(), {"received_at": _now_iso(),
"snapshot": snapshot}) "snapshot": snapshot})
return {"ok": True} return {"ok": True}
+21 -7
View File
@@ -460,9 +460,11 @@
renderPassports(); renderPassports();
if (!_ppBootstrapped) { if (!_ppBootstrapped) {
_ppBootstrapped = true; _ppBootstrapped = true;
// First run on this browser: seed the server with the local drill // Sync the local drill snapshot once per session — drill progress
// snapshot if it has never received one. // made before the career plugin existed (or a relay POST that
if (!(view.drill_state || {}).received_at) relayDrillState(); // 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 req = p.requirement || {};
const need = Math.max(0, (req.songs || 0) - p.qualifying_count); const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
const starGl = '★'.repeat(req.min_stars || 0); 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 = ''; let badgeArea = '';
if (p.badge === 'shown_not_judged') { if (p.badge === 'shown_not_judged') {
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`; badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
@@ -576,17 +592,15 @@
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span> <span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span> <span class="pp-stamp-tier">BRONZE</span>
</div> </div>
<div class="pp-invite">${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}</div>`; <div class="pp-invite">${esc(invite)}</div>`;
} }
const hours = fmtHours(p.seconds_total); const hours = fmtHours(p.seconds_total);
const odometer = hours const odometer = hours
? `<div class="pp-hours">${hours} in ${esc(p.genre)}</div>` : ''; ? `<div class="pp-hours">${hours} in ${esc(p.genre)}</div>` : '';
let drills = ''; let drills = '';
const reqNodes = (p.drills || {}).required || [];
if (reqNodes.length) { if (reqNodes.length) {
const cleared = new Set((p.drills || {}).cleared || []);
drills = `<div class="pp-drills">${reqNodes.map((n) => drills = `<div class="pp-drills">${reqNodes.map((n) =>
`<div class="pp-drill${cleared.has(n) ? ' cleared' : ''}">${cleared.has(n) ? '✓' : '○'} ${esc(n)}</div>`).join('')}</div>`; `<div class="pp-drill${clearedNodes.has(n) ? ' cleared' : ''}">${clearedNodes.has(n) ? '✓' : '○'} ${esc(labels[n] || n)}</div>`).join('')}</div>`;
} }
// Graded instruments collect stubs at the badge bar; shown-not-judged // Graded instruments collect stubs at the badge bar; shown-not-judged
// instruments have no bar — every played genre song is repertoire. // instruments have no bar — every played genre song is repertoire.
+55 -2
View File
@@ -27,13 +27,47 @@ def _passport(client, instrument="guitar", genre_key="blues"):
def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db): def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db):
# Soul has no curated drill requirement — songs alone mint the badge.
for i in range(5):
meta_db.add(f"soul{i}.feedpak", 0, 0.8, genre="Soul", arrangements=LEAD)
_open(client, "guitar", "Soul")
p = _passport(client, "guitar", "soul")
assert p["badge"] == "earned"
assert p["qualifying_count"] == 5
assert all(s["qualifies"] and s["stars"] == 2 for s in p["songs"])
def test_shipped_blues_drill_gates_and_keys_cleared_clears_it(client, meta_db):
# Blues ships a guitar drill (blues_shuffle): songs alone are not enough.
for i in range(5): for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD) meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
_open(client) _open(client)
p = _passport(client) p = _passport(client)
assert p["badge"] == "in_progress"
assert p["drills"]["required"] == ["blues_shuffle"]
# One key cleared (a top-tier clean pass) counts as cleared — the depth
# rungs are a higher bar than Bronze needs.
res = client.post("/api/plugins/career/drill-state", json={
"mode": "casual", "xp": 10,
"byNode": {"blues_shuffle": {"reps": 12, "keysCleared": ["E"],
"depth": {"travel": None, "clean": None},
"masteredAt": None}}})
assert res.status_code == 200
p = _passport(client)
assert p["drills"]["cleared"] == ["blues_shuffle"]
assert p["badge"] == "earned"
def test_drill_lists_are_per_instrument(client, meta_db):
# Keys is graded but Blues curates only a GUITAR drill — a keys passport
# earns on songs alone.
keys_arr = [{"type": "lead", "name": "Keys"}]
for i in range(5):
meta_db.add(f"kb{i}.feedpak", 0, 0.9, genre="Blues", arrangements=keys_arr)
_open(client, "keys")
p = _passport(client, "keys")
assert p["drills"]["required"] == []
assert p["badge"] == "earned" assert p["badge"] == "earned"
assert p["qualifying_count"] == 5
assert all(s["qualifies"] and s["stars"] == 2 for s in p["songs"])
def test_badge_in_progress_below_the_bar(client, meta_db): def test_badge_in_progress_below_the_bar(client, meta_db):
@@ -155,3 +189,22 @@ def test_hours_odometer_sums_seconds_per_instrument_and_genre(client, meta_db):
_open(client, "bass") _open(client, "bass")
assert _passport(client, "guitar")["seconds_total"] == 900 assert _passport(client, "guitar")["seconds_total"] == 900
assert _passport(client, "bass")["seconds_total"] == 1200 assert _passport(client, "bass")["seconds_total"] == 1200
def test_drill_state_merge_is_gained_only(client, meta_db):
# A cleared drill survives a later STALE snapshot that lacks it
# (multi-browser race / settings import / the boot relay).
for i in range(5):
meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD)
_open(client)
client.post("/api/plugins/career/drill-state", json={
"byNode": {"blues_shuffle": {"keysCleared": ["E"]}}})
assert _passport(client)["badge"] == "earned"
# Stale relay: empty byNode, then one with the node but nothing earned.
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
client.post("/api/plugins/career/drill-state", json={
"byNode": {"blues_shuffle": {"reps": 2, "keysCleared": [],
"depth": {"travel": None}, "masteredAt": None}}})
p = _passport(client)
assert p["drills"]["cleared"] == ["blues_shuffle"]
assert p["badge"] == "earned"