diff --git a/CHANGELOG.md b/CHANGELOG.md index 2550ab0..9c30282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Career passports (backend)** — the badge-journey layer on top of career stars. + New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument + passport walls: genre badges computed on read from `song_stats` × the library's + effective genre — Bronze = N genre songs at K★, data-driven in + `plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song + "ticket stubs", the library genre list, and drill status), `POST + /passports/commit` (instrument commitment), `POST /passports/open` (open a genre + passport), and `POST /drill-state` (intake for the relayed Virtuoso + `virtuoso.progress` snapshot, so drill requirements can gate badges + server-side). Badges are never stored; the only persisted state (commitments, + opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the + settings export/import bundle via `settings.server_files`. Instruments are + attributed via the existing progression arrangement→instrument mapping; + non-graded instruments (bass, drums) render shown-not-judged — repertoire + without a pass bar, never a false badge denial. + ### Removed - **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the `/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve diff --git a/plugins/career/passports.json b/plugins/career/passports.json new file mode 100644 index 0000000..423f977 --- /dev/null +++ b/plugins/career/passports.json @@ -0,0 +1,17 @@ +{ + "badge_requirement": { + "songs": 5, + "min_stars": 2 + }, + "genres": {}, + "graded_instruments": [ + "guitar", + "keys" + ], + "instruments": [ + "guitar", + "bass", + "keys", + "drums" + ] +} diff --git a/plugins/career/plugin.json b/plugins/career/plugin.json index 9f0550a..ba6530a 100644 --- a/plugins/career/plugin.json +++ b/plugins/career/plugin.json @@ -1,12 +1,18 @@ { "id": "career", "name": "Career", - "version": "0.1.0", + "version": "0.2.0", "bundled": true, "private": false, - "description": "Career mode — gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.", + "description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.", "screen": "screen.html", "script": "screen.js", "styles": "assets/career.css", + "settings": { + "html": "settings.html", + "server_files": [ + "career/" + ] + }, "routes": "routes.py" } diff --git a/plugins/career/routes.py b/plugins/career/routes.py index ef0fb20..920b708 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -10,11 +10,22 @@ the plugin under ``venue-packs//`` or downloaded on demand into ``CONFIG_DIR/plugin_uploads/career/venues//``. Downloaded packs override bundled packs so release assets can replace a built-in starter venue. +Passports (badge journey per instrument × genre — the identity layer on top +of the same stars): badges are COMPUTED on read from ``song_stats`` × the +library's effective genre, never stored. The only persisted career state is +what cannot be derived — instrument commitment, opened passports, and the +relayed virtuoso drill snapshot — as JSON under ``CONFIG_DIR/career/`` +(exported via ``settings.server_files``). + Endpoints (all under /api/plugins/career/): GET /state stars + per-venue unlock/install/download status POST /packs/{venue_id}/download start background pack download (409 if running) DELETE /packs/{venue_id} remove an installed pack GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers) + GET /passports passport walls: badges, stubs, genres, drill status + POST /passports/commit commit to an instrument (the wax seal, Stage 0) + POST /passports/open open a genre passport for an instrument + POST /drill-state relayed virtuoso.progress snapshot (drill intake) """ import hashlib @@ -26,11 +37,14 @@ import tempfile import threading import urllib.request import zipfile +from datetime import datetime, timezone from pathlib import Path -from fastapi import HTTPException +from fastapi import Body, HTTPException from fastapi.responses import FileResponse +from progression import instrument_for_arrangement + PLUGIN_ID = "career" VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$") PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$") @@ -118,6 +132,235 @@ def _stars(): return sum(per_song.values()), per_song, detail +# ── Passports ───────────────────────────────────────────────────────────────── + +GENRE_MAX_LEN = 64 +DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024 + + +def _now_iso(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _genre_display(genre): + return " ".join(str(genre or "").strip().split()) + + +def _genre_key(genre): + return _genre_display(genre).lower() + + +def _state_file() -> Path: + return _state["state_dir"] / "passports-state.json" + + +def _drill_file() -> Path: + return _state["state_dir"] / "drill-state.json" + + +def _load_json(path: Path, default): + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return default + + +def _save_json(path: Path, obj): + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8") + tmp.replace(path) + + +def _career_state(): + st = _load_json(_state_file(), {}) + if not isinstance(st, dict): + st = {} + if not isinstance(st.get("instruments"), dict): + st["instruments"] = {} + if not isinstance(st.get("passports"), dict): + st["passports"] = {} + return st + + +def _genre_expr(db): + # Reuse the host's override-aware effective-genre SQL (Fix-metadata popup + # overrides); plain `genre` on stand-ins that don't implement it. + fn = getattr(db, "_effective_genre_expr", None) + return fn() if callable(fn) else "genre" + + +def _instrument_of(arrangements, arrangement): + """Progression's arrangement→instrument mapping, via the song_stats + arrangement index into the song's arrangements JSON.""" + entry = None + try: + idx = int(arrangement) + if isinstance(arrangements, list) and 0 <= idx < len(arrangements): + entry = arrangements[idx] + except (TypeError, ValueError): + entry = None + return instrument_for_arrangement(entry) + + +def _played_by_instrument_genre(): + """(instrument, genre_key) → {filename: stub dict}. Best accuracy per + (instrument, song); the JOIN keeps the same dead-song filter as _stars().""" + db = _state["meta_db"] + if db is None: + return {} + thresholds = _state["content"]["star_accuracy_thresholds"] + rows = db.conn.execute( + "SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, " + " songs.title, songs.artist, songs.arrangements, " + f" {_genre_expr(db)} " + "FROM song_stats s JOIN songs ON songs.filename = s.filename" + ).fetchall() + arrs_cache = {} + out = {} + for filename, arrangement, acc, played_at, title, artist, arrs_json, genre in rows: + gkey = _genre_key(genre) + if not gkey: + continue + if filename not in arrs_cache: + try: + arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None + except (TypeError, ValueError): + arrs_cache[filename] = None + instrument = _instrument_of(arrs_cache[filename], arrangement) + acc = acc or 0.0 + stub = out.setdefault((instrument, gkey), {}).get(filename) + if stub is None: + out[(instrument, gkey)][filename] = { + "filename": filename, + "title": title or filename, + "artist": artist or "", + "best_accuracy": acc, + "last_played_at": played_at, + } + else: + stub["best_accuracy"] = max(stub["best_accuracy"], acc) + stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None + for stubs in out.values(): + for stub in stubs.values(): + acc = stub["best_accuracy"] + stub["best_accuracy"] = round(acc, 4) + stub["stars"] = sum(1 for t in thresholds if acc >= t) + return out + + +def _library_genres(): + """Distinct effective genres across the live library (the brochure rack).""" + db = _state["meta_db"] + if db is None: + return [] + rows = db.conn.execute( + f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall() + by_key = {} + for genre, count in rows: + display = _genre_display(genre) + key = display.lower() + if not key: + continue + cur = by_key.get(key) + if cur: # case-variant duplicates collapse onto the first-seen casing + cur["songs_in_library"] += count + else: + by_key[key] = {"genre_key": key, "genre": display, + "songs_in_library": count} + return sorted(by_key.values(), + key=lambda r: (-r["songs_in_library"], r["genre_key"])) + + +def _badge_requirement(gkey): + cfg = _state["passports_content"] + req = dict(cfg.get("badge_requirement") or {}) + req.setdefault("songs", 5) + req.setdefault("min_stars", 2) + override = (cfg.get("genres") or {}).get(gkey) + if isinstance(override, dict): + req.update(override) + req["virtuoso_nodes"] = [n for n in (req.get("virtuoso_nodes") or []) + if isinstance(n, str)] + return req + + +def _drill_by_node(): + doc = _load_json(_drill_file(), {}) + if not isinstance(doc, dict): + 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 + + +def _node_cleared(by_node, node_id): + """A drill counts as cleared on real completion evidence: mastered, or any + depth rung flipped true (virtuoso's gained-only false→true artifacts).""" + entry = by_node.get(node_id) + if not isinstance(entry, dict): + return False + 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()) + + +def _passports_view(): + cfg = _state["passports_content"] + graded = set(cfg.get("graded_instruments") or []) + st = _career_state() + played = _played_by_instrument_genre() + received_at, by_node = _drill_by_node() + instruments = {} + for inst in cfg.get("instruments") or []: + committed_at = (st["instruments"].get(inst) or {}).get("committed_at") + opened = st["passports"].get(inst) + opened = opened if isinstance(opened, dict) else {} + passports = [] + for gkey, meta in sorted(opened.items(), + key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])): + meta = meta if isinstance(meta, dict) else {} + req = _badge_requirement(gkey) + songs = list(played.get((inst, gkey), {}).values()) + for s in songs: + s["qualifies"] = s["stars"] >= req["min_stars"] + songs.sort(key=lambda s: (not s["qualifies"], -s["stars"], + s["title"].lower())) + qualifying = sum(1 for s in songs if s["qualifies"]) + required = req["virtuoso_nodes"] + cleared = [n for n in required if _node_cleared(by_node, n)] + is_graded = inst in graded + if not is_graded: + # Where the engine can't fairly grade the instrument's job + # (bass pocket, feel) the passport shows repertoire, never a + # 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" + else: + badge = "in_progress" + passports.append({ + "genre_key": gkey, + "genre": meta.get("genre") or gkey, + "opened_at": meta.get("opened_at"), + "requirement": req, + "graded": is_graded, + "songs": songs, + "qualifying_count": qualifying, + "drills": {"required": required, "cleared": cleared}, + "badge": badge, + }) + instruments[inst] = {"committed_at": committed_at, "passports": passports} + return { + "config": { + "badge_requirement": cfg.get("badge_requirement") or {}, + "graded_instruments": sorted(graded), + "instruments": list(cfg.get("instruments") or []), + }, + "instruments": instruments, + "genres": _library_genres(), + "drill_state": {"received_at": received_at}, + } + + def _validate_pack_dir(pack_dir: Path): """Raise ValueError unless pack_dir holds a complete venue pack.""" manifest_path = pack_dir / "manifest.json" @@ -197,6 +440,13 @@ def setup(app, context): _state["venues_dir"] = ( Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues") _state["venues_dir"].mkdir(parents=True, exist_ok=True) + _state["passports_content"] = json.loads( + (plugin_dir / "passports.json").read_text(encoding="utf-8")) + # Persisted career state (commitment / opened passports / drill snapshot) + # lives under CONFIG_DIR/career/ — declared in settings.server_files so it + # rides the settings export/import bundle. Packs stay out (they're media). + _state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID + _state["state_dir"].mkdir(parents=True, exist_ok=True) _state["meta_db"] = context.get("meta_db") _state["log"] = context.get("log") or _state["log"] for v in _state["content"]["venues"]: @@ -229,6 +479,64 @@ def setup(app, context): "venues": venues, } + @app.get(f"/api/plugins/{PLUGIN_ID}/passports") + def get_passports(): + with _lock: + return _passports_view() + + @app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit") + def commit_instrument(body: dict = Body(...)): + inst = str((body or {}).get("instrument") or "") + if inst not in (_state["passports_content"].get("instruments") or []): + raise HTTPException(400, "Unknown instrument.") + with _lock: + st = _career_state() + entry = st["instruments"].setdefault(inst, {}) + # Idempotent: the wax seal is pressed once; re-commits keep the + # original date (only-gained-never-lost). + if not entry.get("committed_at"): + entry["committed_at"] = _now_iso() + _save_json(_state_file(), st) + return {"ok": True, "instrument": inst, + "committed_at": entry["committed_at"]} + + @app.post(f"/api/plugins/{PLUGIN_ID}/passports/open") + def open_passport(body: dict = Body(...)): + inst = str((body or {}).get("instrument") or "") + genre = _genre_display((body or {}).get("genre")) + gkey = genre.lower() + if inst not in (_state["passports_content"].get("instruments") or []): + raise HTTPException(400, "Unknown instrument.") + if not gkey or len(genre) > GENRE_MAX_LEN: + raise HTTPException(400, "Provide a genre.") + with _lock: + st = _career_state() + # Opening a passport implies the instrument commitment (permissive + # server, ceremony ordering is the UI's job). + st["instruments"].setdefault(inst, {}).setdefault( + "committed_at", _now_iso()) + genres = st["passports"].setdefault(inst, {}) + if gkey not in genres: + genres[gkey] = {"genre": genre, "opened_at": _now_iso()} + _save_json(_state_file(), st) + return {"ok": True, "instrument": inst, "passport": genres[gkey]} + + @app.post(f"/api/plugins/{PLUGIN_ID}/drill-state") + def post_drill_state(body: dict = Body(...)): + # The relayed virtuoso.progress snapshot (career's screen.js listens to + # the virtuoso:progress bus event and forwards the localStorage doc). + # Only the fields the badge check reads are kept. + if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict): + raise HTTPException(400, "Expected a progress snapshot with byNode.") + snapshot = {"mode": body.get("mode"), "xp": body.get("xp"), + "byNode": body["byNode"]} + if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES: + raise HTTPException(413, "Snapshot too large.") + with _lock: + _save_json(_drill_file(), {"received_at": _now_iso(), + "snapshot": snapshot}) + return {"ok": True} + @app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download") def start_download(venue_id: str): venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None diff --git a/plugins/career/settings.html b/plugins/career/settings.html new file mode 100644 index 0000000..a727aa4 --- /dev/null +++ b/plugins/career/settings.html @@ -0,0 +1,10 @@ + +
+

