mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-19 11:21:31 +00:00
Career v3: Gold tier — verified improv upgrades an earned badge (#960)
* feat(career): Gold tier — a family-style goldImprov artifact upgrades an earned badge
The drill-state relay's goldImprov map (virtuoso gold_improv mints,
gained-only merged like drill nodes) turns an earned badge gold when the
passport's genre — or its genre family — has a verified improv artifact.
Gold never substitutes for the badge bar: gold-without-bronze stays
in_progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(career): Gold tier frontend — relay, ceremony, slam, gold ink everywhere
The drill-state relay now carries virtuoso's goldImprov map; a badge that
comes back gold gets its own ceremony + notification (tier-suffixed seen
ids — the bronze moment stays seen under its legacy id, a gold slam marks
both), a gold stamp slam in the book, gold ink on the shelf-cover mini
stamp, and the real gold foil chip. The bronze page's dashed 'Gold rung
coming' preview becomes a live invitation to jam the style in Virtuoso.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(career): gold review fixes — family-space style matching, intake guards, rail counter
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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0d35228d56
commit
be473dc7af
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 = `
|
||||
<canvas class="pp-confetti"></canvas>
|
||||
<div class="pp-ceremony-card">
|
||||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp${p.badge === 'gold' ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
<span class="pp-stamp-tier">${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</span>
|
||||
</div>
|
||||
<div class="pp-ceremony-title">Badge earned</div>
|
||||
<div class="pp-ceremony-title">${p.badge === 'gold' ? 'Gold — a verified improv' : 'Badge earned'}</div>
|
||||
<div class="pp-ceremony-sub">${esc(p.genre)} — ${esc(ppLabel(inst))} passport</div>
|
||||
</div>`;
|
||||
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
|
||||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
||||
? `<span class="pp-stamp pp-stamp-mini${p.badge === 'gold' ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</span>`
|
||||
: '';
|
||||
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 `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
||||
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||||
@ -641,13 +653,15 @@
|
||||
let badgeArea = '';
|
||||
if (p.badge === 'shown_not_judged') {
|
||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||
} else if (p.badge === 'earned') {
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
} else if (p.badge === 'earned' || p.badge === 'gold') {
|
||||
const gold = p.badge === 'gold';
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}${gold ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
<span class="pp-stamp-tier">${gold ? 'GOLD' : 'BRONZE'}</span>
|
||||
</div>
|
||||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>
|
||||
${gold
|
||||
? '<div class="pp-gold-foil" aria-hidden="true">GOLD</div><div class="pp-gold-note">A verified improv — the comb heard it live.</div>'
|
||||
: '<div class="pp-gold-note">Gold rung: improvise over this style in a Virtuoso jam — verified, not self-reported.</div>'}
|
||||
<div class="pp-card-actions">
|
||||
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user