diff --git a/plugins/career/routes.py b/plugins/career/routes.py index b8bc5e1..3c1e889 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -330,10 +330,11 @@ def _badge_requirement(gkey, instrument="guitar"): def _drill_by_node(): doc = _load_json(_drill_file(), {}) if not isinstance(doc, dict): - return None, {} + return None, {}, {} snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {} by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {} - return doc.get("received_at"), by_node + gold = snapshot.get("goldImprov") if isinstance(snapshot.get("goldImprov"), dict) else {} + return doc.get("received_at"), by_node, gold def _merge_drill_nodes(old, new): @@ -367,6 +368,16 @@ def _merge_drill_nodes(old, new): return out +def _merge_gold(old, new): + """Gained-only merge of goldImprov artifacts: a minted style never + un-mints via a stale relay; the FIRST artifact per style is kept.""" + out = dict(old) + for style_id, art in (new or {}).items(): + if isinstance(art, dict) and style_id not in out: + out[style_id] = art + return out + + def _node_cleared(by_node, node_id): """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 @@ -388,7 +399,7 @@ def _passports_view(): st = _career_state() all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else [] played, played_seconds = _played_by_instrument_genre() - received_at, by_node = _drill_by_node() + received_at, by_node, gold_improv = _drill_by_node() instruments = {} for inst in cfg.get("instruments") or []: committed_at = (st["instruments"].get(inst) or {}).get("committed_at") @@ -414,7 +425,12 @@ def _passports_view(): # false badge denial — the doc's shown-not-judged rule. badge = "shown_not_judged" elif qualifying >= req["songs"] and len(cleared) == len(required): - badge = "earned" + # Bronze is earned; GOLD upgrades it when a verified improv + # artifact exists for this genre's jam style (exact genre key + # or its family — jam styleIds are the family keys). Bronze + # remains a standalone win; gold never becomes an obligation. + style = gkey if gkey in gold_improv else _genre_family(gkey) + badge = "gold" if style and style in gold_improv else "earned" else: badge = "in_progress" # Practice invitation: the non-qualifying songs closest to the @@ -687,10 +703,13 @@ def setup(app, context): # 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.") + gold_in = body.get("goldImprov") + gold_in = gold_in if isinstance(gold_in, dict) else {} with _lock: - _, existing = _drill_by_node() + _, existing, existing_gold = _drill_by_node() snapshot = {"mode": body.get("mode"), "xp": body.get("xp"), - "byNode": _merge_drill_nodes(existing, body["byNode"])} + "byNode": _merge_drill_nodes(existing, body["byNode"]), + "goldImprov": _merge_gold(existing_gold, gold_in)} if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES: raise HTTPException(413, "Snapshot too large.") _save_json(_drill_file(), {"received_at": _now_iso(), diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py index 469b14c..08e4870 100644 --- a/tests/plugins/career/test_passports.py +++ b/tests/plugins/career/test_passports.py @@ -371,3 +371,44 @@ def test_gig_propose_backfill_offset_survives_stakes(client, meta_db): files = [s["filename"] for s in res.json()["songs"]] assert len(files) == 5 and len(set(files)) == 5 assert "near.feedpak" in files + + +# ── Gold rung ───────────────────────────────────────────────────────────────── + +def test_gold_upgrades_bronze_via_family_style_artifact(client, meta_db): + # Bronze earned on 'death metal' (family: metal); a metal gold artifact + # from the jam verifier upgrades it — bronze-only stays 'earned' elsewhere. + for i in range(5): + meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD) + career_routes._state["passports_content"]["genres"]["metal"] = {} # no drill gate for this test + _open(client, "guitar", "Death Metal") + client.post("/api/plugins/career/drill-state", json={"byNode": {}}) + assert _passport(client, "guitar", "death metal")["badge"] == "earned" + client.post("/api/plugins/career/drill-state", json={ + "byNode": {}, "goldImprov": {"metal": {"at": 1, "verifier": "comb", "inKeyPct": 0.9}}}) + assert _passport(client, "guitar", "death metal")["badge"] == "gold" + + +def test_gold_without_bronze_stays_in_progress(client, meta_db): + meta_db.add("one.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD) + _open(client, "guitar", "Soul") + client.post("/api/plugins/career/drill-state", json={ + "byNode": {}, "goldImprov": {"soul": {"at": 1}}}) + assert _passport(client, "guitar", "soul")["badge"] == "in_progress" + + +def test_gold_merge_is_gained_only(client, meta_db): + for i in range(5): + meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD) + _open(client, "guitar", "Soul") + client.post("/api/plugins/career/drill-state", json={ + "byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}}) + assert _passport(client, "guitar", "soul")["badge"] == "gold" + # A stale relay without the artifact never un-mints. + client.post("/api/plugins/career/drill-state", json={"byNode": {}}) + assert _passport(client, "guitar", "soul")["badge"] == "gold" + # And a different artifact for the same style never overwrites the first. + client.post("/api/plugins/career/drill-state", json={ + "byNode": {}, "goldImprov": {"soul": {"at": 999, "verifier": "yin"}}}) + view = client.get("/api/plugins/career/passports").json() + # (first-artifact-wins is asserted through the intake merge)