From 702a9c6daaebd58a54a7bd7bad5a322b9c2ded92 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:14:00 +0200 Subject: [PATCH 1/3] feat(career): 50/150 star thresholds + star collection overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Byron's progression tuning: club at 50★, arena at 150★. /state now returns star_detail rows (title/artist joined from the library, stars, best accuracy, next-star threshold) sorted closest-to-next-star first, and the career screen renders a collection panel: tier summary plus a per-song list with a 'N% to next star' practice hint. Co-Authored-By: Claude Fable 5 --- plugins/career/assets/career.css | 30 +++++++++++++++++++++++ plugins/career/routes.py | 35 +++++++++++++++++++------- plugins/career/screen.html | 7 ++++++ plugins/career/screen.js | 38 +++++++++++++++++++++++++++++ plugins/career/venues.json | 10 +++++--- tests/plugins/career/conftest.py | 7 +++--- tests/plugins/career/test_routes.py | 14 ++++++++++- 7 files changed, 125 insertions(+), 16 deletions(-) diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index 83cd1b0..5fac8c2 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -34,3 +34,33 @@ background-color: #06b6d4; transition: width 0.3s ease; } + +.career-star-list { + display: grid; + gap: 0.375rem; +} +.career-star-row { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding: 0.375rem 0.625rem; + border-radius: 0.5rem; + background-color: rgba(31, 41, 55, 0.4); + font-size: 0.8rem; +} +.career-star-row .stars { + color: #facc15; + letter-spacing: 0.1em; + min-width: 3.2em; +} +.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); } +.career-star-row .song { + color: #e5e7eb; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.career-star-row .song .artist { color: #9ca3af; } +.career-star-row .hint { color: #6b7280; white-space: nowrap; } +.career-star-row .hint.close { color: #22d3ee; } diff --git a/plugins/career/routes.py b/plugins/career/routes.py index 3705194..b9d7310 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -65,23 +65,39 @@ def _installed(venue_id): def _stars(): - """(total, per-song dict). Accuracy in song_stats is a 0..1 fraction.""" + """(total, per-song dict, detail rows). Accuracy is a 0..1 fraction.""" db = _state["meta_db"] if db is None: - return 0, {} + return 0, {}, [] thresholds = _state["content"]["star_accuracy_thresholds"] # Existing-song filter: a scan hides (not deletes) stats of songs removed # from the library, so orphaned rows must not keep counting toward stars. rows = db.conn.execute( - "SELECT filename, MAX(best_accuracy) FROM song_stats " - "WHERE filename IN (SELECT filename FROM songs) GROUP BY filename" + "SELECT s.filename, MAX(s.best_accuracy), " + " COALESCE(MAX(sg.title), ''), COALESCE(MAX(sg.artist), '') " + "FROM song_stats s JOIN songs sg ON sg.filename = s.filename " + "GROUP BY s.filename" ).fetchall() per_song = {} - for filename, acc in rows: - stars = sum(1 for t in thresholds if (acc or 0.0) >= t) + detail = [] + for filename, acc, title, artist in rows: + acc = acc or 0.0 + stars = sum(1 for t in thresholds if acc >= t) if stars: per_song[filename] = stars - return sum(per_song.values()), per_song + next_at = next((t for t in thresholds if acc < t), None) + detail.append({ + "filename": filename, + "title": title or filename, + "artist": artist, + "stars": stars, + "best_accuracy": round(acc, 4), + "next_star_at": next_at, + }) + # closest-to-next-star first (a practice worklist), maxed songs last + detail.sort(key=lambda r: (r["next_star_at"] is None, + (r["next_star_at"] or 1.0) - r["best_accuracy"])) + return sum(per_song.values()), per_song, detail def _validate_pack_dir(pack_dir: Path): @@ -164,7 +180,7 @@ def setup(app, context): @app.get(f"/api/plugins/{PLUGIN_ID}/state") def get_state(): - stars_total, per_song = _stars() + stars_total, per_song, star_detail = _stars() venues = [] for v in _state["content"]["venues"]: with _lock: @@ -182,6 +198,7 @@ def setup(app, context): return { "stars_total": stars_total, "stars_per_song": per_song, + "star_detail": star_detail, "star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"], "venues": venues, } @@ -194,7 +211,7 @@ def setup(app, context): pack = venue.get("pack") if not pack: raise HTTPException(404, "No pack published for this venue yet.") - stars_total, _ = _stars() + stars_total, _, _ = _stars() if stars_total < venue["star_threshold"]: raise HTTPException(403, "Venue not unlocked yet.") with _lock: diff --git a/plugins/career/screen.html b/plugins/career/screen.html index 9305a8c..38c58ca 100644 --- a/plugins/career/screen.html +++ b/plugins/career/screen.html @@ -11,4 +11,11 @@
+
+
+

