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
+19
View File
@@ -128,3 +128,22 @@ test('venue-scene-3d activates/deactivates the crowd layer', () => {
assert.match(src, /syncCrowd\(false\)/);
assert.match(src, /v3VenueCrowd/);
});
test('machine.force commits instantly and dwell holds the forced state', () => {
const m = crowd.createCrowdMachine();
m.force('ecstatic', 100000);
assert.equal(m.current, 'ecstatic');
// The real perf state cannot reassert until the dwell window passes.
m.update('smoke', 100000 + crowd.STABLE_MS);
assert.equal(m.update('smoke', 100000 + crowd.DWELL_MS - 1), null);
assert.equal(m.current, 'ecstatic');
assert.equal(m.update('smoke', 100000 + crowd.DWELL_MS), 'bored');
// Bogus states are ignored.
m.force('confused', 200000);
assert.equal(m.current, 'bored');
});
test('celebrate() is exported and no-ops without a manifest/active venue', () => {
assert.equal(typeof crowd.celebrate, 'function');
assert.equal(crowd.celebrate(), false);
});