diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e68fdf..0d6f57d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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.
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
passport walls: genre badges computed on read from `song_stats` × the library's
diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css
index b6c09b9..7859dea 100644
--- a/plugins/career/assets/career.css
+++ b/plugins/career/assets/career.css
@@ -409,3 +409,60 @@
.pp-slam, .pp-stamp-page::after { opacity: 1; }
.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); }
diff --git a/plugins/career/screen.js b/plugins/career/screen.js
index a6ffe88..0dd8423 100644
--- a/plugins/career/screen.js
+++ b/plugins/career/screen.js
@@ -34,6 +34,8 @@
let _ppRelayTimer = 0;
let _ppBook = null; // {inst, gkey} of the open spread
let _ppReturnFocus = null; // element to refocus when the book closes
+ let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay
+ let _ppCeremonyActive = false;
let _ppBootstrapped = false;
let _ppNotified = {}; // badges chimed this session (slam still pending)
@@ -308,9 +310,10 @@
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
}
- // New badge → chime + notification once per session; the stamp SLAM plays
- // when the passport is next opened (and only then is the badge marked
- // seen, so a pending slam survives a reload).
+ // New badge → chime + notification + the venue ceremony, once per
+ // session; the stamp SLAM plays when the passport is next opened (and
+ // only then is the badge marked seen, so a pending slam survives a
+ // reload).
function detectNewBadges(view) {
const seen = seenBadges();
for (const inst of Object.keys(view.instruments || {})) {
@@ -326,10 +329,109 @@
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 = `
+
+
+
+ ${esc(p.genre.toUpperCase())}
+ BRONZE
+
+
Badge earned
+
${esc(p.genre)} — ${esc(ppLabel(inst))} passport
+
`;
+ 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
// payload) to the server intake, debounced across event bursts.
function relayDrillState() {
diff --git a/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js
index dc39963..94c3fd8 100644
--- a/plugins/career/tests/passports.test.js
+++ b/plugins/career/tests/passports.test.js
@@ -88,6 +88,33 @@ test('detectNewBadges notifies once per badge, never after it is seen', () => {
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', () => {
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
const w = load({ 'feedBack-career-badges-seen': bad });
diff --git a/static/v3/venue-crowd.js b/static/v3/venue-crowd.js
index aae8453..dea8789 100644
--- a/static/v3/venue-crowd.js
+++ b/static/v3/venue-crowd.js
@@ -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;
diff --git a/tests/js/venue_crowd.test.js b/tests/js/venue_crowd.test.js
index 301ae37..50a73b4 100644
--- a/tests/js/venue_crowd.test.js
+++ b/tests/js/venue_crowd.test.js
@@ -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);
+});