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
+9
View File
@@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
a no-op without a venue pack) and a full-screen overlay drops the bronze
stamp with a shine sweep and a confetti burst over whatever screen is active
(badges land right after `stats:recorded`, while the player is still up).
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
chime + notification only. The stamp still slams into the passport book on
next open, unchanged.
- **Career passports (backend)** — the badge-journey layer on top of career stars. - **Career passports (backend)** — the badge-journey layer on top of career stars.
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
passport walls: genre badges computed on read from `song_stats` × the library's passport walls: genre badges computed on read from `song_stats` × the library's
+57
View File
@@ -409,3 +409,60 @@
.pp-slam, .pp-stamp-page::after { opacity: 1; } .pp-slam, .pp-stamp-page::after { opacity: 1; }
.pp-stamp-hidden { opacity: 0.92; } .pp-stamp-hidden { opacity: 0.92; }
} }
/* Badge ceremony (body-level overlay — shows over the player) */
.pp-ceremony-overlay {
position: fixed;
inset: 0;
z-index: 220;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.55);
backdrop-filter: blur(1.5px);
animation: pp-ceremony-in 0.3s ease-out;
cursor: pointer;
}
.pp-ceremony-out { opacity: 0; transition: opacity 0.3s ease-out; }
.pp-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.pp-ceremony-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.pp-ceremony-stamp {
position: relative;
overflow: hidden;
background: rgba(239, 230, 208, 0.97);
transform: rotate(var(--pp-rot)) scale(1.25);
animation: pp-slam 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) 0.15s backwards;
margin-top: 0;
}
.pp-ceremony-stamp::before {
content: '';
position: absolute;
inset: -40%;
background: linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.55) 50%, transparent 58%);
transform: translateX(-120%);
animation: pp-shine 1.1s ease-out 0.75s forwards;
pointer-events: none;
}
@keyframes pp-shine {
to { transform: translateX(120%); }
}
@keyframes pp-ceremony-in {
from { opacity: 0; }
to { opacity: 1; }
}
.pp-ceremony-title {
margin-top: 1rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #f0e2c3;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
}
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
+105 -3
View File
@@ -34,6 +34,8 @@
let _ppRelayTimer = 0; let _ppRelayTimer = 0;
let _ppBook = null; // {inst, gkey} of the open spread let _ppBook = null; // {inst, gkey} of the open spread
let _ppReturnFocus = null; // element to refocus when the book closes let _ppReturnFocus = null; // element to refocus when the book closes
let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay
let _ppCeremonyActive = false;
let _ppBootstrapped = false; let _ppBootstrapped = false;
let _ppNotified = {}; // badges chimed this session (slam still pending) let _ppNotified = {}; // badges chimed this session (slam still pending)
@@ -308,9 +310,10 @@
lsSet(PP_SEEN_KEY, JSON.stringify(seen)); lsSet(PP_SEEN_KEY, JSON.stringify(seen));
} }
// New badge → chime + notification once per session; the stamp SLAM plays // New badge → chime + notification + the venue ceremony, once per
// when the passport is next opened (and only then is the badge marked // session; the stamp SLAM plays when the passport is next opened (and
// seen, so a pending slam survives a reload). // only then is the badge marked seen, so a pending slam survives a
// reload).
function detectNewBadges(view) { function detectNewBadges(view) {
const seen = seenBadges(); const seen = seenBadges();
for (const inst of Object.keys(view.instruments || {})) { for (const inst of Object.keys(view.instruments || {})) {
@@ -326,10 +329,109 @@
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`, message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
}); });
} }
badgeCeremony(inst, p);
} }
} }
} }
function reducedMotion() {
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; }
}
// The badge moment: the crowd erupts first (if a venue pack is live —
// badges land post-stats:recorded while the player is still on screen),
// then a body-level overlay. It CANNOT live in #pp-overlay: #plugin-career
// is display:none during playback.
function badgeCeremony(inst, p) {
// Reduced motion: the chime + fbNotify already delivered the news —
// no overlay, and no app-initiated crowd eruption either.
if (reducedMotion()) return;
const crowd = window.v3VenueCrowd;
if (crowd && typeof crowd.celebrate === 'function') {
try { crowd.celebrate(); } catch (_) { /* crowd layer optional */ }
}
if (!document.body || typeof document.createElement !== 'function') return;
// Several badges can land in one refresh (first load, drill-snapshot
// bootstrap): queue the ceremonies and play them back to back.
_ppCeremonyQueue.push({ inst, p });
if (!_ppCeremonyActive) setTimeout(drainCeremonies, 300);
}
function drainCeremonies() {
if (_ppCeremonyActive) return;
const queued = _ppCeremonyQueue.shift();
if (!queued) return;
_ppCeremonyActive = true;
showCeremonyOverlay(queued.inst, queued.p, () => {
_ppCeremonyActive = false;
setTimeout(drainCeremonies, 250);
});
}
function showCeremonyOverlay(inst, p, done) {
const el = document.createElement('div');
el.id = 'pp-ceremony';
el.className = 'pp-ceremony-overlay';
el.innerHTML = `
<canvas class="pp-confetti"></canvas>
<div class="pp-ceremony-card">
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
<span class="pp-stamp-tier">BRONZE</span>
</div>
<div class="pp-ceremony-title">Badge earned</div>
<div class="pp-ceremony-sub">${esc(p.genre)}${esc(ppLabel(inst))} passport</div>
</div>`;
let timer = 0;
let closed = false;
const dismiss = () => {
if (closed) return;
closed = true;
clearTimeout(timer);
el.classList.add('pp-ceremony-out');
setTimeout(() => { el.remove(); done(); }, 350);
};
el.addEventListener('click', dismiss);
document.body.appendChild(el);
timer = setTimeout(dismiss, 4200);
confettiBurst(el.querySelector('.pp-confetti'));
}
function confettiBurst(canvas) {
if (!canvas || typeof canvas.getContext !== 'function') return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const colors = ['#d9a253', '#b45309', '#facc15', '#06b6d4', '#e5e7eb'];
const parts = Array.from({ length: 42 }, () => ({
x: canvas.width / 2 + (Math.random() - 0.5) * 90,
y: canvas.height * 0.42,
vx: (Math.random() - 0.5) * 9,
vy: -(4 + Math.random() * 7),
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.3,
w: 5 + Math.random() * 5,
h: 3 + Math.random() * 4,
c: colors[(Math.random() * colors.length) | 0],
}));
let frames = 0;
(function tick() {
if (!canvas.isConnected || frames++ > 240) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const q of parts) {
q.x += q.vx; q.y += q.vy; q.vy += 0.18; q.rot += q.vr;
ctx.save();
ctx.translate(q.x, q.y);
ctx.rotate(q.rot);
ctx.fillStyle = q.c;
ctx.fillRect(-q.w / 2, -q.h / 2, q.w, q.h);
ctx.restore();
}
requestAnimationFrame(tick);
}());
}
// Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus // Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus
// payload) to the server intake, debounced across event bursts. // payload) to the server intake, debounced across event bursts.
function relayDrillState() { function relayDrillState() {
+27
View File
@@ -88,6 +88,33 @@ test('detectNewBadges notifies once per badge, never after it is seen', () => {
assert.equal(w2.notifications.length, 0); assert.equal(w2.notifications.length, 0);
}); });
test('a new badge triggers the crowd celebrate() exactly once', () => {
const w = load();
let calls = 0;
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
// Same session, same view: no re-celebration.
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
});
test('ceremony degrades when the crowd layer is absent or throws', () => {
const w = load();
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
w.__careerPassportTest.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// celebrate() throwing must not break detection.
const w2 = load();
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 1);
});
test('seenBadges tolerates corrupt stored values', () => { test('seenBadges tolerates corrupt stored values', () => {
for (const bad of ['null', '[1,2]', '"x"', '{{{']) { for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
const w = load({ 'feedBack-career-badges-seen': bad }); const w = load({ 'feedBack-career-badges-seen': bad });
+30
View File
@@ -59,6 +59,15 @@
candidate = null; candidate = null;
lastSwitchAt = -Infinity; 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 // Feed the latest perf state; returns the new crowd state when a
// transition commits, else null. // transition commits, else null.
update(perfState, nowMs) { update(perfState, nowMs) {
@@ -596,6 +605,26 @@
if (dev && !_manifest) setManifest(dev); 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() { function getState() {
return { return {
venueActive: _venueActive, venueActive: _venueActive,
@@ -621,6 +650,7 @@
setVenueActive, setVenueActive,
bindRuntime, bindRuntime,
getState, getState,
celebrate,
}; };
if (root) root.v3VenueCrowd = api; if (root) root.v3VenueCrowd = api;
+19
View File
@@ -128,3 +128,22 @@ test('venue-scene-3d activates/deactivates the crowd layer', () => {
assert.match(src, /syncCrowd\(false\)/); assert.match(src, /syncCrowd\(false\)/);
assert.match(src, /v3VenueCrowd/); 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);
});