feat(career): career plugin — stars, venue tiers, pack downloads (career mode 2/3) (#907)

* feat(career): career plugin — stars from song_stats, venue tiers, pack downloads (career mode PR2)

Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★),
cumulative stars unlock bar → club → arena (data-driven venues.json).
Venue packs (UE-rendered crowd loops) download on demand to
CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 +
zip-slip validation, served via FileResponse. Career screen (promoted
sidebar entry) shows progress and pushes the active venue's manifest
into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when
either side is absent. Pack URLs land in venues.json in PR3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): keep manifest cleanup path alive on delete; badge only for installed venues

Codex preflight: nulling _appliedManifestVenue on delete skipped
pushCrowdManifest's setManifest(null) cleanup, leaving the crowd layer on
a deleted pack; and the 'playing here' badge showed for an override venue
whose pack was removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): generation-guard in-flight manifest fetches

Codex preflight: a manifest fetch resolving after a newer refresh (pack
deleted, venue switched) could re-apply a stale pack over the user's
newer selection — fetches now carry a generation token and bail when
superseded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): exclude orphaned song_stats from star totals

Codex preflight: scans hide rather than delete stats of removed songs, so
stars now apply the same existing-song filter other stats surfaces use
(filename IN (SELECT filename FROM songs)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(career): 50/150 star thresholds + star collection overview

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(career): refresh tailwind output

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-12 22:39:19 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e779c72396
commit ea0ca94742
13 changed files with 951 additions and 1 deletions
+66
View File
@@ -0,0 +1,66 @@
/* Career plugin — only what the prebuilt core Tailwind doesn't ship
(plugin files are outside the core content glob, so responsive grid
variants and cyan button shades live here under plugin-prefixed names). */
.career-venues {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.career-btn {
font-size: 0.75rem;
line-height: 1rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
transition: background-color 0.15s ease;
}
.career-btn-primary { background-color: #0891b2; color: #fff; }
.career-btn-primary:hover { background-color: #06b6d4; }
.career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; }
.career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); }
.career-bar-track {
height: 0.5rem;
border-radius: 0.25rem;
background-color: rgba(31, 41, 55, 0.9);
overflow: hidden;
}
.career-bar-fill {
height: 100%;
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; }
+12
View File
@@ -0,0 +1,12 @@
{
"id": "career",
"name": "Career",
"version": "0.1.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.",
"screen": "screen.html",
"script": "screen.js",
"styles": "assets/career.css",
"routes": "routes.py"
}
+267
View File
@@ -0,0 +1,267 @@
"""Career mode — venue progression driven by per-song stars.
Stars come straight from ``song_stats`` (meta.db): per song, the best
accuracy across arrangements crosses 0/1/2/3 of the thresholds in
``venues.json`` (data-driven so tuning never touches code). Cumulative
stars unlock venue tiers (bar → club → arena).
Venue packs (crowd-loop videos rendered offline in UE) are heavyweight and
never ship with the app: ``venues.json`` points at a release asset per
venue, downloaded on demand into ``CONFIG_DIR/plugin_uploads/career/venues/
<id>/`` on a background thread (constitution: nothing heavy inline on the
request path), sha256-verified, then served back with the same
FileResponse/no-cache recipe as highway_3d's custom-video route.
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)
"""
import hashlib
import json
import logging
import re
import shutil
import tempfile
import threading
import urllib.request
import zipfile
from pathlib import Path
from fastapi import HTTPException
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|mp3|json)$")
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
DOWNLOAD_CHUNK = 1024 * 256
_lock = threading.Lock()
_state = {
"content": None, # parsed venues.json
"venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues
"meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL)
"log": logging.getLogger("feedBack.plugin.career"),
"downloads": {}, # venue_id -> {status, bytes_done, bytes_total, error}
}
def _venue(venue_id):
for v in _state["content"]["venues"]:
if v["id"] == venue_id:
return v
return None
def _venue_dir(venue_id) -> Path:
return _state["venues_dir"] / venue_id
def _installed(venue_id):
return (_venue_dir(venue_id) / "manifest.json").is_file()
def _stars():
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
db = _state["meta_db"]
if db is None:
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 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 = {}
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
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):
"""Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json"
if not manifest_path.is_file():
raise ValueError("pack has no manifest.json")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
loops = manifest.get("loops") or {}
for state in REQUIRED_LOOPS:
name = loops.get(state)
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.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):
"""Worker thread: stream → sha256 verify → extract → validate → swap in."""
log = _state["log"]
final_dir = _venue_dir(venue_id)
staging = Path(tempfile.mkdtemp(prefix=f"career-{venue_id}-",
dir=str(_state["venues_dir"])))
zip_path = staging / "pack.zip"
try:
digest = hashlib.sha256()
req = urllib.request.Request(pack["url"], headers={"User-Agent": "feedBack-career"})
with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out:
total = int(resp.headers.get("Content-Length") or pack.get("bytes") or 0)
progress["bytes_total"] = total
while True:
chunk = resp.read(DOWNLOAD_CHUNK)
if not chunk:
break
digest.update(chunk)
out.write(chunk)
progress["bytes_done"] += len(chunk)
if digest.hexdigest() != pack["sha256"]:
raise ValueError("sha256 mismatch — corrupt or tampered download")
extract_dir = staging / "pack"
extract_dir.mkdir()
with zipfile.ZipFile(zip_path) as zf:
for info in zf.infolist():
# Zip-slip guard: only flat, whitelisted names get extracted.
if info.is_dir():
continue
name = Path(info.filename).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)
zip_path.unlink()
_validate_pack_dir(extract_dir)
if final_dir.exists():
shutil.rmtree(final_dir)
extract_dir.rename(final_dir)
progress["status"] = "done"
log.info("career: venue pack '%s' installed", venue_id)
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
progress["status"] = "error"
progress["error"] = str(exc)
log.warning("career: venue pack '%s' download failed: %s", venue_id, exc)
finally:
shutil.rmtree(staging, ignore_errors=True)
def setup(app, context):
plugin_dir = Path(__file__).resolve().parent
_state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8"))
_state["venues_dir"] = (
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
_state["meta_db"] = context.get("meta_db")
_state["log"] = context.get("log") or _state["log"]
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
def get_state():
stars_total, per_song, star_detail = _stars()
venues = []
for v in _state["content"]["venues"]:
with _lock:
dl = dict(_state["downloads"].get(v["id"]) or {"status": "idle"})
venues.append({
"id": v["id"],
"name": v["name"],
"description": v.get("description", ""),
"star_threshold": v["star_threshold"],
"unlocked": stars_total >= v["star_threshold"],
"installed": _installed(v["id"]),
"has_pack": bool(v.get("pack")),
"download": dl,
})
return {
"stars_total": stars_total,
"stars_per_song": per_song,
"star_detail": star_detail,
"star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"],
"venues": venues,
}
@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
if venue is None:
raise HTTPException(404, "Unknown venue.")
pack = venue.get("pack")
if not pack:
raise HTTPException(404, "No pack published for this venue yet.")
stars_total, _, _ = _stars()
if stars_total < venue["star_threshold"]:
raise HTTPException(403, "Venue not unlocked yet.")
with _lock:
running = _state["downloads"].get(venue_id)
if running and running["status"] == "running":
raise HTTPException(409, "Download already running.")
progress = {"status": "running", "bytes_done": 0,
"bytes_total": pack.get("bytes") or 0, "error": None}
_state["downloads"][venue_id] = progress
threading.Thread(target=_download_pack, args=(venue_id, pack, progress),
name=f"career-pack-{venue_id}", daemon=True).start()
return {"ok": True}
@app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}")
def delete_pack(venue_id: str):
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)
if running and running["status"] == "running":
raise HTTPException(409, "Download in progress.")
_state["downloads"].pop(venue_id, None)
shutil.rmtree(_venue_dir(venue_id), ignore_errors=True)
return {"ok": True}
@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.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):
# the resolved path must stay inside the venues dir.
try:
resolved = path.resolve()
resolved.relative_to(_state["venues_dir"].resolve())
except (OSError, ValueError):
raise HTTPException(404, "Not found.")
if not resolved.is_file():
raise HTTPException(404, "Not found.")
media = {"mp4": "video/mp4", "webm": "video/webm", "mp3": "audio/mpeg",
"json": "application/json"}[resolved.suffix.lstrip(".").lower()]
return FileResponse(
resolved,
media_type=media,
# Pack files are immutable per version, but a re-download after a
# pack update overwrites in place — no-cache + ETag revalidation
# keeps browsers honest for the price of a 304.
headers={"Cache-Control": "no-cache",
"X-Content-Type-Options": "nosniff"},
)
+21
View File
@@ -0,0 +1,21 @@
<div class="max-w-5xl mx-auto px-4 py-6">
<div class="flex items-end justify-between flex-wrap gap-3 mb-1">
<h1 class="text-2xl font-bold text-white">Career</h1>
<div id="career-stars-summary" class="text-sm text-gray-400"></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 id="career-progress-wrap" class="mb-6">
<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>
+277
View File
@@ -0,0 +1,277 @@
/*
* Career plugin — venue progression UI + crowd-manifest push.
*
* Reads /api/plugins/career/state (stars from song_stats, per-venue
* unlock/install/download status), renders the career screen, and pushes the
* active venue's pack manifest into the crowd video layer
* (window.v3VenueCrowd, shipped with the venue crowd PR) whenever it changes.
* Everything degrades: no crowd layer → screen still works; no packs → the
* venue scene keeps its static plate.
*/
(function () {
'use strict';
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;
let _pollTimer = 0;
let _appliedManifestVenue = null;
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
let _prevUnlockedIds = null;
function $(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function fetchState() {
const res = await fetch(API + '/state');
if (!res.ok) throw new Error('career state ' + res.status);
return res.json();
}
function lastOf(arr) { return arr.length ? arr[arr.length - 1] : null; }
// Active pack = localStorage override when unlocked+installed, else the
// highest unlocked+installed tier; none → clear the crowd manifest.
async function pushCrowdManifest(state) {
const crowd = window.v3VenueCrowd;
if (!crowd || typeof crowd.setManifest !== 'function') return;
// Any newer invocation (delete, venue switch, fresher state) must win
// over a manifest fetch still in flight from this one.
const gen = ++_manifestReqGen;
const unlocked = state.venues.filter((v) => v.unlocked);
let venue = null;
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;
if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
}
if (!venue) {
if (_appliedManifestVenue !== null) {
_appliedManifestVenue = null;
crowd.setManifest(null);
}
return;
}
if (venue.id === _appliedManifestVenue) return;
try {
const res = await fetch(`${API}/venues/${venue.id}/manifest.json`);
if (gen !== _manifestReqGen || !res.ok) return;
const manifest = await res.json();
if (gen !== _manifestReqGen) return;
manifest.base = `${API}/venues/${venue.id}/`;
_appliedManifestVenue = venue.id;
crowd.setManifest(manifest);
} catch (_) { /* pack half-installed; next refresh retries */ }
}
function venueCardHTML(v, state) {
const locked = !v.unlocked;
const dl = v.download || { status: 'idle' };
const pct = dl.bytes_total > 0
? Math.round((dl.bytes_done / dl.bytes_total) * 100) : 0;
let action = '';
if (locked) {
action = `<div class="text-xs text-gray-500">Unlocks at ${v.star_threshold} ★ — ${Math.max(0, v.star_threshold - state.stars_total)} to go</div>`;
} else if (dl.status === 'running') {
action = `<div class="career-bar-track mb-1" style="height:0.375rem"><div class="career-bar-fill" style="width:${pct}%"></div></div>
<div class="text-xs text-gray-400">Downloading… ${pct}%</div>`;
} else if (v.installed) {
const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
const main = active
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
action = `<div class="flex items-center gap-2">
${main}
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
</div>`;
} else if (v.has_pack) {
const err = dl.status === 'error'
? `<div class="text-xs text-amber-400 mb-1">${esc(dl.error || 'Download failed')} — try again</div>` : '';
action = `${err}<button data-career-download="${esc(v.id)}" class="career-btn career-btn-primary">Download venue pack</button>`;
} else {
action = '<div class="text-xs text-gray-500">Venue pack coming soon — plays with the standard stage for now</div>';
}
// Mirror pushCrowdManifest(): an override only counts while the pack
// is installed — after a removal the badge must not claim a venue the
// crowd layer can't use.
const isActive = !locked && v.installed &&
localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
return `<div class="rounded-xl border ${locked ? 'border-gray-800 opacity-60' : 'border-gray-700'} bg-dark-700/40 p-4 flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-white">${esc(v.name)}${isActive ? ' <span class="text-cyan-400 text-xs">● playing here</span>' : ''}</div>
<div class="text-xs text-gray-400">${v.star_threshold} ★</div>
</div>
<div class="text-xs text-gray-400 flex-1">${esc(v.description)}</div>
${action}
</div>`;
}
function starGlyphs(n) {
let out = '';
for (let i = 0; i < 3; i++) {
out += `<span class="${i < n ? 'on' : 'off'}">★</span>`;
}
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 = '<div class="text-xs text-gray-500">Play songs to start collecting stars — 60% accuracy earns the first one.</div>';
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 `<div class="career-star-row">
<span class="stars">${starGlyphs(r.stars)}</span>
<span class="song">${esc(r.title)}${r.artist ? ` <span class="artist">— ${esc(r.artist)}</span>` : ''}</span>
<span class="hint${close}">best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}</span>
</div>`;
}).join('');
}
function render(state) {
const host = $('career-venues');
if (!host) return;
$('career-stars-summary').textContent = `${state.stars_total} total`;
const next = state.venues.find((v) => !v.unlocked);
const bar = $('career-progress-bar');
const label = $('career-progress-label');
if (next) {
const prevThreshold = state.venues
.filter((v) => v.unlocked)
.reduce((m, v) => Math.max(m, v.star_threshold), 0);
const span = Math.max(1, next.star_threshold - prevThreshold);
const into = Math.max(0, state.stars_total - prevThreshold);
bar.style.width = Math.min(100, Math.round((into / span) * 100)) + '%';
label.textContent = `${state.stars_total} / ${next.star_threshold} ★ to unlock ${next.name}`;
} else {
bar.style.width = '100%';
label.textContent = 'All venues unlocked — enjoy the arena.';
}
host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join('');
renderStars(state);
}
function schedulePoll(state) {
clearTimeout(_pollTimer);
if (state.venues.some((v) => (v.download || {}).status === 'running')) {
_pollTimer = setTimeout(refresh, POLL_MS);
}
}
function announceUnlocks(state) {
const unlocked = state.venues.filter((v) => v.unlocked).map((v) => v.id);
if (_prevUnlockedIds) {
for (const v of state.venues) {
if (v.unlocked && !_prevUnlockedIds.includes(v.id)) {
const sm = window.feedBack;
if (sm && typeof sm.emit === 'function') {
sm.emit('career:venue-unlocked', { id: v.id, name: v.name });
}
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
window.fbNotify.show({
big: true, icon: '🎤', accent: '#06B6D4',
title: 'New venue unlocked!',
message: `${v.name} — your crowd just got bigger.`,
});
}
}
}
}
_prevUnlockedIds = unlocked;
}
async function refresh() {
let state;
try {
state = await fetchState();
} catch (_) {
return; // server restarting; next trigger retries
}
_state = state;
announceUnlocks(state);
render(state);
schedulePoll(state);
pushCrowdManifest(state);
}
function onClick(e) {
const dlBtn = e.target.closest('[data-career-download]');
const delBtn = e.target.closest('[data-career-delete]');
const playBtn = e.target.closest('[data-career-play]');
if (dlBtn) {
fetch(`${API}/packs/${dlBtn.dataset.careerDownload}/download`, { method: 'POST' })
.then(refresh);
} else if (delBtn) {
// Do NOT null _appliedManifestVenue here: pushCrowdManifest()
// clears/replaces the crowd manifest precisely by seeing that the
// applied venue is no longer among the installed ones.
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
.then(refresh);
} else if (playBtn) {
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 */ }
// keep _appliedManifestVenue: pushCrowdManifest clears the crowd
// manifest precisely by seeing it is still set with no venue left
refresh();
}
}
function boot() {
const screen = document.getElementById('plugin-career');
if (screen) screen.addEventListener('click', onClick);
const sm = window.feedBack;
if (sm && typeof sm.on === 'function') {
// New song stats can add stars → thresholds may cross mid-session.
sm.on('stats:recorded', () => refresh());
}
refresh();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
+30
View File
@@ -0,0 +1,30 @@
{
"star_accuracy_thresholds": [
0.6,
0.75,
0.85
],
"venues": [
{
"id": "bar",
"name": "The Dive Bar",
"description": "Sticky floors, a dozen regulars, and a PA that has seen better decades.",
"star_threshold": 0,
"pack": null
},
{
"id": "club",
"name": "Velvet Room",
"description": "A proper club stage. People actually came to hear you.",
"star_threshold": 50,
"pack": null
},
{
"id": "arena",
"name": "Feedback Arena",
"description": "Ten thousand seats. Try not to think about it.",
"star_threshold": 150,
"pack": null
}
]
}