mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 03:24:30 +00:00
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:
co-authored by
Claude Fable 5
parent
0fc6a4beed
commit
1d014a5575
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user