* fix(career): a gig's song pool is the whole genre, and re-roll varies it
Two tester reports, one root: the gig song pool was built from only two sets —
songs played ON THIS PASSPORT'S INSTRUMENT, and songs never played AT ALL
(`filename NOT IN song_stats`).
A song played on a DIFFERENT instrument's arrangement is in neither: it has a
stats row (so the "unplayed" filler skipped it), and its played bucket is that
other instrument's, not this passport's. It could never be gigged.
- "Metalcore says 137 songs only shows 1 in the gig list" — a library of
metalcore all played on another instrument. Reproduced: a guitar passport with
137 bass-played metalcore songs got a 404, zero songs. The "1" the tester saw
was whatever handful happened to be on-instrument or truly unplayed.
- "Passport re-roll does not change songs" — a set drawn from that filler was the
library's first N in table ORDER, every call. Re-roll re-proposes, so it
returned the identical set. Reproduced: 3 proposals, byte-identical.
_unplayed_genre_songs -> _fill_genre_songs: the pool is now every library song of
the genre the set hasn't already picked (a stats row on some other instrument has
no bearing on whether a song can be in THIS gig), and it is shuffled so re-roll
actually re-rolls.
Both reproduced against the real propose logic before the fix and pinned as
regression tests (both fail on the pre-fix routes.py). Full career suite green.
* fix(career): load the gig's venue pack when the gig starts
Tester: "Venue doesn't load when starting song from passport. Loads standard
particles."
crowd.setManifest(venue) — the call that actually loads a venue's crowd/stage
pack — is reached ONLY through pushCrowdManifest, and pushCrowdManifest is
called ONLY from refresh(), the career tab's own reload. A gig navigates AWAY
from the career tab to the player, so refresh() never runs during it. startGig
set the venue override and nulled _appliedManifestVenue but never re-pushed, so
the venue visualization turned on (3D highway) while its pack never loaded — the
song played over the bare highway backdrop, or over whatever venue a previous
refresh() had left applied.
startGig now pushes the crowd manifest for the gig venue right after setting the
override, using the career state the booking screen already fetched.
This is a call-graph fact, not a guess (pushCrowdManifest has exactly one other
caller and startGig is not it), but it is fixed by static analysis — I could not
reproduce the user-visible symptom locally because this instance happened to have
a manifest already applied from a prior refresh. On-device confirmation on a real
passport gig is still owed.
Guard test: startGig must push the manifest after setting the override (fails on
the pre-fix source). Career suite green.
* feat(career): extract the whole setlist before the gig starts
A feedpak is a zip, and the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs: the player finished
a number and then sat there waiting for the next one to unpack, mid-gig.
A setlist is a known list up front, so unpack it all while the poster is still on
screen. New POST /gigs/prepare walks the set through resolve_source_dir; the
poster's Play button shows "Preparing set…" while it runs.
Best-effort by design, at every level:
- a corrupt pak in the set does not sink the prepare (it is reported in
`failed`; the play itself surfaces the error exactly as it does outside a
gig — slow beats blocked)
- a host without the library resolvers degrades to a no-op rather than 500
- a failed request just falls through to the old lazy extraction
Ordering matters and is pinned: the set is unpacked BEFORE the stage is borrowed
(venue/viz overwritten) and before the queue starts, so a proposal cancelled
while unpacking leaves nothing half-applied to unwind.
Tests unpack REAL zips rather than mocking the extractor: every song of the set
lands on disk before the first note, a re-prepare does not duplicate the unpack,
one bad pak still leaves the good one prepared, and no-library / empty-setlist
degrade cleanly. 18/18.
NB the other half of the gig report — the per-song results popup interrupting
the set (and worse, claimAutoExit'ing so the queue would not advance until it was
dismissed) — is fixed in the note_detect plugin repo, which is not part of this
checkout.
* fix(career): bound the prepare request; validate the setlist (PR #971 review)
Both CodeRabbit findings were right.
1. A HUNG PREPARE COULD BLOCK THE GIG FOREVER.
`await fetch(...)` only rejects on a network ERROR. A server that accepts the
connection and then never answers hangs indefinitely — and the gig would never
start. That makes this optimisation the exact thing the PR promises it can
never be: the reason you cannot play.
The request is now bounded by an AbortController (PREPARE_TIMEOUT_MS, generous
because unpacking a setlist is real work — but a CEILING, not a wait). Past it
we start the gig and let the first play extract lazily, as it always did. The
Play button is restored in a `finally`, so a timeout cannot strand the poster
on "Preparing set…" with Play disabled — which would have been the same bug
wearing a different hat.
2. THE `songs` BODY WAS UNVALIDATED.
A str is iterable: "abc" would have prepared three one-character "songs". And
the endpoint unpacks zips, so an arbitrary caller could ask for unbounded work.
Now list-only, string entries, blanks dropped, capped at MAX_GIG_SONGS.
Tests: the fetch is abortable and the button is re-enabled on EVERY path
including the abort; non-list bodies, non-string/blank entries, and an
oversized setlist. 50 career tests, JS 5/5, eslint clean.
* fix(career): path-traversal guard on prepare; a cap test that actually tests the cap
CodeRabbit again, and the first one is a real hole I put there.
1. PATH TRAVERSAL. sloppak.resolve_source_dir() does a bare `dlc_root / filename`
with NO containment guard — so `../../x` walks straight out of the library, and
my new endpoint handed it attacker-supplied filenames. Every filename now goes
through _resolve_dlc_path first, the same check every other filename-bound
handler applies. Pinned: `..`, backslash traversal, an absolute POSIX path and
a Windows drive path are all refused, and nothing outside the library is
unpacked.
2. THE CAP TEST WAS VACUOUS. It asserted `prepared == 0` against a fixture with no
library — where the endpoint exits before extraction — so it passed whether or
not MAX_GIG_SONGS existed. It now runs against a real library and asserts the
endpoint CONSIDERED at most MAX_GIG_SONGS of the 82 it was handed. Verified to
fail when the cap is removed.
Same class of mistake as the notedetect gigBlock: a test that passes for the
wrong reason. Worth saying out loud since it is twice in one day.
3. E702 — semicolon-joined statements in the new tests, split.
51 career tests; full suite green.
The Velvet Room (50 stars) now ships in every build like the bar and
arena: 4 reactive crowd loops, 2 stingers, and a balcony flyover intro
rendered from the AXA Music Stage scene (110 spectators, state-scaled
stage washes over the venue's own neon). Audio files are dive-bar
placeholders until club-scale recordings land.
The installed/delete test now asserts bundled-fallback semantics:
with every venue bundled, deleting a downloaded pack reveals the
bundled copy instead of uninstalling.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Feedback Arena (150 stars) now ships in every build like the bar:
4 reactive crowd loops, 2 stingers, and a flyover intro rendered
from the UE5 arena scene (200 spectators + 396-body intro fill,
state-reactive rig lighting). Served by the existing bundled-pack
fallback; venues.json unchanged. Audio files are dive-bar
placeholders until arena-scale recordings land.
Largest file is 89MB — future re-renders must stay under GitHub's
100MB hard limit.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(career): Gold tier — a family-style goldImprov artifact upgrades an earned badge
The drill-state relay's goldImprov map (virtuoso gold_improv mints,
gained-only merged like drill nodes) turns an earned badge gold when the
passport's genre — or its genre family — has a verified improv artifact.
Gold never substitutes for the badge bar: gold-without-bronze stays
in_progress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(career): Gold tier frontend — relay, ceremony, slam, gold ink everywhere
The drill-state relay now carries virtuoso's goldImprov map; a badge that
comes back gold gets its own ceremony + notification (tier-suffixed seen
ids — the bronze moment stays seen under its legacy id, a gold slam marks
both), a gold stamp slam in the book, gold ink on the shelf-cover mini
stamp, and the real gold foil chip. The bronze page's dashed 'Gold rung
coming' preview becomes a live invitation to jam the style in Virtuoso.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(career): gold review fixes — family-space style matching, intake guards, rail counter
The review's showstopper: virtuoso mints goldImprov under raw
STYLE_PALETTES ids ('punk', 'djent', 'disco'), which are mostly NOT
family keys — the tier check now matches in family space (artifact style
and passport genre bucket through the same _genre_family keyword match),
so a 'punk' gold reaches a 'punk rock' passport. Also: non-dict
goldImprov 400s loudly instead of silently dropping; evidence-free
artifacts (no verifier) never mint; goldImprov gets the same pre-merge
size bound byNode has (junk under the cap could otherwise persist
forever and wedge every later relay at the post-merge check); the
instrument-rail badge counter counts gold (earning gold no longer made a
badge vanish from the rail); first-artifact-wins is now asserted against
the persisted snapshot instead of vacuously.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(career): gigs frontend — poster, runner strip, summary, encore
Career v3, WS3 (frontend half), rebuilt cleanly on merged main (v3-a/b/c
in) after git interleaved the structurally-similar canvas functions:
- Book a gig from any opened passport: /gigs/propose renders as a GIG
POSTER (venue presents GENRE NIGHT, numbered bill) with re-roll and
Save/Copy poster (natively-drawn canvas via blob-io, audible failure
paths, slash-safe filenames).
- Play the gig: venue override + Venue viz handoff, then
playQueue.start(..., {source:'gig'}) — the queue's auto-advance runs
the set; zero new playback machinery.
- Floating gig strip (body-level, pointer-events none, z 35 per the
chrome invariant) tracks set position and names what's next.
- Completion = song:ended with an empty queue → POST /gigs → summary
poster overlay with per-song accuracies; encore fires the crowd
celebrate + confetti (reduced-motion: neither). song:stop with a dead
queue = abandoned (no log); end-of-song teardown (queue still active)
must NOT abandon.
- Gigs played render as dated rows in the passport book.
vm tests: runner advance/abandon semantics via the queue-state seam.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(career): stage restore survives a setViz throw; size-register row
CodeRabbit on #956: a setViz failure nulled the restore snapshot AFTER
the overrides were written, permanently borrowing the stage — snapshot
now captured before any write, write failures keep it intact. Also
registers career screen.js in docs/size-exemptions.md 'Planned, not
exempt' (1,516 lines; max-lines WARNS non-blocking — the split plan
needs Byron's sign-off).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: register career screen.js in the size register (planned, not exempt)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(career): gigs backend — propose a setlist, log the completed set
Career v3, WS3 (backend half). A gig is career's verb:
- POST /gigs/propose {instrument, genre, size}: setlist from the
passport's own stubs — qualifying songs (per the genre's badge bar,
family-aware) shuffled for a free re-roll, topped with the
highest-accuracy near-bar songs as stakes, and filled from UNPLAYED
genre songs when the passport is young (the first gig is how stubs
start). Names the highest venue the current stars can book.
- POST /gigs: logs a COMPLETED set only (abandoned sets never log — no
fail state). Per-song accuracy = MAX(last_accuracy) from song_stats,
freshly written by the set's own plays; encore = avg ≥ the data-driven
bar (passports.json gig.encore_accuracy, 0.75). Appends to the career
state file (same atomic _save_json pattern).
- Passports view: per-passport gigs (newest first, capped 20) and
per-instrument gig_count — the profile wall's gig line lights up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(career): gig backfill offsets by qualifying taken, not picks length
CodeRabbit on #954: after the stakes loop appends near-bar songs,
qualifying[len(picks):] overshoots and skips eligible qualifying songs
— a stocked passport could still get a short set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(career): profile passport wall, home career card, shareable PNG card
Career v3, WS2. The identity artifact leaves the plugin tab:
- Profile: #v3-profile-passports-mount (core, one div) filled by career
on v3:profile-rendered — per-instrument shelves of earned covers,
hours, gig count, open-career link. Absent-not-empty.
- Home: the plugin-count stat tile becomes #v3-dash-career-slot with the
old stat as fallback content; career replaces it with a trading-card
tile (leather + foil shine, badge count, hours, closest-stamp ask) on
the existing v3:dashboard-rendered event.
- Shareable card: static/js/blob-io.js (downloadBlob lifts the idiom
duplicated verbatim in settings-io/diagnostics-export — both
refactored; copyImageBlob wraps ClipboardItem, returns false to signal
the download fallback). Earned passports get Save/Copy card: a
natively-drawn 480×640 canvas (leather, stamp ring, stubs+hours line);
copy falls back to download with a notice when the clipboard refuses.
- Mount-point convention documented in docs/plugin-v3-ui.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(career): external surfaces stay absent until a passport exists
CodeRabbit on #955: a bare commitment produced a zero-passport wall and
replaced the dashboard fallback. Docs also now say mounts may hold
fallback content and plugins REPLACE, never append.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(career): practice invitations — closest stamps + bring-these-up
Career v3, WS1. The passport now points at the practice that pays:
- Stubs carry next_star_at (the same primitive _stars() uses) and each
passport exposes `nearest`: the top 3 non-qualifying songs by distance
to their next star, in the worklist order.
- "Closest stamps" strip above the shelf: the in-progress graded
passports nearest to minting, each row naming the ask — N more songs
(with the nearest title + best %) or the blocking Virtuoso drill.
Rows open the passport.
- "Bring these up" list on the stubs page of in-progress passports;
earned pages stay memorabilia (no homework on a won badge).
- Invitation-voiced throughout: no meters, no completion pressure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(test): comment says qualifying-bar ranking, matching the assertion
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The enrichment fallback made the passport rack real (hundreds of MB
sub-genres) but only the five exact umbrella keys carried Virtuoso
drills. Genres now resolve to a family by keyword substring (MB's
vocabulary is open — 'metalcore' must hit metal without an alias),
first-match-wins in list order ('blues rock' → blues), and inherit the
family's requirement from the same genres map. Exact entries still win;
per-instrument scoping unchanged; unmatched genres stay songs-only.
Data: families for metal (incl. djent/grindcore/thrash/doom), blues,
jazz (bebop/swing/bossa), funk (disco), rock (punk/grunge/shoegaze).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(career): passport visuals pack — tilt, emerging ink, gold foil
Career v2, WS4. All CSS + a rAF-throttled pointer handler, no deps:
- Trading-card tilt on EARNED artifacts only (shelf covers + the badge
page stamp): pointer-tracked perspective rotateX/Y with a glint sweep
following the pointer. The cover's jitter rotation moves into a CSS
var so the tilt transform composes with it; the blanket cover-hover
translate is scoped :not(.pp-tilt) so it can't fight the tilt.
Hover-capable pointers only; off under prefers-reduced-motion.
- Emerging-stamp ink: the ghost stamp fills by qualifying/required via
a conic-gradient — the stamp visibly carves in, no numbers added.
- Gold foil preview: the "coming" note gains a dashed foil chip with a
periodic shimmer — honest, never earnable-looking.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(career): tilt rAF race + freshly-slammed stamp becomes a card
CodeRabbit on #944: a queued tilt frame closed over the departed card
and re-applied vars after pointerleave; and the just-slammed stamp
never regained pp-tilt until the next open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Career v2, WS3. Bronze in blues/rock/metal/funk/jazz now also asks for
the genre's signature Virtuoso drill, data-driven in passports.json:
blues_shuffle, rock_power_backbeat, melodic_metal_gallop,
sixteenth_pocket, vl_shells — with career-side display labels the
passport page renders instead of raw node ids.
- virtuoso_nodes becomes {instrument: [node_ids]} so a keys passport
never demands a guitar drill; a flat list keeps meaning guitar
(virtuoso's content is guitar-first).
- _node_cleared also accepts keysCleared (a top-tier clean pass in one
key — virtuoso's FIRST gained-only artifact). The depth rungs
additionally require a maxed speed tier, too high a bar for Bronze.
- Genres without a curated entry stay songs-only.
Note: pre-release behavior change — v1 passports aren't in any shipped
build, so no earned badge can demote in the wild.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Career v2, WS2. Nothing measured play time before (the achievements
plugin's final-position shortcut double-counts loops and mis-reads
seeks). Now:
- stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔
pause/stop/ended spans (single spans clamp at 2h against suspend
inflation) and piggybacks them as `seconds` on the POSTs it already
sends; failed POSTs restore the accumulator; a session reset flushes
first so time can't re-attribute to the next song/arrangement.
- POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the
scored and position branches, plus a new seconds-only branch for
unscored plays that ran to the natural end — banks time WITHOUT
touching the resume position (song:ended must not overwrite Continue)
and still counts as playing today for the streak.
- song_stats gains additive idempotent `seconds_total`; record_session/
touch_position accrue, new add_play_seconds() for the seconds-only
path; the legacy-encoding stats merge sums seconds across duplicates.
- Passports surface it: "14.2 h in Blues" under the badge stamp and on
the shelf cover sub-line — a true fact that only grows, never a
target or a meter (Stage 5 post-cap, per the career design).
Tests: seconds accrual/validation/seconds-only branch (stats API),
per-instrument-and-genre summing (career), fmtHours formatting (vm).
Full suites: pytest 2480, JS 1165.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Earning a genre badge now stages the full moment (career v2, WS1):
- venue-crowd.js gains a public celebrate(): machine.force('ecstatic')
commits instantly (bypassing STABLE_MS/DWELL_MS; the stamped
lastSwitchAt makes the dwell window HOLD the forced state before the
real perf machine reasserts) + a cheer stinger via the same
_lastStingerAt=-Infinity bypass the end-of-song reaction uses. If a
stinger/intro owns the idle layer, the ecstatic loop is queued via
_pendingLoop exactly like onPerformanceState. No-op without a
manifest/active venue.
- career detectNewBadges() calls badgeCeremony(): crowd first, then a
body-appended full-screen overlay 300ms later (it cannot live in
#pp-overlay — #plugin-career is display:none during playback): dimmed
backdrop, the bronze stamp slamming in with a shine sweep, a 42-piece
canvas confetti burst, click-or-4s dismiss.
- prefers-reduced-motion: chime + fbNotify only, no overlay.
Tests: machine.force commit/dwell-hold/bogus-state, celebrate export +
no-manifest no-op, celebrate-called-once-per-badge, crowd-absent and
crowd-throwing degradation.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(career): refresh bar pack to v4 + restore crowd-SFX setting
Pack v4: per-character desynced animation starts, flyover intro,
per-venue reaction sounds (sfx-up/sfx-down in manifest).
Settings: re-add the crowd sound reactions toggle that was dropped
when settings.html became the passports data panel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(v3): crowd mood-change sound reactions (cheer up / boo down)
Port the venue-crowd SFX runtime that the settings toggle and the
pack's sfx-up/sfx-down files were built for: on a committed mood
transition, play the venue's own cheer (up) or boo (down) one-shot,
gated by the feedBack-venue-crowd-sfx setting (default off).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: rebuild tailwind.min.css for settings toggle classes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit follow-up on #936 (the one Major — overlay outside the click
root — was verified false: the host mounts every screen.html root inside
#plugin-career, ✕-close confirmed working live):
- Tabs: aria-selected/aria-controls + role=tabpanel/aria-labelledby.
- Book overlay: role=dialog + aria-modal + aria-label; focus moves to
the close button on open and returns to the opener on close.
- seenBadges(): guard non-object JSON so a corrupt stored value cannot
throw on every passport refresh (covered by a new corruption test).
- Fresh-session suppression test (badge seen → no re-notification).
- Stylelint declaration-empty-line-before nit in .pp-stamp.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The Passports tab beside Venues renders the badge journey physically:
- Per-instrument passport book: embossed CSS-leather cover, 3D page-turn
spread (badge page left, ticket stubs right), Escape/backdrop close.
- Wax-seal commitment ceremony (Stage 0) — pressing the seal commits the
instrument; opening a first passport runs the ceremony implicitly.
- Rubber-stamp badge slam: earned badges chime + notify immediately, the
slam (with ink bleed, page shake, deterministic sin-hash jitter) plays
when the passport is next opened, then the badge is marked seen.
- Ticket-stub repertoire: qualifying songs as collected stubs.
- Brochure rack: unopened genres as "Explore next" invitations — no
greyed slots, no completion meters (the anti-list as layout).
- Drill relay: on virtuoso:progress bus events the career screen posts
the full virtuoso.progress localStorage snapshot to the drill-state
intake (debounced; one-time bootstrap when the server has none).
- Four synthesized sfx (stamp/seal/page/chime, 13 KB total) as plugin
assets; prefers-reduced-motion disables the theatrics.
Pure logic (ppKey, ppJitter, badge diff/seen) is covered by a bare-vm
node --test suite via a window.__careerPassportTest seam.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The badge-journey layer on top of career stars (Christian's career-mode
v2 design, composed with the shipped venue system). Badges are computed
on read from song_stats × the library's effective genre — never stored:
Bronze = N genre songs at min_stars (data-driven in passports.json,
default 5 songs at 2★) plus any configured virtuoso drill nodes.
New endpoints under /api/plugins/career/:
- GET /passports passport walls per instrument: badges, ticket
stubs (qualifying songs), library genres, drills
- POST /passports/commit instrument commitment (idempotent wax seal)
- POST /passports/open open a genre passport (implies commitment)
- POST /drill-state intake for the relayed virtuoso.progress
snapshot (career's frontend listens on the bus)
Instrument attribution reuses progression.instrument_for_arrangement via
the song_stats arrangement index; the genre column goes through the
host's override-aware effective-genre SQL. Non-graded instruments (bass,
drums) render shown-not-judged — repertoire, never a false badge denial.
Persisted state (commitments, opened passports, drill snapshot) lives
under CONFIG_DIR/career/ and rides the settings export bundle via
settings.server_files; a minimal settings.html documents it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* 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>