diff --git a/CHANGELOG.md b/CHANGELOG.md index 584c506..8e68fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ 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. +- **Career passports (UI)** — the Career screen gains a Passports tab beside + Venues: a physical per-instrument passport book (embossed leather cover, 3D + page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge + slam with ink bleed and deterministic per-genre jitter, qualifying songs as + collected ticket stubs, and unopened genres as an "Explore next" + travel-brochure rack (invitations, never greyed-out slots or completion + meters). Badge earns chime + notify immediately; the stamp slam plays when + the passport is next opened. Four small synthesized sound effects ship as + plugin assets. The career screen also relays the Virtuoso `virtuoso.progress` + localStorage snapshot to the drill-state intake on `virtuoso:progress` bus + events (debounced, plus a one-time bootstrap), closing the + fires-into-a-void seam without touching the virtuoso plugin. - **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing stopped core from reading a manifest key the spec never defined, which is exactly what happened with @@ -53,6 +80,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry). ### Fixed +- **Career passports review polish** — the passport tabs and book overlay carry + proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`; + `role="dialog"` + `aria-modal` with focus moved to the close button on open + and restored on close), and a corrupt stored seen-badges value (e.g. a stray + `"null"`) can no longer throw on every passport refresh. - **The packaged desktop app could not start (`ModuleNotFoundError: No module named 'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`, diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index 5fac8c2..b6c09b9 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -64,3 +64,348 @@ .career-star-row .song .artist { color: #9ca3af; } .career-star-row .hint { color: #6b7280; white-space: nowrap; } .career-star-row .hint.close { color: #22d3ee; } + +/* ── Passports (badge journey) ─────────────────────────────────────────── */ + +.career-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1rem; + border-bottom: 1px solid rgba(55, 65, 81, 0.6); +} +.career-tab { + padding: 0.375rem 0.875rem; + font-size: 0.85rem; + color: #9ca3af; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.career-tab:hover { color: #e5e7eb; } +.career-tab.active { color: #fff; border-bottom-color: #06b6d4; } + +.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; } +.pp-inst { + padding: 0.3rem 0.8rem; + border-radius: 999px; + font-size: 0.8rem; + color: #d1d5db; + background-color: rgba(31, 41, 55, 0.7); + border: 1px solid transparent; +} +.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); } +.pp-inst.active { border-color: #06b6d4; color: #fff; } +.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); } +.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; } +.pp-inst-plus { color: #6b7280; } + +/* Leather covers — per-instrument hue, embossed with layered shadows and a + subtle grain gradient (no image assets). */ +.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); } +.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); } +.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); } +.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); } + +.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; } +.pp-cover, .pp-commit-cover { + position: relative; + width: 9.5rem; + height: 13rem; + border-radius: 0.5rem 0.75rem 0.75rem 0.5rem; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.06), + inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8), + 0 6px 16px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.75rem; + text-align: center; +} +.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; } +.pp-cover:hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); } +.pp-cover-title { + font-weight: 700; + font-size: 0.85rem; + letter-spacing: 0.14em; + color: rgba(240, 226, 195, 0.92); + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12); + overflow-wrap: anywhere; +} +.pp-cover-inst { + font-size: 0.6rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: rgba(240, 226, 195, 0.55); +} +.pp-cover-sub { + position: absolute; + bottom: 0.6rem; + font-size: 0.6rem; + color: rgba(240, 226, 195, 0.5); +} + +.pp-commit-card { + display: flex; + gap: 1.25rem; + align-items: center; + padding: 1rem; + border-radius: 0.75rem; + border: 1px solid rgba(55, 65, 81, 0.6); + background-color: rgba(31, 41, 55, 0.35); +} +.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; } + +.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); } +.pp-brochure { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + padding: 0.75rem 0.875rem; + border-radius: 0.5rem; + text-align: left; + background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55)); + border: 1px solid rgba(75, 85, 99, 0.5); + transition: transform 0.15s ease, border-color 0.15s ease; +} +.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; } +.pp-brochure-art { font-size: 1.4rem; } +.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; } +.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; } + +/* The open book */ +.pp-overlay { position: fixed; inset: 0; z-index: 60; } +.pp-book-wrap { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(3, 7, 18, 0.72); + backdrop-filter: blur(2px); +} +.pp-book { + position: relative; + width: min(92vw, 720px); + height: min(72vh, 470px); + perspective: 1800px; +} +.pp-page { + position: absolute; + top: 0; + bottom: 0; + width: 50%; + background: + linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%), + #efe6d0; + color: #3f3428; + padding: 1.1rem 1.2rem; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; } +.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); } +.pp-book.open .pp-page-left { opacity: 1; } +.pp-book-cover { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 50%; + border-radius: 0 0.6rem 0.6rem 0; + transform-origin: left center; + transform: rotateY(0deg); + backface-visibility: hidden; + transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1); + z-index: 5; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5); +} +.pp-book.open .pp-book-cover { transform: rotateY(-180deg); } +.pp-book-close { + position: absolute; + top: -0.75rem; + right: -0.75rem; + z-index: 8; + width: 2rem; + height: 2rem; + border-radius: 999px; + background: rgba(17, 24, 39, 0.95); + color: #d1d5db; + border: 1px solid rgba(107, 114, 128, 0.5); +} +.pp-book-close:hover { color: #fff; border-color: #06b6d4; } +.pp-page-head { + font-size: 0.7rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #8a7a5e; + border-bottom: 1px solid rgba(138, 122, 94, 0.35); + padding-bottom: 0.4rem; + width: 100%; + text-align: center; +} + +/* The rubber stamp */ +.pp-stamp { + --pp-rot: 0deg; + + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.1rem; + width: 9rem; + height: 9rem; + border-radius: 999px; + border: 3px solid #9a5b16; + box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16; + color: #9a5b16; + transform: rotate(var(--pp-rot)); + margin-top: 1.25rem; + text-align: center; + padding: 0.75rem; + opacity: 0.92; +} +.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; } +.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; } +.pp-stamp-ghost { + border-style: dashed; + box-shadow: none; + border-color: #b3a68b; + color: #b3a68b; + opacity: 0.8; +} +.pp-stamp-hidden { opacity: 0; } +.pp-stamp-mini { + position: absolute; + top: 0.5rem; + right: 0.5rem; + width: auto; + height: auto; + border-width: 2px; + box-shadow: none; + border-radius: 999px; + font-size: 0.5rem; + font-weight: 800; + letter-spacing: 0.2em; + color: #d9a253; + border-color: #d9a253; + padding: 0.2rem 0.4rem; + margin: 0; + display: inline-block; + transform: rotate(var(--pp-rot)); + opacity: 0.95; +} +.pp-stamp-page::after { + content: ''; + position: absolute; + inset: -10%; + border-radius: 999px; + background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%); + filter: blur(5px); + opacity: 0; + pointer-events: none; +} +.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; } +.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; } +@keyframes pp-slam { + 0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; } + 55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; } + 75% { transform: rotate(var(--pp-rot)) scale(1.05); } + 100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; } +} +@keyframes pp-ink { + from { opacity: 0; transform: scale(0.6); } + to { opacity: 1; transform: scale(1); } +} +.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; } +@keyframes pp-shake { + 0%, 100% { transform: translate(0, 0) rotate(0); } + 25% { transform: translate(2px, 1px) rotate(0.3deg); } + 50% { transform: translate(-2px, 2px) rotate(-0.25deg); } + 75% { transform: translate(1px, -1px) rotate(0.15deg); } +} + +.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; } +.pp-invite { color: #6d5d40; } +.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; } +.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; } +.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; } +.pp-drill.cleared { color: #4d7c0f; } + +/* Ticket stubs */ +.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; } +.pp-stub { + background: #f7f1e3; + border: 1px solid #d8cbaa; + border-left: 2px dashed #b6a98c; + border-radius: 0.25rem 0.4rem 0.4rem 0.25rem; + padding: 0.4rem 0.6rem 0.4rem 0.75rem; + display: grid; + grid-template-columns: auto 1fr; + column-gap: 0.6rem; + align-items: baseline; + box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15); +} +.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; } +.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; } +.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; } +.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; } + +/* Wax-seal commitment ceremony */ +.pp-ceremony { width: 11rem; height: 15rem; } +.pp-wax { + position: absolute; + bottom: 1.4rem; + display: flex; + align-items: center; + justify-content: center; + width: 3.4rem; + height: 3.4rem; + border-radius: 999px; + background: + radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%); + box-shadow: + inset 0 0 0 4px rgba(124, 20, 20, 0.9), + inset 0 2px 4px rgba(255, 255, 255, 0.25), + 0 3px 8px rgba(0, 0, 0, 0.55); + color: rgba(255, 235, 235, 0.9); + font-weight: 700; + font-size: 1.15rem; + animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards; +} +@keyframes pp-seal-drop { + 0% { transform: translateY(-120px) scale(2.1); opacity: 0; } + 60% { transform: translateY(0) scale(0.9); opacity: 1; } + 80% { transform: translateY(0) scale(1.05); } + 100% { transform: translateY(0) scale(1); } +} + +/* Small screens: the spread stacks; the flip cover would straddle both + pages, so the book simply opens. */ +@media (max-width: 640px) { + .pp-book { height: min(80vh, 620px); } + .pp-page { position: static; width: 100%; height: 50%; border-radius: 0; } + .pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; } + .pp-page-right { border-radius: 0 0 0.6rem 0.6rem; } + .pp-book-cover { display: none; } + .pp-book { display: flex; flex-direction: column; } +} + +@media (prefers-reduced-motion: reduce) { + .pp-book-cover, .pp-page-left, .pp-cover { transition: none; } + .pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; } + .pp-slam, .pp-stamp-page::after { opacity: 1; } + .pp-stamp-hidden { opacity: 0.92; } +} diff --git a/plugins/career/assets/sfx/chime.mp3 b/plugins/career/assets/sfx/chime.mp3 new file mode 100644 index 0000000..24fd9f2 Binary files /dev/null and b/plugins/career/assets/sfx/chime.mp3 differ diff --git a/plugins/career/assets/sfx/page.mp3 b/plugins/career/assets/sfx/page.mp3 new file mode 100644 index 0000000..de05e8a Binary files /dev/null and b/plugins/career/assets/sfx/page.mp3 differ diff --git a/plugins/career/assets/sfx/seal.mp3 b/plugins/career/assets/sfx/seal.mp3 new file mode 100644 index 0000000..1c0ca97 Binary files /dev/null and b/plugins/career/assets/sfx/seal.mp3 differ diff --git a/plugins/career/assets/sfx/stamp.mp3 b/plugins/career/assets/sfx/stamp.mp3 new file mode 100644 index 0000000..757ba71 Binary files /dev/null and b/plugins/career/assets/sfx/stamp.mp3 differ 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/screen.html b/plugins/career/screen.html index 38c58ca..3fa5047 100644 --- a/plugins/career/screen.html +++ b/plugins/career/screen.html @@ -3,19 +3,40 @@

