mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
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:
co-authored by
Claude Fable 5
parent
e779c72396
commit
ea0ca94742
@@ -24,6 +24,9 @@ plugins/*/
|
|||||||
!plugins/achievements/
|
!plugins/achievements/
|
||||||
!plugins/achievements/**
|
!plugins/achievements/**
|
||||||
plugins/achievements/__pycache__/
|
plugins/achievements/__pycache__/
|
||||||
|
!plugins/career/
|
||||||
|
!plugins/career/**
|
||||||
|
plugins/career/__pycache__/
|
||||||
!plugins/highway_3d/
|
!plugins/highway_3d/
|
||||||
!plugins/highway_3d/**
|
!plugins/highway_3d/**
|
||||||
plugins/highway_3d/__pycache__/
|
plugins/highway_3d/__pycache__/
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -48,6 +48,7 @@
|
|||||||
// above. Screens are injected async by the plugin loader, so go()'s
|
// above. Screens are injected async by the plugin loader, so go()'s
|
||||||
// plugin- guard applies.
|
// plugin- guard applies.
|
||||||
{ key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' },
|
{ key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' },
|
||||||
|
{ key: 'career', screen: 'plugin-career', label: 'Career', group: null, icon: 'trophy' },
|
||||||
{ key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' },
|
{ key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' },
|
||||||
{ key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' },
|
{ key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' },
|
||||||
{ key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' },
|
{ key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' },
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
// that group. Each is gated on the plugin actually being installed.
|
// that group. Each is gated on the plugin actually being installed.
|
||||||
const PROMOTED_PLUGINS = [
|
const PROMOTED_PLUGINS = [
|
||||||
{ navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' },
|
{ navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' },
|
||||||
|
{ navKey: 'career', pluginId: 'career', slotId: 'v3-nav-career', anchorAfter: 'feedbarcade' },
|
||||||
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
|
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
|
||||||
{ navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' },
|
{ navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' },
|
||||||
{ navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' },
|
{ navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' },
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const PLUGIN_DIR = path.join(ROOT, 'plugins', 'career');
|
||||||
|
const SHELL_JS = path.join(ROOT, 'static', 'v3', 'shell.js');
|
||||||
|
|
||||||
|
test('career plugin manifest is complete and bundled', () => {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'plugin.json'), 'utf8'));
|
||||||
|
assert.equal(manifest.id, 'career');
|
||||||
|
assert.equal(manifest.bundled, true);
|
||||||
|
assert.equal(manifest.screen, 'screen.html');
|
||||||
|
assert.equal(manifest.script, 'screen.js');
|
||||||
|
assert.equal(manifest.routes, 'routes.py');
|
||||||
|
for (const f of ['screen.html', 'screen.js', 'routes.py', 'venues.json', manifest.styles]) {
|
||||||
|
assert.ok(fs.existsSync(path.join(PLUGIN_DIR, f)), `${f} missing`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('venues.json defines the 3 ascending tiers with star thresholds', () => {
|
||||||
|
const content = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, 'venues.json'), 'utf8'));
|
||||||
|
assert.deepEqual(content.star_accuracy_thresholds, [0.6, 0.75, 0.85]);
|
||||||
|
const venues = content.venues;
|
||||||
|
assert.deepEqual(venues.map((v) => v.id), ['bar', 'club', 'arena']);
|
||||||
|
assert.equal(venues[0].star_threshold, 0, 'bar must always be unlocked');
|
||||||
|
for (let i = 1; i < venues.length; i++) {
|
||||||
|
assert.ok(venues[i].star_threshold > venues[i - 1].star_threshold,
|
||||||
|
'thresholds must ascend');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shell promotes the career plugin into the sidebar', () => {
|
||||||
|
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
||||||
|
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
||||||
|
assert.match(src, /navKey: 'career',\s*pluginId: 'career',\s*slotId: 'v3-nav-career'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('career screen pushes the crowd manifest with a base URL', () => {
|
||||||
|
const src = fs.readFileSync(path.join(PLUGIN_DIR, 'screen.js'), 'utf8');
|
||||||
|
assert.match(src, /v3VenueCrowd/);
|
||||||
|
assert.match(src, /setManifest\(manifest\)/);
|
||||||
|
assert.match(src, /manifest\.base = /);
|
||||||
|
assert.match(src, /feedBack-career-venue/);
|
||||||
|
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||||
|
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'career'))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||||
|
sys.modules.pop('routes', None)
|
||||||
|
import routes as career_routes
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMetaDb:
|
||||||
|
"""song_stats-only stand-in for MetadataDB (the plugin reads nothing else)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||||
|
self.conn.execute(
|
||||||
|
"""CREATE TABLE song_stats (
|
||||||
|
filename TEXT, arrangement TEXT, best_accuracy REAL
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
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.replace(".feedpak", "").title(), "Test Artist", filename))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _bind_career_routes():
|
||||||
|
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
|
||||||
|
prev = sys.modules.get('routes')
|
||||||
|
sys.modules['routes'] = career_routes
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if prev is not None:
|
||||||
|
sys.modules['routes'] = prev
|
||||||
|
else:
|
||||||
|
sys.modules.pop('routes', None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state():
|
||||||
|
# Module state outlives tests when the module stays imported — reset the
|
||||||
|
# mutable bits so ordering can't leak downloads/content between tests.
|
||||||
|
career_routes._state["downloads"] = {}
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def meta_db():
|
||||||
|
return FakeMetaDb()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, meta_db):
|
||||||
|
app = FastAPI()
|
||||||
|
career_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db})
|
||||||
|
return TestClient(app)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""HTTP-level tests for the career plugin: stars, unlocks, packs."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import routes as career_routes
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_pack(venue_id, files=None):
|
||||||
|
"""Drop a valid installed pack into the plugin's venues dir."""
|
||||||
|
pack_dir = career_routes._venue_dir(venue_id)
|
||||||
|
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
loops = {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS}
|
||||||
|
(pack_dir / "manifest.json").write_text(json.dumps(
|
||||||
|
{"venue": venue_id, "version": 1, "loops": loops,
|
||||||
|
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"}}))
|
||||||
|
for name in list(loops.values()) + ["clap.mp4", "cheer.mp4"]:
|
||||||
|
(pack_dir / name).write_bytes((files or {}).get(name, b"\x00video"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_stars_from_best_accuracy_across_arrangements(client, meta_db):
|
||||||
|
# Thresholds 0.6/0.75/0.85 → 1/2/3 stars; best arrangement wins.
|
||||||
|
meta_db.add("a.feedpak", "guitar", 0.5) # 0 stars
|
||||||
|
meta_db.add("b.feedpak", "guitar", 0.62) # 1 star
|
||||||
|
meta_db.add("c.feedpak", "guitar", 0.70)
|
||||||
|
meta_db.add("c.feedpak", "bass", 0.80) # 2 stars (max across arrangements)
|
||||||
|
meta_db.add("d.feedpak", "guitar", 0.99) # 3 stars
|
||||||
|
state = client.get("/api/plugins/career/state").json()
|
||||||
|
assert state["stars_total"] == 6
|
||||||
|
assert state["stars_per_song"] == {"b.feedpak": 1, "c.feedpak": 2, "d.feedpak": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlock_flags_follow_thresholds(client, meta_db):
|
||||||
|
# 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()
|
||||||
|
by_id = {v["id"]: v for v in state["venues"]}
|
||||||
|
assert by_id["bar"]["unlocked"] is True
|
||||||
|
assert by_id["club"]["unlocked"] is False
|
||||||
|
assert by_id["arena"]["unlocked"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_orphaned_stats_do_not_count(client, meta_db):
|
||||||
|
# A song removed from the library (stats row survives the scan) must not
|
||||||
|
# keep contributing stars.
|
||||||
|
meta_db.add("gone.feedpak", "guitar", 0.99, in_library=False)
|
||||||
|
meta_db.add("here.feedpak", "guitar", 0.99)
|
||||||
|
state = client.get("/api/plugins/career/state").json()
|
||||||
|
assert state["stars_total"] == 3
|
||||||
|
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
|
||||||
|
assert state["venues"][0]["unlocked"] is True # bar is always open
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_unknown_venue_404s(client):
|
||||||
|
assert client.post("/api/plugins/career/packs/nope/download").status_code == 404
|
||||||
|
assert client.post("/api/plugins/career/packs/../etc/download").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_without_published_pack_404s(client):
|
||||||
|
# venues.json ships pack: null until packs are released.
|
||||||
|
assert client.post("/api/plugins/career/packs/bar/download").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_locked_venue_403s(client, monkeypatch):
|
||||||
|
club = career_routes._venue("club")
|
||||||
|
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||||
|
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_pack_file_serving_and_traversal_guard(client):
|
||||||
|
_install_fake_pack("bar")
|
||||||
|
ok = client.get("/api/plugins/career/venues/bar/manifest.json")
|
||||||
|
assert ok.status_code == 200
|
||||||
|
assert ok.json()["loops"]["ecstatic"] == "ecstatic.mp4"
|
||||||
|
video = client.get("/api/plugins/career/venues/bar/bored.mp4")
|
||||||
|
assert video.status_code == 200
|
||||||
|
assert video.headers["content-type"].startswith("video/mp4")
|
||||||
|
assert video.headers["x-content-type-options"] == "nosniff"
|
||||||
|
# Traversal / junk shapes never resolve.
|
||||||
|
for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"):
|
||||||
|
assert client.get(f"/api/plugins/career/venues/bar/{bad}").status_code == 404
|
||||||
|
assert client.get("/api/plugins/career/venues/../bar/manifest.json").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_reports_installed_and_delete_removes(client):
|
||||||
|
_install_fake_pack("bar")
|
||||||
|
state = client.get("/api/plugins/career/state").json()
|
||||||
|
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is True
|
||||||
|
assert client.delete("/api/plugins/career/packs/bar").status_code == 200
|
||||||
|
state = client.get("/api/plugins/career/state").json()
|
||||||
|
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_worker_end_to_end(client, tmp_path):
|
||||||
|
# Build a real pack zip, serve it via file://, verify the full worker path:
|
||||||
|
# stream → sha256 → extract (flat names only) → validate → swap in.
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
names = [f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS] + ["cheer.mp4"]
|
||||||
|
for name in names:
|
||||||
|
(src / name).write_bytes(b"fake-video-" + name.encode())
|
||||||
|
(src / "manifest.json").write_text(json.dumps({
|
||||||
|
"venue": "bar", "version": 1,
|
||||||
|
"loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS},
|
||||||
|
"stingers": {"cheer": "cheer.mp4"},
|
||||||
|
}))
|
||||||
|
zip_path = tmp_path / "bar-pack.zip"
|
||||||
|
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||||
|
for p in src.iterdir():
|
||||||
|
zf.write(p, p.name)
|
||||||
|
sha = hashlib.sha256(zip_path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
||||||
|
career_routes._download_pack(
|
||||||
|
"bar", {"url": zip_path.as_uri(), "sha256": sha}, progress)
|
||||||
|
assert progress["status"] == "done", progress["error"]
|
||||||
|
assert career_routes._installed("bar")
|
||||||
|
assert progress["bytes_done"] == zip_path.stat().st_size
|
||||||
|
|
||||||
|
# Corrupt hash → error status, nothing installed over the good pack.
|
||||||
|
bad = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
|
||||||
|
career_routes._download_pack("bar", {"url": zip_path.as_uri(), "sha256": "0" * 64}, bad)
|
||||||
|
assert bad["status"] == "error"
|
||||||
|
assert "sha256" in bad["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_download_409s(client, monkeypatch):
|
||||||
|
bar = career_routes._venue("bar")
|
||||||
|
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
|
||||||
|
# Pretend one is already running.
|
||||||
|
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||||
|
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||||
|
assert client.delete("/api/plugins/career/packs/bar").status_code == 409
|
||||||
Reference in New Issue
Block a user