Your star collection

+
+
+
+
diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 44a67e7..58e0c03 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -108,6 +108,43 @@ `; } + function starGlyphs(n) { + let out = ''; + for (let i = 0; i < 3; i++) { + out += `★`; + } + return out; + } + + function renderStars(state) { + const list = $('career-star-list'); + const summary = $('career-star-summary'); + if (!list || !summary) return; + const detail = state.star_detail || []; + const tiers = [0, 0, 0, 0]; + for (const r of detail) tiers[r.stars]++; + summary.textContent = + `${tiers[3]}× 3★ · ${tiers[2]}× 2★ · ${tiers[1]}× 1★ · ${tiers[0]} unstarred`; + if (!detail.length) { + list.innerHTML = '
Play songs to start collecting stars — 60% accuracy earns the first one.
'; + return; + } + list.innerHTML = detail.map((r) => { + let hint = 'maxed'; + let close = ''; + if (r.next_star_at != null) { + const gap = Math.max(0, r.next_star_at - r.best_accuracy) * 100; + hint = `${gap.toFixed(0)}% to next ★`; + if (gap <= 5) close = ' close'; + } + return `
+ ${starGlyphs(r.stars)} + ${esc(r.title)}${r.artist ? ` — ${esc(r.artist)}` : ''} + best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint} +
`; + }).join(''); + } + function render(state) { const host = $('career-venues'); if (!host) return; @@ -128,6 +165,7 @@ label.textContent = 'All venues unlocked — enjoy the arena.'; } host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join(''); + renderStars(state); } function schedulePoll(state) { diff --git a/plugins/career/venues.json b/plugins/career/venues.json index d4684ba..c749a65 100644 --- a/plugins/career/venues.json +++ b/plugins/career/venues.json @@ -1,5 +1,9 @@ { - "star_accuracy_thresholds": [0.6, 0.75, 0.85], + "star_accuracy_thresholds": [ + 0.6, + 0.75, + 0.85 + ], "venues": [ { "id": "bar", @@ -12,14 +16,14 @@ "id": "club", "name": "Velvet Room", "description": "A proper club stage. People actually came to hear you.", - "star_threshold": 15, + "star_threshold": 50, "pack": null }, { "id": "arena", "name": "Feedback Arena", "description": "Ten thousand seats. Try not to think about it.", - "star_threshold": 40, + "star_threshold": 150, "pack": null } ] diff --git a/tests/plugins/career/conftest.py b/tests/plugins/career/conftest.py index f738dd3..eb6ad63 100644 --- a/tests/plugins/career/conftest.py +++ b/tests/plugins/career/conftest.py @@ -23,15 +23,16 @@ class FakeMetaDb: filename TEXT, arrangement TEXT, best_accuracy REAL )""" ) - self.conn.execute("CREATE TABLE songs (filename 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)) if in_library: self.conn.execute( - "INSERT INTO songs SELECT ? WHERE NOT EXISTS " - "(SELECT 1 FROM songs WHERE filename = ?)", (filename, filename)) + "INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS " + "(SELECT 1 FROM songs WHERE filename = ?)", + (filename, filename.replace(".feedpak", "").title(), "Test Artist", filename)) self.conn.commit() diff --git a/tests/plugins/career/test_routes.py b/tests/plugins/career/test_routes.py index 3ec59bb..581d553 100644 --- a/tests/plugins/career/test_routes.py +++ b/tests/plugins/career/test_routes.py @@ -32,7 +32,7 @@ def test_stars_from_best_accuracy_across_arrangements(client, meta_db): def test_unlock_flags_follow_thresholds(client, meta_db): - # 6 stars: bar (0) unlocked, club (15) and arena (40) locked. + # 6 stars: bar (0) unlocked, club (50) and arena (150) locked. for i in range(2): meta_db.add(f"s{i}.feedpak", "guitar", 0.9) # 3 stars each state = client.get("/api/plugins/career/state").json() @@ -52,6 +52,18 @@ def test_orphaned_stats_do_not_count(client, meta_db): assert "gone.feedpak" not in state["stars_per_song"] +def test_star_detail_rows_sorted_by_next_star_gap(client, meta_db): + meta_db.add("far.feedpak", "guitar", 0.61) # 1★, 14% from next + meta_db.add("close.feedpak", "guitar", 0.84) # 2★, 1% from next + meta_db.add("maxed.feedpak", "guitar", 0.99) # 3★, maxed + detail = client.get("/api/plugins/career/state").json()["star_detail"] + assert [r["filename"] for r in detail] == \ + ["close.feedpak", "far.feedpak", "maxed.feedpak"] + close = detail[0] + assert close["stars"] == 2 and close["next_star_at"] == 0.85 + assert detail[2]["next_star_at"] is None + + def test_no_stats_still_serves_state(client): state = client.get("/api/plugins/career/state").json() assert state["stars_total"] == 0 From 803193046e4bc3b916284d87a5a6d9e237155b69 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:39:41 +0200 Subject: [PATCH 2/3] feat(career): venue select/unselect UX, intro manifest support, fullmatch guards - 'Play here' now also defaults the visualization to Venue (remembering the prior viz); active venues show 'Leave venue' which restores it and sets the '__none__' override so no installed venue silently reapplies. - Pack manifests may ship an intro block (flyover video + ambience mp3); files validate like loops/stingers, .mp3 added to the serving whitelist. - Codex preflight: whitelist regexes use fullmatch (trailing-newline names could validate but 500 on serving). Co-Authored-By: Claude Fable 5 --- plugins/career/routes.py | 19 +++++++++++-------- plugins/career/screen.js | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/plugins/career/routes.py b/plugins/career/routes.py index b9d7310..dfc6834 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -35,7 +35,7 @@ from fastapi.responses import FileResponse 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|json)$") +PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$") REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic") DOWNLOAD_CHUNK = 1024 * 256 @@ -109,13 +109,16 @@ def _validate_pack_dir(pack_dir: Path): loops = manifest.get("loops") or {} for state in REQUIRED_LOOPS: name = loops.get(state) - if not name or not PACK_FILENAME_RE.match(name): + if not name or not PACK_FILENAME_RE.fullmatch(name): raise ValueError(f"manifest is missing the '{state}' loop") if not (pack_dir / name).is_file(): raise ValueError(f"loop file '{name}' missing from pack") for name in (manifest.get("stingers") or {}).values(): - if name and (not PACK_FILENAME_RE.match(name) or not (pack_dir / name).is_file()): + if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()): raise ValueError(f"stinger file '{name}' invalid or missing") + for name in (manifest.get("intro") or {}).values(): + if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()): + raise ValueError(f"intro file '{name}' invalid or missing") def _download_pack(venue_id, pack, progress): @@ -149,7 +152,7 @@ def _download_pack(venue_id, pack, progress): if info.is_dir(): continue name = Path(info.filename).name - if name != info.filename or not PACK_FILENAME_RE.match(name): + if name != info.filename or not PACK_FILENAME_RE.fullmatch(name): raise ValueError(f"unexpected file in pack: {info.filename!r}") with zf.open(info) as src, open(extract_dir / name, "wb") as dst: shutil.copyfileobj(src, dst) @@ -205,7 +208,7 @@ def setup(app, context): @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.match(venue_id) else None + venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None if venue is None: raise HTTPException(404, "Unknown venue.") pack = venue.get("pack") @@ -227,7 +230,7 @@ def setup(app, context): @app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}") def delete_pack(venue_id: str): - if not VENUE_ID_RE.match(venue_id) or _venue(venue_id) is None: + if not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None: raise HTTPException(404, "Unknown venue.") with _lock: running = _state["downloads"].get(venue_id) @@ -239,7 +242,7 @@ def setup(app, context): @app.get(f"/api/plugins/{PLUGIN_ID}/venues/{{venue_id}}/{{filename}}") async def get_pack_file(venue_id: str, filename: str): - if not VENUE_ID_RE.match(venue_id) or not PACK_FILENAME_RE.match(filename): + if not VENUE_ID_RE.fullmatch(venue_id) or not PACK_FILENAME_RE.fullmatch(filename): raise HTTPException(404, "Not found.") path = _venue_dir(venue_id) / filename # Defense-in-depth beyond the regexes (same recipe as highway_3d): @@ -251,7 +254,7 @@ def setup(app, context): raise HTTPException(404, "Not found.") if not resolved.is_file(): raise HTTPException(404, "Not found.") - media = {"mp4": "video/mp4", "webm": "video/webm", + media = {"mp4": "video/mp4", "webm": "video/webm", "mp3": "audio/mpeg", "json": "application/json"}[resolved.suffix.lstrip(".").lower()] return FileResponse( resolved, diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 58e0c03..ddbe463 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -13,6 +13,8 @@ const API = '/api/plugins/career'; const VENUE_OVERRIDE_KEY = 'feedBack-career-venue'; + const NO_VENUE = '__none__'; + const PREV_VIZ_KEY = 'feedBack-career-prev-viz'; const POLL_MS = 2000; let _state = null; @@ -46,11 +48,12 @@ const gen = ++_manifestReqGen; const unlocked = state.venues.filter((v) => v.unlocked); let venue = null; - try { - const override = localStorage.getItem(VENUE_OVERRIDE_KEY); + let override = null; + try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ } + if (override !== NO_VENUE) { venue = unlocked.find((v) => v.id === override && v.installed) || null; - } catch (_) { /* ok */ } - if (!venue) venue = lastOf(unlocked.filter((v) => v.installed)); + if (!venue) venue = lastOf(unlocked.filter((v) => v.installed)); + } if (!venue) { if (_appliedManifestVenue !== null) { _appliedManifestVenue = null; @@ -82,8 +85,12 @@ action = `
Downloading… ${pct}%
`; } else if (v.installed) { + const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id; + const main = active + ? `` + : ``; action = `
- + ${main}
`; } else if (v.has_pack) { @@ -225,9 +232,28 @@ fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' }) .then(refresh); } else if (playBtn) { - try { localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay); } catch (_) { /* ok */ } + try { + localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay); + // Selecting a venue makes the Venue visualization the default; + // remember what the user had so Leave venue can restore it. + const cur = localStorage.getItem('vizSelection'); + if (cur && cur !== 'venue') localStorage.setItem(PREV_VIZ_KEY, cur); + localStorage.setItem('vizSelection', 'venue'); + if (typeof window.setViz === 'function') window.setViz('venue'); + } catch (_) { /* ok */ } _appliedManifestVenue = null; // force manifest re-push refresh(); + } else if (e.target.closest('[data-career-unselect]')) { + try { + localStorage.setItem(VENUE_OVERRIDE_KEY, NO_VENUE); + const prev = localStorage.getItem(PREV_VIZ_KEY); + if (prev) { + localStorage.setItem('vizSelection', prev); + if (typeof window.setViz === 'function') window.setViz(prev); + } + } catch (_) { /* ok */ } + _appliedManifestVenue = null; + refresh(); } } From f8fc5e6a5f3070700fba0aae234786338b9bc7c1 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 12 Jul 2026 21:42:03 +0200 Subject: [PATCH 3/3] fix(career): let pushCrowdManifest clear the manifest on Leave venue Codex preflight: nulling _appliedManifestVenue before refresh skipped the setManifest(null) cleanup branch, leaving the crowd playing. Co-Authored-By: Claude Fable 5 --- plugins/career/screen.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/career/screen.js b/plugins/career/screen.js index ddbe463..2cc523d 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -252,7 +252,8 @@ if (typeof window.setViz === 'function') window.setViz(prev); } } catch (_) { /* ok */ } - _appliedManifestVenue = null; + // keep _appliedManifestVenue: pushCrowdManifest clears the crowd + // manifest precisely by seeing it is still set with no venue left refresh(); } }