Career

-

Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.

-
-
-
-
-
+
+ +
-
-
-
-

Your star collection

-
+ +
+

Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.

+
+
+
+
+
+
+
+
+
+

Your star collection

+
+
+
+
+
+ +
+ diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 1d081cb..a6ffe88 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -17,11 +17,25 @@ const PREV_VIZ_KEY = 'feedBack-career-prev-viz'; const POLL_MS = 2000; + // Passports (the badge-journey layer; see routes.py — badges are computed + // server-side, this file only renders and relays). + const PP_SEEN_KEY = 'feedBack-career-badges-seen'; + const PP_INST_KEY = 'feedBack-career-instrument'; + const PP_TAB_KEY = 'feedBack-career-tab'; + const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' }; + const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕']; + let _state = null; let _pollTimer = 0; let _appliedManifestVenue = null; let _manifestReqGen = 0; // invalidates in-flight manifest fetches let _prevUnlockedIds = null; + let _pp = null; // last /passports view + let _ppRelayTimer = 0; + let _ppBook = null; // {inst, gkey} of the open spread + let _ppReturnFocus = null; // element to refocus when the book closes + let _ppBootstrapped = false; + let _ppNotified = {}; // badges chimed this session (slam still pending) function $(id) { return document.getElementById(id); } @@ -219,9 +233,401 @@ render(state); schedulePoll(state); pushCrowdManifest(state); + refreshPassports(); // independent fetch; failures don't touch venues + } + + // ── Passports ───────────────────────────────────────────────────────── + + function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } } + function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* ok */ } } + + function ppLabel(inst) { + return PP_LABELS[inst] || (inst.charAt(0).toUpperCase() + inst.slice(1)); + } + + function ppKey(genre) { + return String(genre || '').trim().replace(/\s+/g, ' ').toLowerCase(); + } + + function ppHash(seed) { + let h = 0; + for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0; + return h; + } + + // Deterministic per-key jitter (sin-hash): stamps and stubs land slightly + // askew, the same way on every visit. + function ppJitter(seed, range) { + return (Math.abs(Math.sin(ppHash(seed))) * 2 - 1) * range; + } + + function sfx(name) { + try { + const a = new Audio(`${API}/assets/sfx/${name}.mp3`); + a.volume = 0.45; + a.play().catch(() => { /* autoplay policy — silent is fine */ }); + } catch (_) { /* no Audio — fine */ } + } + + function showCareerTab(tab) { + lsSet(PP_TAB_KEY, tab); + const venues = $('career-tab-venues'); + const pp = $('career-tab-passports'); + if (!venues || !pp) return; + venues.classList.toggle('hidden', tab !== 'venues'); + pp.classList.toggle('hidden', tab !== 'passports'); + document.querySelectorAll('#plugin-career .career-tab').forEach((b) => { + const active = b.dataset.careerTab === tab; + b.classList.toggle('active', active); + b.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + } + + function activeInstrument() { + const list = (_pp && _pp.config && _pp.config.instruments) || []; + const saved = lsGet(PP_INST_KEY); + if (saved && list.includes(saved)) return saved; + const committed = list.find((i) => ((_pp.instruments || {})[i] || {}).committed_at); + return committed || list[0] || 'guitar'; + } + + function seenBadges() { + try { + const seen = JSON.parse(lsGet(PP_SEEN_KEY) || '{}'); + // Guard non-object JSON (a stray "null" or array) — a broken + // stored value must not throw on every passport refresh. + return seen && typeof seen === 'object' && !Array.isArray(seen) ? seen : {}; + } catch (_) { return {}; } + } + + function badgeId(inst, gkey) { return inst + '/' + gkey; } + + function markBadgeSeen(inst, gkey) { + const seen = seenBadges(); + seen[badgeId(inst, gkey)] = 1; + lsSet(PP_SEEN_KEY, JSON.stringify(seen)); + } + + // New badge → chime + notification once per session; the stamp SLAM plays + // when the passport is next opened (and only then is the badge marked + // seen, so a pending slam survives a reload). + function detectNewBadges(view) { + 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; + _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.`, + }); + } + } + } + } + + // Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus + // payload) to the server intake, debounced across event bursts. + function relayDrillState() { + clearTimeout(_ppRelayTimer); + _ppRelayTimer = setTimeout(() => { + let snap = null; + try { snap = JSON.parse(lsGet('virtuoso.progress') || 'null'); } catch (_) { /* corrupt */ } + if (!snap || typeof snap !== 'object' || !snap.byNode || typeof snap.byNode !== 'object') return; + fetch(`${API}/drill-state`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }), + }).then(() => refreshPassports()).catch(() => { /* next event retries */ }); + }, 1500); + } + + async function refreshPassports() { + let view; + try { + const res = await fetch(`${API}/passports`); + if (!res.ok) return; + view = await res.json(); + } catch (_) { return; } + _pp = view; + detectNewBadges(view); + renderPassports(); + if (!_ppBootstrapped) { + _ppBootstrapped = true; + // First run on this browser: seed the server with the local drill + // snapshot if it has never received one. + if (!(view.drill_state || {}).received_at) relayDrillState(); + } + } + + function ppCoverHTML(inst, p) { + const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2); + const stamp = p.badge === 'earned' + ? `BRONZE` + : ''; + const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`; + return ``; + } + + function renderShelf(inst, data) { + const shelf = $('pp-shelf'); + if (!shelf) return; + if (!data.committed_at) { + shelf.innerHTML = `
+
+ ${esc(ppLabel(inst).toUpperCase())} + passport +
+
+
Pick up the ${esc(ppLabel(inst).toLowerCase())}.
+
Press your seal to commit — then choose a genre below and go deep.
+ +
+
`; + return; + } + const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join(''); + shelf.innerHTML = books || + '
Your shelf is ready — open your first genre passport below.
'; + } + + function renderRack(inst, data) { + const rack = $('pp-rack'); + if (!rack || !_pp) return; + const openedKeys = new Set((data.passports || []).map((p) => p.genre_key)); + const genres = (_pp.genres || []).filter((g) => !openedKeys.has(g.genre_key)); + if (!genres.length) { + rack.innerHTML = '
No further genres in your library yet — new songs bring new brochures.
'; + return; + } + rack.innerHTML = genres.map((g) => { + const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length]; + return ``; + }).join(''); + } + + function renderPassports() { + const host = $('pp-instruments'); + if (!host || !_pp) return; + const inst = activeInstrument(); + 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 committed = !!d.committed_at; + return ``; + }).join(''); + renderShelf(inst, data); + renderRack(inst, data); + } + + function ppStubHTML(s) { + const date = (s.last_played_at || '').slice(0, 10); + return `
+ ${'★'.repeat(s.stars)} + ${esc(s.title)} + ${s.artist ? `${esc(s.artist)}` : ''} + ${date ? `${esc(date)} · ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}% +
`; + } + + function ppBookHTML(inst, p, pendingSlam) { + const req = p.requirement || {}; + const need = Math.max(0, (req.songs || 0) - p.qualifying_count); + const starGl = '★'.repeat(req.min_stars || 0); + let badgeArea = ''; + if (p.badge === 'shown_not_judged') { + badgeArea = `
Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.
`; + } else if (p.badge === 'earned') { + badgeArea = `
+ ${esc(p.genre.toUpperCase())} + BRONZE +
+
Gold rung coming — improvise it, verified.
`; + } else { + badgeArea = `
+ ${esc(p.genre.toUpperCase())} + BRONZE +
+
${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}
`; + } + let drills = ''; + const reqNodes = (p.drills || {}).required || []; + if (reqNodes.length) { + const cleared = new Set((p.drills || {}).cleared || []); + drills = `
${reqNodes.map((n) => + `
${cleared.has(n) ? '✓' : '○'} ${esc(n)}
`).join('')}
`; + } + // Graded instruments collect stubs at the badge bar; shown-not-judged + // instruments have no bar — every played genre song is repertoire. + const stubs = p.badge === 'shown_not_judged' + ? (p.songs || []) + : (p.songs || []).filter((s) => s.qualifies); + const emptyLine = p.badge === 'shown_not_judged' + ? `Play ${esc(p.genre)} songs to fill this page.` + : `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`; + const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('') + : `
${emptyLine}
`; + return ``; + } + + function openBook(inst, gkey) { + if (!_pp) return; + const p = (((_pp.instruments || {})[inst] || {}).passports || []) + .find((x) => x.genre_key === gkey); + const overlay = $('pp-overlay'); + if (!p || !overlay) return; + _ppBook = { inst, gkey }; + _ppReturnFocus = document.activeElement; + const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)]; + overlay.innerHTML = ppBookHTML(inst, p, pending); + overlay.classList.remove('hidden'); + const close = overlay.querySelector('.pp-book-close'); + if (close) close.focus(); + sfx('page'); + // Double rAF so the cover's closed state paints before the transition. + requestAnimationFrame(() => requestAnimationFrame(() => { + const book = overlay.querySelector('.pp-book'); + if (book) book.classList.add('open'); + })); + if (pending) { + setTimeout(() => { + if (!_ppBook || _ppBook.gkey !== gkey || _ppBook.inst !== inst) return; + const stamp = overlay.querySelector('.pp-stamp-page'); + const book = overlay.querySelector('.pp-book'); + if (!stamp) return; + stamp.classList.remove('pp-stamp-hidden'); + stamp.classList.add('pp-slam'); + if (book) book.classList.add('pp-shake'); + sfx('stamp'); + markBadgeSeen(inst, gkey); + renderPassports(); // the shelf cover gains its mini-stamp + }, 950); + } + } + + function closeBook() { + _ppBook = null; + const overlay = $('pp-overlay'); + if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; } + if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' && + document.contains(_ppReturnFocus)) { + _ppReturnFocus.focus(); + } + _ppReturnFocus = null; + } + + function commitInstrument(inst, after) { + fetch(`${API}/passports/commit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instrument: inst }), + }).then(() => refreshPassports()) + .then(() => { if (after) after(); }) + .catch(() => { /* server restarting; user retries */ }); + } + + // Stage 0 — the wax seal. Purely theatrical: the overlay plays the press, + // the POST commits, the shelf re-renders committed. + function sealCeremony(inst, after) { + const overlay = $('pp-overlay'); + if (!overlay) { commitInstrument(inst, after); return; } + overlay.innerHTML = `
+
+ ${esc(ppLabel(inst).toUpperCase())} + passport + ${esc(ppLabel(inst).charAt(0))} +
+
`; + overlay.classList.remove('hidden'); + setTimeout(() => sfx('seal'), 450); + setTimeout(() => { + overlay.classList.add('hidden'); + overlay.innerHTML = ''; + commitInstrument(inst, after); + }, 1500); + } + + function openGenre(inst, genre) { + fetch(`${API}/passports/open`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instrument: inst, genre }), + }).then((res) => { if (!res.ok) throw new Error('open ' + res.status); }) + .then(() => refreshPassports()) + .then(() => openBook(inst, ppKey(genre))) + .catch(() => { /* validation/restart; rack stays */ }); } function onClick(e) { + const tabBtn = e.target.closest('[data-career-tab]'); + const instBtn = e.target.closest('[data-pp-inst]'); + const commitBtn = e.target.closest('[data-pp-commit]'); + const coverBtn = e.target.closest('[data-pp-open]'); + const brochureBtn = e.target.closest('[data-pp-genre]'); + if (tabBtn) { + showCareerTab(tabBtn.dataset.careerTab); + return; + } + if (instBtn) { + lsSet(PP_INST_KEY, instBtn.dataset.ppInst); + renderPassports(); + return; + } + if (commitBtn) { + sealCeremony(commitBtn.dataset.ppCommit); + return; + } + if (coverBtn) { + openBook(activeInstrument(), coverBtn.dataset.ppOpen); + return; + } + if (brochureBtn) { + const inst = activeInstrument(); + const genre = brochureBtn.dataset.ppGenre; + const committed = _pp && ((_pp.instruments || {})[inst] || {}).committed_at; + // Opening your first passport on an instrument IS the commitment — + // the seal ceremony runs first, then the passport opens. + if (committed) openGenre(inst, genre); + else sealCeremony(inst, () => openGenre(inst, genre)); + return; + } + if (e.target.closest('[data-pp-close]') || + (e.target.dataset && e.target.dataset.ppCloseBg)) { + closeBook(); + return; + } const dlBtn = e.target.closest('[data-career-download]'); const delBtn = e.target.closest('[data-career-delete]'); const playBtn = e.target.closest('[data-career-play]'); @@ -268,10 +674,24 @@ if (sm && typeof sm.on === 'function') { // New song stats can add stars → thresholds may cross mid-session. sm.on('stats:recorded', () => refresh()); + // Virtuoso's progress emits are the drill-state relay trigger; the + // payload is a thin delta, so the relay reads the full localStorage + // snapshot instead (see relayDrillState). + sm.on('virtuoso:progress', relayDrillState); } + showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues'); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && _ppBook) closeBook(); + }); refresh(); } + // Test seam (bare-vm harness, see plugins/career/tests/): pure helpers + + // the badge-diff logic; nothing here touches the DOM. + window.__careerPassportTest = { + ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen, + }; + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { 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/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js new file mode 100644 index 0000000..dc39963 --- /dev/null +++ b/plugins/career/tests/passports.test.js @@ -0,0 +1,101 @@ +// Passport UI pure-logic tests: load screen.js in a bare vm window and +// exercise the __careerPassportTest seam (no DOM beyond stubs, no network). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function load(seed) { + const store = Object.assign({}, seed); + const window = { + console, + localStorage: { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + }, + document: { + readyState: 'complete', + getElementById: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, + }, + notifications: [], + }; + window.window = window; + window.globalThis = window; + window.fbNotify = { show: (n) => window.notifications.push(n) }; + const context = vm.createContext(window); + // `document` and `localStorage` resolve as bare names inside the IIFE. + context.document = window.document; + context.localStorage = window.localStorage; + const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + vm.runInContext(src, context, { filename: 'career/screen.js' }); + return window; +} + +test('module loads (and boots) in a bare vm window', () => { + const w = load(); + assert.equal(typeof w.__careerPassportTest.ppKey, 'function'); +}); + +test('ppKey normalizes case and whitespace', () => { + const { ppKey } = load().__careerPassportTest; + assert.equal(ppKey(' Blues Rock '), 'blues rock'); + assert.equal(ppKey('FUNK'), 'funk'); + assert.equal(ppKey(''), ''); + assert.equal(ppKey(null), ''); +}); + +test('ppJitter is deterministic and bounded', () => { + const { ppJitter } = load().__careerPassportTest; + assert.equal(ppJitter('blues', 8), ppJitter('blues', 8)); + for (const seed of ['blues', 'funk', 'jazz', 'metal']) { + const j = ppJitter(seed, 8); + assert.ok(j >= -8 && j <= 8, `${seed} → ${j}`); + } + assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8)); +}); + +test('detectNewBadges notifies once per badge, never after it is seen', () => { + const w = load(); + const t = w.__careerPassportTest; + const view = { + instruments: { + guitar: { + passports: [ + { genre_key: 'blues', genre: 'Blues', badge: 'earned' }, + { genre_key: 'funk', genre: 'Funk', badge: 'in_progress' }, + ], + }, + }, + }; + t.detectNewBadges(view); + assert.equal(w.notifications.length, 1); + assert.match(w.notifications[0].message, /Blues/); + // Same view again in the same session: no duplicate notification. + t.detectNewBadges(view); + assert.equal(w.notifications.length, 1); + // Seen (slam played) → a fresh session stays quiet too. + t.markBadgeSeen('guitar', 'blues'); + // JSON-compare: vm objects carry a foreign Object prototype. + assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}'); + + // Fresh session (new vm, empty notify cache) with the badge already seen: + // detection must stay silent. + const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' }); + w2.__careerPassportTest.detectNewBadges(view); + assert.equal(w2.notifications.length, 0); +}); + +test('seenBadges tolerates corrupt stored values', () => { + for (const bad of ['null', '[1,2]', '"x"', '{{{']) { + const w = load({ 'feedBack-career-badges-seen': bad }); + const t = w.__careerPassportTest; + assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`); + // And detection still works on top of the recovered empty state. + t.detectNewBadges({ instruments: { guitar: { passports: [ + { genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } }); + assert.equal(w.notifications.length, 1, `stored ${bad}`); + } +}); 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