From d8807c139ee8b7d43942a8186512d0feb0dd6f10 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 10:36:06 +0200 Subject: [PATCH] =?UTF-8?q?fix(career):=20gold=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20family-space=20style=20matching,=20intake=20guards,?= =?UTF-8?q?=20rail=20counter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's showstopper: virtuoso mints goldImprov under raw STYLE_PALETTES ids ('punk', 'djent', 'disco'), which are mostly NOT family keys — the tier check now matches in family space (artifact style and passport genre bucket through the same _genre_family keyword match), so a 'punk' gold reaches a 'punk rock' passport. Also: non-dict goldImprov 400s loudly instead of silently dropping; evidence-free artifacts (no verifier) never mint; goldImprov gets the same pre-merge size bound byNode has (junk under the cap could otherwise persist forever and wedge every later relay at the post-merge check); the instrument-rail badge counter counts gold (earning gold no longer made a badge vanish from the rail); first-artifact-wins is now asserted against the persisted snapshot instead of vacuously. Co-Authored-By: Claude Fable 5 --- plugins/career/routes.py | 32 +++++++++++++++----- plugins/career/screen.js | 2 +- tests/plugins/career/test_passports.py | 41 +++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/plugins/career/routes.py b/plugins/career/routes.py index 3c1e889..90be728 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -426,11 +426,19 @@ def _passports_view(): badge = "shown_not_judged" elif qualifying >= req["songs"] and len(cleared) == len(required): # 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" + # artifact exists for this genre's jam style. Virtuoso mints + # under raw STYLE_PALETTES ids ('punk', 'djent', 'disco', ...), + # which are mostly NOT family keys — so match in family space: + # the same keyword bucketing genres get ('punk' and 'punk + # rock' both bucket to 'rock'), with the exact key as a direct + # hit. Bronze remains a standalone win; gold never becomes an + # obligation. + fam = _genre_family(gkey) + gold = any( + s == gkey or (fam is not None and _genre_family(s) == fam) + for s in gold_improv + ) + badge = "gold" if gold else "earned" else: badge = "in_progress" # Practice invitation: the non-qualifying songs closest to the @@ -703,8 +711,18 @@ 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 {} + gold_in = body.get("goldImprov", {}) + if not isinstance(gold_in, dict): + # A relay bug must be LOUD, not a silent 200 that drops gold. + raise HTTPException(400, "goldImprov must be an object keyed by style id.") + # Keep only plausible artifacts: a dict that names its verifier — + # an empty {} must not mint an evidence-free gold. + gold_in = {k: v for k, v in gold_in.items() + if isinstance(v, dict) and v.get("verifier")} + # Same pre-merge bound byNode gets: the gained-only merge dropping + # junk must not become a size-guard bypass (nor lock-held CPU burn). + if len(json.dumps(gold_in)) > DRILL_SNAPSHOT_MAX_BYTES: + raise HTTPException(413, "Snapshot too large.") with _lock: _, existing, existing_gold = _drill_by_node() snapshot = {"mode": body.get("mode"), "xp": body.get("xp"), diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 99d39c2..c981625 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -611,7 +611,7 @@ const data = (_pp.instruments || {})[inst] || { passports: [] }; host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => { const d = (_pp.instruments || {})[i] || {}; - const earned = (d.passports || []).filter((p) => p.badge === 'earned').length; + const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold').length; const committed = !!d.committed_at; return `