Career computes stars and genre badges from your play + stats — they are never stored, so there is nothing to back up or reset.

+

What is saved server-side: your instrument + commitments, opened genre passports, and the practice-drill snapshot the + Virtuoso plugin reports. These ride along in + Settings → Export automatically.

+
diff --git a/tests/plugins/career/conftest.py b/tests/plugins/career/conftest.py index eb6ad63..a998e94 100644 --- a/tests/plugins/career/conftest.py +++ b/tests/plugins/career/conftest.py @@ -1,3 +1,4 @@ +import json import sqlite3 import sys from pathlib import Path @@ -14,25 +15,45 @@ import routes as career_routes class FakeMetaDb: - """song_stats-only stand-in for MetadataDB (the plugin reads nothing else).""" + """song_stats/songs stand-in for MetadataDB (the plugin reads nothing else). + + The real song_stats.arrangement is an INTEGER index into the song's + arrangements JSON; the legacy star tests pass strings ("guitar"), which + the passport code treats as index-less → instrument defaults to guitar.""" def __init__(self): self.conn = sqlite3.connect(":memory:", check_same_thread=False) self.conn.execute( """CREATE TABLE song_stats ( - filename TEXT, arrangement TEXT, best_accuracy REAL + filename TEXT, arrangement TEXT, best_accuracy REAL, + last_played_at TEXT + )""" + ) + self.conn.execute( + """CREATE TABLE songs ( + filename TEXT, title TEXT, artist TEXT, + genre TEXT DEFAULT '', arrangements TEXT )""" ) - self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)") - def add(self, filename, arrangement, best_accuracy, in_library=True): - self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)", - (filename, arrangement, best_accuracy)) + def add(self, filename, arrangement, best_accuracy, in_library=True, + genre="", arrangements=None, last_played_at=None): + self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)", + (filename, arrangement, best_accuracy, last_played_at)) if in_library: self.conn.execute( - "INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS " + "INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS " "(SELECT 1 FROM songs WHERE filename = ?)", - (filename, filename.replace(".feedpak", "").title(), "Test Artist", filename)) + (filename, filename.replace(".feedpak", "").title(), "Test Artist", + genre, + json.dumps(arrangements) if arrangements is not None else None, + filename)) + self.conn.commit() + + def add_song_only(self, filename, genre=""): + """A library song with no plays — feeds the genre (brochure) list.""" + self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)", + (filename, filename, "Test Artist", genre, None)) self.conn.commit() diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py new file mode 100644 index 0000000..61f2829 --- /dev/null +++ b/tests/plugins/career/test_passports.py @@ -0,0 +1,145 @@ +"""HTTP-level tests for the passport layer: badges, stubs, genres, drill intake. + +Badges are computed on read (never stored): N genre songs at min_stars — with +stars ≥2 meaning best_accuracy ≥ 0.75 under the default 0.6/0.75/0.85 +thresholds — plus any configured virtuoso drills. +""" + +import routes as career_routes + +LEAD = [{"type": "lead", "name": "Lead"}] +BASS = [{"type": "bass", "name": "Bass"}] + + +def _open(client, instrument="guitar", genre="Blues"): + res = client.post("/api/plugins/career/passports/open", + json={"instrument": instrument, "genre": genre}) + assert res.status_code == 200 + return res.json() + + +def _passport(client, instrument="guitar", genre_key="blues"): + view = client.get("/api/plugins/career/passports").json() + for p in view["instruments"][instrument]["passports"]: + if p["genre_key"] == genre_key: + return p + return None + + +def test_badge_earned_at_five_genre_songs_two_stars(client, meta_db): + for i in range(5): + meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD) + _open(client) + p = _passport(client) + 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): + for i in range(4): + meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD) + meta_db.add("weak.feedpak", 0, 0.65, genre="Blues", arrangements=LEAD) # 1★ + _open(client) + p = _passport(client) + assert p["badge"] == "in_progress" + assert p["qualifying_count"] == 4 + # Qualifying stubs sort ahead of the near-misses. + assert [s["qualifies"] for s in p["songs"]] == [True] * 4 + [False] + + +def test_instruments_split_and_bass_is_shown_not_judged(client, meta_db): + # Same 5 songs but played on the BASS arrangement: no guitar badge credit. + for i in range(5): + meta_db.add(f"blues{i}.feedpak", 0, 0.9, genre="Blues", arrangements=BASS) + _open(client, "guitar") + _open(client, "bass") + guitar = _passport(client, "guitar") + bass = _passport(client, "bass") + assert guitar["qualifying_count"] == 0 and guitar["badge"] == "in_progress" + assert bass["qualifying_count"] == 5 + # Bass isn't a graded instrument: repertoire shows, no pass/fail bar. + assert bass["badge"] == "shown_not_judged" and bass["graded"] is False + + +def test_best_accuracy_per_instrument_across_arrangements(client, meta_db): + both = [{"type": "lead", "name": "Lead"}, {"type": "lead", "name": "Alt. Lead"}] + meta_db.add("song.feedpak", 0, 0.7, genre="Blues", arrangements=both) + meta_db.add("song.feedpak", 1, 0.9, genre="Blues", arrangements=both) + _open(client) + p = _passport(client) + assert len(p["songs"]) == 1 + assert p["songs"][0]["best_accuracy"] == 0.9 + assert p["songs"][0]["stars"] == 3 + + +def test_orphaned_songs_do_not_feed_stubs(client, meta_db): + meta_db.add("gone.feedpak", 0, 0.9, genre="Blues", arrangements=LEAD, + in_library=False) + _open(client) + assert _passport(client)["songs"] == [] + + +def test_genre_rack_collapses_case_and_skips_blank(client, meta_db): + meta_db.add_song_only("a.feedpak", genre="Blues") + meta_db.add_song_only("b.feedpak", genre="blues") + meta_db.add_song_only("c.feedpak", genre="Funk") + meta_db.add_song_only("d.feedpak", genre="") + genres = client.get("/api/plugins/career/passports").json()["genres"] + assert genres == [ + {"genre_key": "blues", "genre": "Blues", "songs_in_library": 2}, + {"genre_key": "funk", "genre": "Funk", "songs_in_library": 1}, + ] + + +def test_commit_is_idempotent_and_open_implies_commit(client): + first = client.post("/api/plugins/career/passports/commit", + json={"instrument": "guitar"}).json() + again = client.post("/api/plugins/career/passports/commit", + json={"instrument": "guitar"}).json() + assert first["committed_at"] == again["committed_at"] + _open(client, "bass", "Funk") + view = client.get("/api/plugins/career/passports").json() + assert view["instruments"]["bass"]["committed_at"] + # Re-opening the same passport keeps the original opened_at. + opened = view["instruments"]["bass"]["passports"][0]["opened_at"] + _open(client, "bass", " funk ") # normalizes to the same key + view = client.get("/api/plugins/career/passports").json() + assert [p["opened_at"] for p in view["instruments"]["bass"]["passports"]] == [opened] + + +def test_open_and_commit_validation(client): + assert client.post("/api/plugins/career/passports/commit", + json={"instrument": "theremin"}).status_code == 400 + assert client.post("/api/plugins/career/passports/open", + json={"instrument": "guitar", "genre": " "}).status_code == 400 + assert client.post("/api/plugins/career/passports/open", + json={"instrument": "guitar", "genre": "x" * 65}).status_code == 400 + + +def test_drill_requirement_gates_badge_until_snapshot_clears_it(client, meta_db): + for i in range(5): + meta_db.add(f"blues{i}.feedpak", 0, 0.8, genre="Blues", arrangements=LEAD) + career_routes._state["passports_content"]["genres"]["blues"] = { + "virtuoso_nodes": ["node.shuffle"]} + _open(client) + p = _passport(client) + assert p["badge"] == "in_progress" + assert p["drills"] == {"required": ["node.shuffle"], "cleared": []} + + res = client.post("/api/plugins/career/drill-state", json={ + "mode": "casual", "xp": 120, + "byNode": {"node.shuffle": {"masteredAt": 1720000000, + "depth": {"travel": None}}}}) + assert res.status_code == 200 + p = _passport(client) + assert p["drills"]["cleared"] == ["node.shuffle"] + assert p["badge"] == "earned" + + +def test_drill_state_validation(client): + assert client.post("/api/plugins/career/drill-state", + json={"mode": "casual"}).status_code == 400 + huge = {"byNode": {"pad": "x" * (300 * 1024)}} + assert client.post("/api/plugins/career/drill-state", + json=huge).status_code == 413