diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d7e7b..afe1975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Gold tier (career passports)** — an earned badge turns **gold** when + Virtuoso verifies an improvised jam in the passport's style (the + `gold_improv` artifact relays with the drill snapshot; a genre inherits its + family's style, gained-only, and gold never substitutes for the badge bar + itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on + the shelf cover, profile wall, and passport card; the bronze page's "Gold + rung coming" preview becomes a live invitation to jam it. - **Gigs (the career verb, frontend)** — book a gig from any opened passport: a gig poster proposes the setlist (re-roll for a different bill; save or copy the poster as a PNG), "Play the gig" hands the set to the play queue diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 49a6741..91d9344 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -62,7 +62,7 @@ and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` (2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js` (3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js` -(1,516 — career v3 gigs pushed it over; split plan: carve the gig block into a +(1,530 — career v3 gigs + gold pushed it over; split plan: carve the gig block into a `scriptType: module` file when career work next touches it) — and every monolith with a PR train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope by policy — the norm governs source files. diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index c89cd5b..921eae3 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -525,7 +525,18 @@ pointer-events: none; } -/* Gold foil preview — honest "coming", never earnable-looking. */ +/* Gold ink — a REAL gold badge (comb-verified improv). */ +.pp-stamp-gold { + border-color: #b8860b; + color: #a97b1b; + box-shadow: inset 0 0 0 3px #f3e8c8, inset 0 0 0 4px #b8860b; +} +.pp-stamp-mini.pp-stamp-gold { + color: #f0c75e; + border-color: #f0c75e; + box-shadow: none; +} +/* Gold foil chip — rendered only alongside an earned gold stamp. */ .pp-gold-foil { position: relative; overflow: hidden; @@ -535,8 +546,8 @@ margin-top: 0.9rem; padding: 0.28rem 0.85rem; border-radius: 999px; - border: 2px dashed #c8b273; - color: #a8946d; + border: 2px solid #d9a253; + color: #c89040; font-size: 0.58rem; font-weight: 700; letter-spacing: 0.32em; diff --git a/plugins/career/routes.py b/plugins/career/routes.py index b8bc5e1..90be728 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,20 @@ 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. 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 @@ -687,10 +711,23 @@ 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", {}) + 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 = _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/plugins/career/screen.js b/plugins/career/screen.js index 03b1bcc..c981625 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -304,11 +304,15 @@ } catch (_) { return {}; } } - function badgeId(inst, gkey) { return inst + '/' + gkey; } + // Bronze keeps the legacy un-suffixed id, so badges seen before the Gold + // tier existed stay seen; gold is a distinct moment with its own id. + function badgeId(inst, gkey, tier) { return inst + '/' + gkey + (tier === 'gold' ? '@gold' : ''); } - function markBadgeSeen(inst, gkey) { + function markBadgeSeen(inst, gkey, tier) { const seen = seenBadges(); - seen[badgeId(inst, gkey)] = 1; + seen[badgeId(inst, gkey, tier)] = 1; + // A gold slam covers the bronze moment too — never queue both. + if (tier === 'gold') seen[badgeId(inst, gkey)] = 1; lsSet(PP_SEEN_KEY, JSON.stringify(seen)); } @@ -320,15 +324,19 @@ const seen = seenBadges(); for (const inst of Object.keys(view.instruments || {})) { for (const p of (view.instruments[inst].passports || [])) { - const id = badgeId(inst, p.genre_key); - if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue; + if (p.badge !== 'earned' && p.badge !== 'gold') continue; + const gold = p.badge === 'gold'; + const id = badgeId(inst, p.genre_key, p.badge); + if (seen[id] || _ppNotified[id]) continue; _ppNotified[id] = true; sfx('chime'); if (window.fbNotify && typeof window.fbNotify.show === 'function') { window.fbNotify.show({ - big: true, icon: '🛂', accent: '#b45309', - title: 'Badge earned!', - message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`, + big: true, icon: gold ? '🏅' : '🛂', accent: gold ? '#d9a253' : '#b45309', + title: gold ? 'Gold — a verified improv!' : 'Badge earned!', + message: gold + ? `${p.genre} — your ${ppLabel(inst)} badge turns gold.` + : `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`, }); } badgeCeremony(inst, p); @@ -377,11 +385,11 @@ el.innerHTML = `
-
+
${esc(p.genre.toUpperCase())} - BRONZE + ${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}
-
Badge earned
+
${p.badge === 'gold' ? 'Gold — a verified improv' : 'Badge earned'}
${esc(p.genre)} — ${esc(ppLabel(inst))} passport
`; let timer = 0; @@ -445,7 +453,11 @@ fetch(`${API}/drill-state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }), + body: JSON.stringify({ + mode: snap.mode, xp: snap.xp, byNode: snap.byNode, + ...(snap.goldImprov && typeof snap.goldImprov === 'object' && !Array.isArray(snap.goldImprov) + ? { goldImprov: snap.goldImprov } : {}), + }), }).then(() => refreshPassports()).catch(() => { /* next event retries */ }); }, 1500); } @@ -483,9 +495,9 @@ function ppCoverHTML(inst, p) { const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2); - const earned = p.badge === 'earned'; + const earned = p.badge === 'earned' || p.badge === 'gold'; const stamp = earned - ? `BRONZE` + ? `${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}` : ''; const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`; const hours = fmtHours(p.seconds_total); @@ -599,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 ` @@ -724,7 +738,8 @@ if (!p || !overlay) return; _ppBook = { inst, gkey }; _ppReturnFocus = document.activeElement; - const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)]; + const pending = (p.badge === 'earned' || p.badge === 'gold') + && !seenBadges()[badgeId(inst, gkey, p.badge)]; overlay.innerHTML = ppBookHTML(inst, p, pending); overlay.classList.remove('hidden'); const close = overlay.querySelector('.pp-book-close'); @@ -746,7 +761,7 @@ stamp.classList.add('pp-tilt'); // freshly slammed = trading card too if (book) book.classList.add('pp-shake'); sfx('stamp'); - markBadgeSeen(inst, gkey); + markBadgeSeen(inst, gkey, p.badge); renderPassports(); // the shelf cover gains its mini-stamp }, 950); } diff --git a/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js index ff36bbf..a106d5c 100644 --- a/plugins/career/tests/passports.test.js +++ b/plugins/career/tests/passports.test.js @@ -198,3 +198,52 @@ test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => t.onGigSongStop(); assert.equal(t.getGigRun(), null); }); + +test('a gold upgrade notifies even when the bronze moment was already seen', () => { + // Bronze seen under the legacy un-suffixed id; the badge then turns gold. + const w = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' }); + const t = w.__careerPassportTest; + const view = { instruments: { guitar: { passports: [ + { genre_key: 'blues', genre: 'Blues', badge: 'gold' }] } } }; + t.detectNewBadges(view); + assert.equal(w.notifications.length, 1); + assert.match(w.notifications[0].title, /Gold/); + // Same session: no duplicate. + t.detectNewBadges(view); + assert.equal(w.notifications.length, 1); + // Gold slam seen → fresh session stays silent. + t.markBadgeSeen('guitar', 'blues', 'gold'); + const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(t.seenBadges()) }); + w2.__careerPassportTest.detectNewBadges(view); + assert.equal(w2.notifications.length, 0); +}); + +test('a gold slam marks the bronze moment seen too — never both ceremonies', () => { + const w = load(); + const t = w.__careerPassportTest; + t.markBadgeSeen('guitar', 'blues', 'gold'); + const seen = JSON.parse(JSON.stringify(t.seenBadges())); + assert.equal(seen['guitar/blues@gold'], 1); + assert.equal(seen['guitar/blues'], 1); + // A later view where the badge reads 'earned' (e.g. gold state lost + // server-side) must not replay the bronze ceremony. + const view = { instruments: { guitar: { passports: [ + { genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } }; + const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(seen) }); + w2.__careerPassportTest.detectNewBadges(view); + assert.equal(w2.notifications.length, 0); +}); + +test('careerTotals counts gold badges on the wall', () => { + const t = load().__careerPassportTest; + t.setView({ + config: { instruments: ['guitar'] }, + instruments: { guitar: { committed_at: 1, gig_count: 0, passports: [ + { genre_key: 'blues', genre: 'Blues', badge: 'gold', seconds_total: 60 }, + { genre_key: 'funk', genre: 'Funk', badge: 'in_progress', seconds_total: 0 }, + ] } }, + }); + const totals = t.careerTotals(); + assert.equal(totals.badges, 1); + assert.equal(totals.walls[0].earned[0].badge, 'gold'); +}); diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py index 469b14c..8687565 100644 --- a/tests/plugins/career/test_passports.py +++ b/tests/plugins/career/test_passports.py @@ -371,3 +371,77 @@ 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, "verifier": "comb"}}}) + 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 — + # asserted against the PERSISTED snapshot (the view doesn't expose + # artifact contents), so a last-write-wins regression can't stay green. + client.post("/api/plugins/career/drill-state", json={ + "byNode": {}, "goldImprov": {"soul": {"at": 999, "verifier": "yin"}}}) + _, _, gold = career_routes._drill_by_node() + assert gold["soul"] == {"at": 1, "verifier": "comb"} + + +def test_gold_matches_raw_style_id_through_family(client, meta_db): + # Virtuoso mints under raw STYLE_PALETTES ids ('punk', not 'rock'): a + # 'punk rock' passport (family rock) must go gold from a 'punk' artifact. + for i in range(5): + meta_db.add(f"pk{i}.feedpak", 0, 0.9, genre="Punk Rock", arrangements=LEAD) + career_routes._state["passports_content"]["genres"]["rock"] = {} # no drill gate + _open(client, "guitar", "Punk Rock") + client.post("/api/plugins/career/drill-state", json={ + "byNode": {}, "goldImprov": {"punk": {"at": 1, "verifier": "comb"}}}) + assert _passport(client, "guitar", "punk rock")["badge"] == "gold" + + +def test_gold_intake_rejects_junk(client, meta_db): + # A non-dict goldImprov is a relay bug: loud 400, never a silent drop. + res = client.post("/api/plugins/career/drill-state", + json={"byNode": {}, "goldImprov": ["metal"]}) + assert res.status_code == 400 + # Evidence-free artifacts (no verifier) never mint. + for i in range(5): + meta_db.add(f"j{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD) + _open(client, "guitar", "Soul") + client.post("/api/plugins/career/drill-state", json={ + "byNode": {}, "goldImprov": {"soul": {}}}) + assert _passport(client, "guitar", "soul")["badge"] == "earned" + # An oversized goldImprov is bounded BEFORE the merge, like byNode. + blob = {f"s{i}": {"verifier": "comb", "pad": "x" * 4096} for i in range(200)} + res = client.post("/api/plugins/career/drill-state", + json={"byNode": {}, "goldImprov": blob}) + assert res.status_code == 413