Merge branch 'main' into chore/feedpak-spec-gate

Signed-off-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
byrongamatos
2026-07-13 13:27:25 +02:00
15 changed files with 1449 additions and 23 deletions
+32
View File
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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 - **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 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 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). engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed ### 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 - **The packaged desktop app could not start (`ModuleNotFoundError: No module named
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded 'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`, list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
+345
View File
@@ -64,3 +64,348 @@
.career-star-row .song .artist { color: #9ca3af; } .career-star-row .song .artist { color: #9ca3af; }
.career-star-row .hint { color: #6b7280; white-space: nowrap; } .career-star-row .hint { color: #6b7280; white-space: nowrap; }
.career-star-row .hint.close { color: #22d3ee; } .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; }
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
{
"badge_requirement": {
"songs": 5,
"min_stars": 2
},
"genres": {},
"graded_instruments": [
"guitar",
"keys"
],
"instruments": [
"guitar",
"bass",
"keys",
"drums"
]
}
+8 -2
View File
@@ -1,12 +1,18 @@
{ {
"id": "career", "id": "career",
"name": "Career", "name": "Career",
"version": "0.1.0", "version": "0.2.0",
"bundled": true, "bundled": true,
"private": false, "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", "screen": "screen.html",
"script": "screen.js", "script": "screen.js",
"styles": "assets/career.css", "styles": "assets/career.css",
"settings": {
"html": "settings.html",
"server_files": [
"career/"
]
},
"routes": "routes.py" "routes": "routes.py"
} }
+309 -1
View File
@@ -10,11 +10,22 @@ the plugin under ``venue-packs/<id>/`` or downloaded on demand into
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override ``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
bundled packs so release assets can replace a built-in starter venue. 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/): Endpoints (all under /api/plugins/career/):
GET /state stars + per-venue unlock/install/download status GET /state stars + per-venue unlock/install/download status
POST /packs/{venue_id}/download start background pack download (409 if running) POST /packs/{venue_id}/download start background pack download (409 if running)
DELETE /packs/{venue_id} remove an installed pack DELETE /packs/{venue_id} remove an installed pack
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers) 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 import hashlib
@@ -26,11 +37,14 @@ import tempfile
import threading import threading
import urllib.request import urllib.request
import zipfile import zipfile
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import HTTPException from fastapi import Body, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from progression import instrument_for_arrangement
PLUGIN_ID = "career" PLUGIN_ID = "career"
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$") 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)$") 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 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): def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack.""" """Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json" manifest_path = pack_dir / "manifest.json"
@@ -197,6 +440,13 @@ def setup(app, context):
_state["venues_dir"] = ( _state["venues_dir"] = (
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues") Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
_state["venues_dir"].mkdir(parents=True, exist_ok=True) _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["meta_db"] = context.get("meta_db")
_state["log"] = context.get("log") or _state["log"] _state["log"] = context.get("log") or _state["log"]
for v in _state["content"]["venues"]: for v in _state["content"]["venues"]:
@@ -229,6 +479,64 @@ def setup(app, context):
"venues": venues, "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") @app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str): def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
+33 -12
View File
@@ -3,19 +3,40 @@
<h1 class="text-2xl font-bold text-white">Career</h1> <h1 class="text-2xl font-bold text-white">Career</h1>
<div id="career-stars-summary" class="text-sm text-gray-400"></div> <div id="career-stars-summary" class="text-sm text-gray-400"></div>
</div> </div>
<p class="text-sm text-gray-400 mb-4">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.</p> <div class="career-tabs" role="tablist">
<div id="career-progress-wrap" class="mb-6"> <button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
<div class="career-bar-track"> <button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div> </div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8"> <div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2"> <p class="text-sm text-gray-400 mb-4">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.</p>
<h2 class="text-lg font-semibold text-white">Your star collection</h2> <div id="career-progress-wrap" class="mb-6">
<div id="career-star-summary" class="text-xs text-gray-400"></div> <div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
<div id="pp-instruments" class="pp-instruments"></div>
<div id="pp-shelf-wrap" class="mt-5">
<div id="pp-shelf" class="pp-shelf"></div>
</div>
<div id="pp-rack-wrap" class="mt-8">
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
<div id="pp-rack" class="pp-rack"></div>
</div> </div>
<div id="career-star-list" class="career-star-list"></div>
</div> </div>
</div> </div>
<div id="pp-overlay" class="pp-overlay hidden"></div>
+420
View File
@@ -17,11 +17,25 @@
const PREV_VIZ_KEY = 'feedBack-career-prev-viz'; const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
const POLL_MS = 2000; 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 _state = null;
let _pollTimer = 0; let _pollTimer = 0;
let _appliedManifestVenue = null; let _appliedManifestVenue = null;
let _manifestReqGen = 0; // invalidates in-flight manifest fetches let _manifestReqGen = 0; // invalidates in-flight manifest fetches
let _prevUnlockedIds = null; 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); } function $(id) { return document.getElementById(id); }
@@ -219,9 +233,401 @@
render(state); render(state);
schedulePoll(state); schedulePoll(state);
pushCrowdManifest(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'
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
: '';
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
return `<button class="pp-cover pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="transform:rotate(${rot}deg)">
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
${stamp}
<span class="pp-cover-sub">${stubs}</span>
</button>`;
}
function renderShelf(inst, data) {
const shelf = $('pp-shelf');
if (!shelf) return;
if (!data.committed_at) {
shelf.innerHTML = `<div class="pp-commit-card">
<div class="pp-commit-cover pp-leather-${esc(inst)}">
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
<span class="pp-cover-inst">passport</span>
</div>
<div>
<div class="text-sm text-gray-200 font-medium mb-1">Pick up the ${esc(ppLabel(inst).toLowerCase())}.</div>
<div class="text-xs text-gray-400 mb-2">Press your seal to commit — then choose a genre below and go deep.</div>
<button class="career-btn career-btn-primary" data-pp-commit="${esc(inst)}">Press the seal</button>
</div>
</div>`;
return;
}
const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join('');
shelf.innerHTML = books ||
'<div class="text-xs text-gray-500">Your shelf is ready — open your first genre passport below.</div>';
}
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 = '<div class="text-xs text-gray-500">No further genres in your library yet — new songs bring new brochures.</div>';
return;
}
rack.innerHTML = genres.map((g) => {
const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length];
return `<button class="pp-brochure" data-pp-genre="${esc(g.genre)}">
<span class="pp-brochure-art" aria-hidden="true">${art}</span>
<span class="pp-brochure-name">${esc(g.genre)}</span>
<span class="pp-brochure-sub">${g.songs_in_library === 1 ? '1 song' : `${g.songs_in_library} songs`} in your library</span>
</button>`;
}).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 `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
</button>`;
}).join('');
renderShelf(inst, data);
renderRack(inst, data);
}
function ppStubHTML(s) {
const date = (s.last_played_at || '').slice(0, 10);
return `<div class="pp-stub" style="transform:rotate(${ppJitter(s.filename, 1.2).toFixed(2)}deg)">
<span class="pp-stub-stars">${'★'.repeat(s.stars)}</span>
<span class="pp-stub-title">${esc(s.title)}</span>
${s.artist ? `<span class="pp-stub-artist">${esc(s.artist)}</span>` : ''}
<span class="pp-stub-meta">${date ? `${esc(date)} · ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}%</span>
</div>`;
}
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 = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
} else if (p.badge === 'earned') {
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
} else {
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-invite">${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}</div>`;
}
let drills = '';
const reqNodes = (p.drills || {}).required || [];
if (reqNodes.length) {
const cleared = new Set((p.drills || {}).cleared || []);
drills = `<div class="pp-drills">${reqNodes.map((n) =>
`<div class="pp-drill${cleared.has(n) ? ' cleared' : ''}">${cleared.has(n) ? '✓' : '○'} ${esc(n)}</div>`).join('')}</div>`;
}
// 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('')
: `<div class="pp-stub-empty">${emptyLine}</div>`;
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
<div class="pp-book">
<div class="pp-page pp-page-left">
<div class="pp-page-head">${esc(p.genre)}${esc(ppLabel(inst))}</div>
${badgeArea}${drills}
</div>
<div class="pp-page pp-page-right">
<div class="pp-page-head">Ticket stubs</div>
<div class="pp-stubs">${stubsHTML}</div>
</div>
<div class="pp-book-cover pp-leather-${esc(inst)}">
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
</div>
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
</div>
</div>`;
}
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 = `<div class="pp-book-wrap">
<div class="pp-commit-cover pp-ceremony pp-leather-${esc(inst)}">
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
<span class="pp-cover-inst">passport</span>
<span class="pp-wax"><span>${esc(ppLabel(inst).charAt(0))}</span></span>
</div>
</div>`;
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) { 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 dlBtn = e.target.closest('[data-career-download]');
const delBtn = e.target.closest('[data-career-delete]'); const delBtn = e.target.closest('[data-career-delete]');
const playBtn = e.target.closest('[data-career-play]'); const playBtn = e.target.closest('[data-career-play]');
@@ -268,10 +674,24 @@
if (sm && typeof sm.on === 'function') { if (sm && typeof sm.on === 'function') {
// New song stats can add stars → thresholds may cross mid-session. // New song stats can add stars → thresholds may cross mid-session.
sm.on('stats:recorded', () => refresh()); 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(); 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') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot); document.addEventListener('DOMContentLoaded', boot);
} else { } else {
+10
View File
@@ -0,0 +1,10 @@
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
settings.server_files has a visible home in Settings; nothing to configure. -->
<div class="text-sm text-gray-300 space-y-2">
<p><strong>Career</strong> computes stars and genre badges from your play
stats — they are never stored, so there is nothing to back up or reset.</p>
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
commitments, opened genre passports, and the practice-drill snapshot the
Virtuoso plugin reports. These ride along in
<em>Settings → Export</em> automatically.</p>
</div>
+101
View File
@@ -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}`);
}
});
+29 -8
View File
@@ -1,3 +1,4 @@
import json
import sqlite3 import sqlite3
import sys import sys
from pathlib import Path from pathlib import Path
@@ -14,25 +15,45 @@ import routes as career_routes
class FakeMetaDb: 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): def __init__(self):
self.conn = sqlite3.connect(":memory:", check_same_thread=False) self.conn = sqlite3.connect(":memory:", check_same_thread=False)
self.conn.execute( self.conn.execute(
"""CREATE TABLE song_stats ( """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): def add(self, filename, arrangement, best_accuracy, in_library=True,
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)", genre="", arrangements=None, last_played_at=None):
(filename, arrangement, best_accuracy)) self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)",
(filename, arrangement, best_accuracy, last_played_at))
if in_library: if in_library:
self.conn.execute( self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS " "INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
"(SELECT 1 FROM songs WHERE filename = ?)", "(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() self.conn.commit()
+145
View File
@@ -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