feat(career): badge ceremony — the crowd erupts, the stamp drops (#941)

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>
This commit is contained in:
Byron Gamatos
2026-07-13 15:07:28 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 45caa86ab8
commit d26347981c
6 changed files with 247 additions and 3 deletions
+30
View File
@@ -59,6 +59,15 @@
candidate = null;
lastSwitchAt = -Infinity;
},
// Commit a state NOW, bypassing stability/dwell (badge ceremony).
// Stamping lastSwitchAt makes the dwell window hold the forced
// state before the real perf machine can reassert.
force(state, nowMs) {
if (!CROWD_STATES.includes(state)) return;
current = state;
candidate = null;
lastSwitchAt = nowMs;
},
// Feed the latest perf state; returns the new crowd state when a
// transition commits, else null.
update(perfState, nowMs) {
@@ -596,6 +605,26 @@
if (dev && !_manifest) setManifest(dev);
}
// Badge-ceremony hook (career passports): the crowd erupts NOW — ecstatic
// loop bypassing stability/dwell (the dwell window then holds it while
// the real perf state waits its turn) plus a cheer. Degrades to a no-op
// without a pack / outside the player, like every other entry point.
function celebrate() {
if (!_venueActive || !_manifest || !_videos[0]) return false;
machine.force('ecstatic', now());
if (_stingerUntilEnded || _introActive) {
// A stinger/intro owns the idle layer (likely the end-of-song
// accuracy cheer — the crowd is already reacting); queue the
// ecstatic loop for when it ends, same as onPerformanceState.
_pendingLoop = 'ecstatic';
} else {
showLoop('ecstatic', FADE_MS);
_lastStingerAt = -Infinity; // a badge earn always gets its cheer
playStinger('cheer');
}
return true;
}
function getState() {
return {
venueActive: _venueActive,
@@ -621,6 +650,7 @@
setVenueActive,
bindRuntime,
getState,
celebrate,
};
if (root) root.v3VenueCrowd = api;