// feedBack-plugin-minigames — SDK + hub controller. // // This file does two things: // 1) Publishes window.feedBackMinigames — the SDK that individual // minigame plugins call (register, start/end, scoring, ui, persistence). // 2) Mounts a hub UI in screen.html that lists every registered minigame // and the shared profile/leaderboards. // // Plugin load order is alphabetical, so minigame plugins (e.g. flappy_bend) // load BEFORE this script. They should register via a tiny shim that queues // to `window.__feedBackMinigamesPending` if the SDK isn't up yet — we drain // the queue on init and also fire `feedBack-minigames-ready` once ready. (function () { 'use strict'; if (window.feedBackMinigames && window.feedBackMinigames.__alive) { return; // hot-reload guard } const PLUGIN_ID = 'minigames'; const API_BASE = `/api/plugins/${PLUGIN_ID}`; // ── YIN pitch tracker ───────────────────────────────────────────────── // Compact YIN (de Cheveigné & Kawahara, 2002). Sufficient for monophonic // electric-guitar pitch tracking down to ~80 Hz. Returns: // { freqHz, confidence } — confidence in [0, 1] // or null if no confident pitch was found in the buffer. // scratchD / scratchCmnd are optional preallocated Float32Arrays of length // ≥ halfN. Pass them from the createContinuous closure to avoid per-callback // allocations. If omitted, yinDetect allocates its own (slower but safe). function yinDetect(buf, sampleRate, opts, scratchD, scratchCmnd) { const threshold = (opts && opts.threshold) || 0.15; const minHz = (opts && opts.minHz) || 70; const maxHz = (opts && opts.maxHz) || 1500; const N = buf.length; const halfN = (N / 2) | 0; const tauMin = Math.max(2, Math.floor(sampleRate / maxHz)); const tauMax = Math.min(halfN - 1, Math.floor(sampleRate / minHz)); // Difference function d(τ). Only compute up to tauMax — values beyond // tauMax can never be selected, so computing them is wasteful O(N^2) work. // Reuse scratch buffer if provided. const d = (scratchD && scratchD.length >= halfN) ? scratchD : new Float32Array(halfN); for (let tau = 1; tau <= tauMax; tau++) { let sum = 0; for (let i = 0; i < halfN; i++) { const diff = buf[i] - buf[i + tau]; sum += diff * diff; } d[tau] = sum; } // Cumulative mean normalized difference (CMND). Only compute up to tauMax // since the threshold search only reads cmnd[tauMin..tauMax]. Reuse scratch. const cmnd = (scratchCmnd && scratchCmnd.length >= halfN) ? scratchCmnd : new Float32Array(halfN); cmnd[0] = 1; let running = 0; for (let tau = 1; tau <= tauMax; tau++) { running += d[tau]; cmnd[tau] = (d[tau] * tau) / (running || 1); } // Absolute threshold — first τ where CMND drops below threshold AND // is a local minimum. let tauEstimate = -1; for (let tau = tauMin; tau <= tauMax; tau++) { if (cmnd[tau] < threshold) { while (tau + 1 <= tauMax && cmnd[tau + 1] < cmnd[tau]) tau++; tauEstimate = tau; break; } } if (tauEstimate < 0) return null; // Parabolic interpolation around the minimum for sub-sample τ. let betterTau = tauEstimate; if (tauEstimate > 0 && tauEstimate < tauMax) { const s0 = cmnd[tauEstimate - 1]; const s1 = cmnd[tauEstimate]; const s2 = cmnd[tauEstimate + 1]; const denom = 2 * (s0 - 2 * s1 + s2); if (denom !== 0) { betterTau = tauEstimate + (s0 - s2) / denom; } } const freqHz = sampleRate / betterTau; if (freqHz < minHz || freqHz > maxHz) return null; // Confidence = 1 - CMND at the chosen τ, clamped. const conf = Math.max(0, Math.min(1, 1 - cmnd[tauEstimate])); return { freqHz, confidence: conf }; } // ── scoring.createContinuous ────────────────────────────────────────── // Self-contained (does not use createNoteDetector). Opens its own // getUserMedia stream and runs YIN on a 2048-sample window. Emits // 'pitch' events with { freqHz, midiFloat, cents, confidence, tMs, // expectedBaseFreqHz }. `cents` is relative to expectedBaseFreqHz if // provided, otherwise relative to freqHz itself (so always 0). function createContinuous(opts) { // Coerce expectedBaseFreqHz to a finite positive number, or null. // A truthy-but-invalid value (NaN, Infinity, negative, string) would make // Math.log2(freq / expectedBaseFreqHz) return NaN and corrupt every emitted // pitch event. const _rawBase = opts && opts.expectedBaseFreqHz; const expectedBaseFreqHz = (typeof _rawBase === 'number' && isFinite(_rawBase) && _rawBase > 0) ? _rawBase : null; const rawSmoothing = opts && opts.smoothingMs; const smoothingMs = (typeof rawSmoothing === 'number' && isFinite(rawSmoothing) && rawSmoothing > 0) ? rawSmoothing : 30; const handlers = { pitch: [], end: [] }; let audioCtx = null; let mediaStream = null; let source = null; let processor = null; let stopped = false; let lastFreq = 0; let lastConf = 0; let startError = null; const ringSize = 2048; const ring = new Float32Array(ringSize); // Preallocated buffers for YIN — reused every audio callback to avoid // per-callback GC pressure from Float32Array allocations inside yinDetect. const yinWindow = new Float32Array(ringSize); const yinHalfN = ringSize >> 1; // 1024 — matches halfN in yinDetect const yinD = new Float32Array(yinHalfN); const yinCmnd = new Float32Array(yinHalfN); let ringWrite = 0; // Desktop-engine bridge path. On feedBack-desktop the native JUCE engine // owns the input device (often an exclusive ASIO device the browser's // getUserMedia can't see), so a renderer getUserMedia stream lands on the // wrong/silent Windows-default device. When the bridge is present we pull // post-noise-gate frames from the engine instead — same device the player // and note_detect read from. getUserMedia stays as the web fallback. let bridgePoll = null; // setTimeout handle for the engine poll loop let usingBridge = false; let bridgeGotFrame = false; // first non-empty frame seen (vs downlevel addon) let bridgeSampleRate = 48000; // queried once from the engine const handle = { on(event, cb) { (handlers[event] || (handlers[event] = [])).push(cb); return handle; }, stop, isRunning: () => !stopped && (!!audioCtx || usingBridge), }; function emit(event, payload) { (handlers[event] || []).forEach(cb => { try { cb(payload); } catch (e) { console.error(e); } }); } // Shared analysis used by both the mic and engine-bridge sources. `win` is a // ringSize-length Float32Array (oldest→newest); `dtMs` is the elapsed time // since the previous frame, driving the log-freq EMA smoothing. RMS-gate → // YIN → smooth → emit('pitch'). Keeping this single function guarantees the // two sources emit byte-identical frame shapes to consumers. function analyzeWindow(win, sampleRate, dtMs) { let rms = 0; for (let i = 0; i < ringSize; i++) rms += win[i] * win[i]; rms = Math.sqrt(rms / ringSize); // -60 dBFS RMS gate (≈ 0.001 full-scale): on silence YIN's difference // function collapses and reports a spurious high-freq lock, so skip it. const res = (rms > 0.001) ? yinDetect(win, sampleRate, { minHz: 70, maxHz: 1500 }, yinD, yinCmnd) : null; const nowMs = performance.now(); if (res && res.confidence > 0.3) { const alpha = Math.min(1, (dtMs > 0 ? dtMs : 1) / smoothingMs); if (lastFreq > 0) { lastFreq = Math.exp(Math.log(lastFreq) * (1 - alpha) + Math.log(res.freqHz) * alpha); } else { lastFreq = res.freqHz; } lastConf = res.confidence; } else { // Fade confidence rather than zeroing — lets the consumer gate "no // input" on confidence falling below its own threshold. lastConf *= 0.85; } const freq = lastFreq; const midiFloat = freq > 0 ? 69 + 12 * Math.log2(freq / 440) : 0; const cents = (expectedBaseFreqHz && freq > 0) ? 1200 * Math.log2(freq / expectedBaseFreqHz) : 0; emit('pitch', { freqHz: freq, midiFloat, cents, confidence: lastConf, tMs: nowMs, expectedBaseFreqHz, }); } // getUserMedia source — used on the web build, and on desktop only when the // engine bridge is absent. NOTE: on desktop this lands on the Windows-default // device, which is usually NOT the guitar (see the bridge path above); the // dispatcher in start() prefers the bridge for that reason. async function startMic() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, video: false, }); if (stopped) { stream.getTracks().forEach(t => t.stop()); return; } mediaStream = stream; if (stopped) { stream.getTracks().forEach(t => t.stop()); return; } const AC = window.AudioContext || window.webkitAudioContext; audioCtx = new AC(); // Some browsers (Chrome, Safari) start AudioContexts in "suspended" // state when constructed outside a direct user-gesture call stack. // resume() must be called explicitly; ignore errors (e.g. the context // is already running — not an error state we need to surface). await audioCtx.resume().catch(() => {}); if (stopped) { audioCtx.close().catch(() => {}); audioCtx = null; stream.getTracks().forEach(t => t.stop()); return; } source = audioCtx.createMediaStreamSource(stream); // ScriptProcessor is deprecated but universally supported and lets // us pull raw frames synchronously — fine for v1. Migration to // AudioWorklet is straightforward later. const bufSize = 1024; processor = audioCtx.createScriptProcessor(bufSize, 1, 1); processor.onaudioprocess = (e) => { if (stopped) return; // race: stop() called while onaudioprocess was queued const inBuf = e.inputBuffer.getChannelData(0); // Copy into ring. for (let i = 0; i < inBuf.length; i++) { ring[ringWrite] = inBuf[i]; ringWrite = (ringWrite + 1) % ringSize; } // Build a contiguous window for YIN: from oldest sample to newest. // Write into the preallocated yinWindow to avoid per-callback allocs. for (let i = 0; i < ringSize; i++) { yinWindow[i] = ring[(ringWrite + i) % ringSize]; } // Mic frame interval ≈ bufSize/sampleRate; feed it as the EMA dt. analyzeWindow(yinWindow, audioCtx.sampleRate, bufSize / audioCtx.sampleRate * 1000); }; source.connect(processor); // Must connect processor → destination for onaudioprocess to fire. // Route through a muted gain node so we don't actually output mic. const mute = audioCtx.createGain(); mute.gain.value = 0; processor.connect(mute); mute.connect(audioCtx.destination); } catch (err) { console.error('[minigames] continuous scoring failed to start:', err); // Do NOT emit 'end' here — stop() will emit one. Tagging the // stop() reason via a captured variable keeps subscribers from // seeing two events for the same shutdown. startError = err; stop(); } } function stop() { if (stopped) return; stopped = true; if (bridgePoll) { try { clearTimeout(bridgePoll); } catch (e) {} bridgePoll = null; } usingBridge = false; try { if (processor) processor.disconnect(); } catch (e) {} try { if (source) source.disconnect(); } catch (e) {} try { if (mediaStream) mediaStream.getTracks().forEach(t => t.stop()); } catch (e) {} try { if (audioCtx) audioCtx.close().catch(() => {}); } catch (e) {} audioCtx = mediaStream = source = processor = null; emit('end', startError ? { reason: 'error', error: startError } : { reason: 'stopped' }); } // Desktop source: pull post-noise-gate frames from the native JUCE engine // (the device the user actually plays through) and run the same YIN. Polled // via a self-rescheduling setTimeout — NOT setInterval — so a slow IPC // round-trip can't stack callbacks. Falls back to the mic path if the addon // is downlevel (getRawAudioFrame resolves an empty array). async function startBridge(audio) { try { // The minigame needs live input; make sure the engine is capturing. if (typeof audio.isAudioRunning === 'function') { const running = await audio.isAudioRunning(); if (!running && typeof audio.startAudio === 'function') await audio.startAudio(); } } catch (e) { /* best-effort; poll anyway */ } try { const sr = await audio.getSampleRate(); if (typeof sr === 'number' && sr > 0) bridgeSampleRate = sr; } catch (e) { /* keep 48000 default */ } if (stopped) return; usingBridge = true; let lastT = performance.now(); const bridgeStartT = performance.now(); const POLL_MS = 25; // ~40 Hz — matches the mic path's cadence const BRIDGE_WARMUP_MS = 1500; // grace for startAudio()/device spin-up // before concluding there's no frame tap const tick = async () => { if (stopped) return; let frame = null; try { frame = await audio.getRawAudioFrame(ringSize); } catch (e) { /* transient IPC hiccup */ } if (stopped) return; if (!frame || frame.length === 0) { // Empty/failed poll. startAudio() above may still be spinning the // input device up, so the first frames can legitimately be empty — // only conclude the addon has no engine frame tap (downlevel) after a // warm-up grace during which no frame ever arrived. A thrown poll is // transient and simply retries until that grace elapses. if (!bridgeGotFrame && (performance.now() - bridgeStartT) > BRIDGE_WARMUP_MS) { usingBridge = false; console.warn('[minigames] engine frame tap unavailable — using getUserMedia'); startMic(); return; } } else { bridgeGotFrame = true; // Copy the newest ringSize samples into yinWindow (left-zero-pad if the // engine handed back fewer, e.g. a cold-start partial ring). const n = frame.length; if (n >= ringSize) { for (let i = 0; i < ringSize; i++) yinWindow[i] = frame[n - ringSize + i]; } else { const pad = ringSize - n; for (let i = 0; i < pad; i++) yinWindow[i] = 0; for (let i = 0; i < n; i++) yinWindow[pad + i] = frame[i]; } const now = performance.now(); const dt = now - lastT; lastT = now; analyzeWindow(yinWindow, bridgeSampleRate, dt); } // Guard the reschedule: a consumer's 'pitch' handler can call stop() // synchronously inside analyzeWindow, so re-check before re-arming. if (!stopped) bridgePoll = setTimeout(tick, POLL_MS); }; tick(); } // Prefer the desktop engine bridge (correct, user-configured input device); // fall back to getUserMedia on the web build or a downlevel addon. function start() { const audio = window.feedBackDesktop && window.feedBackDesktop.audio; if (audio && typeof audio.getRawAudioFrame === 'function') { startBridge(audio); } else { startMic(); } } start(); return handle; } // ── scoring.createDiscrete / createChord ────────────────────────────── // Both wrap window.createNoteDetector from feedBack-plugin-notedetect. // For v1 they are thin event re-emitters — minigames using them must // run alongside a chart (createNoteDetector needs a highway). Chart-free // discrete scoring is out of scope until the scoring-core extraction // PR lands. function _wrapNoteDetector(opts) { const handlers = { hit: [], miss: [], end: [] }; const fn = window.createNoteDetector; if (typeof fn !== 'function') { console.warn('[minigames] window.createNoteDetector unavailable — install feedBack-plugin-notedetect for discrete/chord scoring.'); let _unavailStopped = false; return { on(event, cb) { (handlers[event] || (handlers[event] = [])).push(cb); return this; }, stop() { if (_unavailStopped) return; _unavailStopped = true; handlers.end.forEach(cb => { try { cb({ reason: 'unavailable' }); } catch (e) { console.error(e); } }); }, isRunning: () => false, }; } const inst = fn(opts || {}); const root = (typeof inst.getRoot === 'function') ? inst.getRoot() : window; const onHit = (e) => handlers.hit.forEach(cb => { try { cb(e.detail); } catch (err) { console.error(err); } }); const onMiss = (e) => handlers.miss.forEach(cb => { try { cb(e.detail); } catch (err) { console.error(err); } }); root.addEventListener('notedetect:hit', onHit); root.addEventListener('notedetect:miss', onMiss); let stopped = false; if (typeof inst.enable === 'function') { try { inst.enable(); } catch (e) { console.error(e); } } return { on(event, cb) { (handlers[event] || (handlers[event] = [])).push(cb); return this; }, stop() { if (stopped) return; stopped = true; root.removeEventListener('notedetect:hit', onHit); root.removeEventListener('notedetect:miss', onMiss); try { inst.destroy && inst.destroy(); } catch (e) {} handlers.end.forEach(cb => { try { cb({ reason: 'stopped' }); } catch (e) { console.error(e); } }); }, isRunning: () => !stopped, noteDetector: inst, }; } // ── UI helpers ──────────────────────────────────────────────────────── function mountHUD(content) { const hud = document.getElementById('mg-stage-hud'); if (!hud) return; if (content instanceof Node) { hud.innerHTML = ''; hud.appendChild(content); } else if (typeof content === 'string') { hud.innerHTML = content; } } function runSummary(result) { const root = document.getElementById('mg-summary'); if (!root) return; const spec = registered.get(result.gameId); // Prefer the caller-supplied resolvedTitle (manifest-resolved) over the // JS spec title so minigames that register a minimal spec still show the // correct display name in the post-game summary. document.getElementById('mg-summary-game').textContent = result.resolvedTitle || (spec ? spec.title || spec.id : '') || (result.gameId || ''); document.getElementById('mg-summary-score').textContent = String(Math.floor(Math.max(0, Number(result.score) || 0))); document.getElementById('mg-summary-xp').textContent = '+' + String(Math.floor(Math.max(0, Number(result.xpGained) || 0))) + ' dB'; document.getElementById('mg-summary-best').textContent = String(Math.floor(Math.max(0, Number(result.best) || 0))); const extra = document.getElementById('mg-summary-extra'); if (result.extra) { // Trust boundary: result.extra (summaryHtml) is produced by the game // plugin's own end() call — it is first-party, same-origin code, not // user-supplied content. innerHTML is intentional here so games can // render formatted post-run stats (tables, bold numbers, etc.). If this // field ever flows from an untrusted source we must sanitize first. extra.innerHTML = result.extra; } else { extra.textContent = ''; } root.classList.remove('hidden'); const closeBtn = document.getElementById('mg-summary-close'); const againBtn = document.getElementById('mg-summary-again'); const closeSummary = () => { root.classList.add('hidden'); document.removeEventListener('keydown', onKey); // Clear the module-level reference so teardownActiveSession() knows // there is no stale keydown listener left to clean up. _summaryCleanup = null; }; const onKey = (e) => { if (e.key === 'Escape') { closeSummary(); renderHub(); } }; document.addEventListener('keydown', onKey); // Register the cleanup so teardownActiveSession() can remove the listener // if the user navigates away while the summary is visible. _summaryCleanup = closeSummary; // Move focus into the modal for keyboard/screen-reader users. if (closeBtn) setTimeout(() => closeBtn.focus(), 0); closeBtn.onclick = () => { closeSummary(); // Re-render hub so updated XP / best appears. renderHub(); }; againBtn.onclick = () => { closeSummary(); if (spec) start(spec.id, result.lastOpts || {}); else renderHub(); }; } // Modifier picker — modal with one row per modifier from the spec. // Returns a Promise that resolves to {modifiers} or rejects on cancel. function modifierPicker(spec) { return new Promise((resolve, reject) => { const root = document.getElementById('mg-picker'); if (!root) { reject(new Error('picker DOM missing')); return; } document.getElementById('mg-picker-title').textContent = spec.title || spec.id; document.getElementById('mg-picker-tagline').textContent = spec.tagline || ''; const body = document.getElementById('mg-picker-body'); body.innerHTML = ''; const selected = Object.create(null); (spec.modifiers || []).forEach(mod => { selected[mod.id] = mod.default; const row = document.createElement('div'); row.innerHTML = `
${escapeHtml(mod.label || mod.id)}
`; const btnRow = row.querySelector('[data-mod-id]'); (mod.values || []).forEach(v => { const btn = document.createElement('button'); btn.className = 'px-3 py-1.5 rounded text-sm bg-fb-bg/60 text-fb-textDim hover:bg-fb-border'; btn.textContent = String(v); btn.dataset.value = String(v); if (v === mod.default) btn.classList.add('!bg-accent', '!text-white'); btn.onclick = () => { selected[mod.id] = v; btnRow.querySelectorAll('button').forEach(b => b.classList.remove('!bg-accent', '!text-white')); btn.classList.add('!bg-accent', '!text-white'); }; btnRow.appendChild(btn); }); body.appendChild(row); }); // Track selector for chart-free games that ship tracks. if (Array.isArray(spec.availableTracks) && spec.availableTracks.length) { selected.__track = spec.availableTracks[0].id; const row = document.createElement('div'); row.innerHTML = `
Track
`; const btnRow = row.querySelector('[data-mod-id]'); spec.availableTracks.forEach((t, i) => { const btn = document.createElement('button'); btn.className = 'px-3 py-1.5 rounded text-sm bg-fb-bg/60 text-fb-textDim hover:bg-fb-border'; btn.textContent = t.title || t.id; if (i === 0) btn.classList.add('!bg-accent', '!text-white'); btn.onclick = () => { selected.__track = t.id; btnRow.querySelectorAll('button').forEach(b => b.classList.remove('!bg-accent', '!text-white')); btn.classList.add('!bg-accent', '!text-white'); }; btnRow.appendChild(btn); }); body.appendChild(row); } root.classList.remove('hidden'); const cancelBtn = document.getElementById('mg-picker-cancel'); const startBtn = document.getElementById('mg-picker-start'); const onKey = (e) => { if (e.key === 'Escape') { cleanup(); reject(new Error('cancelled')); } }; const cleanup = () => { root.classList.add('hidden'); document.removeEventListener('keydown', onKey); }; document.addEventListener('keydown', onKey); // Move focus into the modal so keyboard/screen-reader users can proceed. if (startBtn) setTimeout(() => startBtn.focus(), 0); cancelBtn.onclick = () => { cleanup(); reject(new Error('cancelled')); }; // Convert the null-proto `selected` map into a plain object. Using // Object.fromEntries avoids the Object.assign({}, ...) footgun where // a modifier id of '__proto__' would invoke the setter on the target // and mutate its prototype — fromEntries always creates own data props. startBtn.onclick = () => { cleanup(); resolve({ modifiers: Object.fromEntries(Object.entries(selected)) }); }; }); } // ── Persistence client ──────────────────────────────────────────────── async function submitRun(payload) { const r = await fetch(`${API_BASE}/runs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!r.ok) throw new Error(`submitRun failed: ${r.status}`); return r.json(); } async function getLeaderboard(gameId, opts) { const params = new URLSearchParams(); if (gameId) params.set('game_id', gameId); if (opts && opts.scope) params.set('scope', opts.scope); if (opts && opts.limit) params.set('limit', String(opts.limit)); const r = await fetch(`${API_BASE}/runs?${params}`); if (!r.ok) throw new Error(`getLeaderboard failed: ${r.status}`); return r.json(); } async function getProfile() { const r = await fetch(`${API_BASE}/profile`); if (!r.ok) throw new Error(`getProfile failed: ${r.status}`); return r.json(); } async function resetProfile() { const r = await fetch(`${API_BASE}/profile/reset`, { method: 'POST' }); if (!r.ok) throw new Error(`resetProfile failed: ${r.status}`); return r.json(); } async function getServerRegistry() { const r = await fetch(`${API_BASE}/registry`); if (!r.ok) throw new Error(`registry failed: ${r.status}`); return r.json(); } // ── Scheduler (AudioContext-relative would be ideal; v1 uses perf.now) ─ const _timers = new Set(); const scheduler = { every(ms, cb) { const id = setInterval(cb, ms); _timers.add(id); return id; }, in(ms, cb) { const id = setTimeout(() => { _timers.delete(id); cb(); }, ms); _timers.add(id); return id; }, cancel(id) { clearTimeout(id); clearInterval(id); _timers.delete(id); }, now: () => performance.now(), }; // ── Registry ────────────────────────────────────────────────────────── const registered = new Map(); function register(spec) { if (!spec || typeof spec.id !== 'string' || !spec.id) { console.warn('[minigames] register() called with missing or non-string spec.id'); return; } if (typeof spec.start !== 'function') { console.warn('[minigames] register(%s): spec.start must be a function', spec.id); return; } registered.set(spec.id, spec); // If the hub is mounted, re-render. Otherwise it'll pick this up on next mount. if (document.getElementById('mg-grid')) renderHub(); } // ── Lifecycle ───────────────────────────────────────────────────────── let active = null; // { spec, modifiers, resolvedTitle, startedAt, lastOpts } let starting = false; // true while start() is in-flight (before active is set) // Monotonically incrementing generation counter. Incremented by // teardownActiveSession() so any in-flight _startInner() can detect that it // has been superseded and bail after each await without mounting the stage // or calling spec.start() for a screen the user has already left. let _startGeneration = 0; // When runSummary() opens the post-run modal it registers a document-level // keydown listener. This variable holds the cleanup function so that // teardownActiveSession() can remove the listener even when active is null // (i.e. the run is over and the user navigates away while the summary is open). let _summaryCleanup = null; async function start(gameId, opts) { // Guard against concurrent start() calls: `active` is set only after the // async registry fetch + modifier picker, so a naive `if (active)` check // would allow two calls to enter the async section simultaneously (e.g. // a double-tap on a tile). `starting` flips synchronously on entry. if (active || starting) { console.warn('[minigames] a game is already active or starting; ignoring start(%s)', gameId); return; } starting = true; const myGeneration = ++_startGeneration; try { return await _startInner(gameId, opts, myGeneration); } finally { starting = false; } } async function _startInner(gameId, opts, myGeneration) { const spec = registered.get(gameId); if (!spec) { console.warn('[minigames] no such minigame:', gameId); return; } // Always fetch the server registry so the resolved title/tagline can be // used in both the stage header and the run summary — even when modifiers // are supplied directly (e.g. Play Again with cached opts). const reg = await getServerRegistry().catch(() => ({ minigames: [] })); // Bail if teardownActiveSession() fired while we were awaiting. if (myGeneration !== _startGeneration) { return; } const manifestSpec = (reg.minigames || []).find(m => m.plugin_id === gameId) || {}; const resolvedTitle = manifestSpec.title || spec.title || gameId; let modifiers = opts && opts.modifiers; if (!modifiers) { // Build a picker config from the server-side manifest entry (which // includes modifiers + unlocks). Manifest fields take precedence; // JS spec fields serve as fallback when the manifest omits them. const tracks = manifestSpec.availableTracks || spec.availableTracks || null; try { const pick = await modifierPicker({ id: gameId, title: resolvedTitle, tagline: manifestSpec.tagline || spec.tagline || '', modifiers: manifestSpec.modifiers || spec.modifiers || [], availableTracks: tracks, }); modifiers = pick.modifiers; } catch (e) { return; // cancelled } // Bail if teardownActiveSession() fired while we were in the picker. if (myGeneration !== _startGeneration) { return; } } // Show the stage chrome. const stage = document.getElementById('mg-stage'); const body = document.getElementById('mg-stage-body'); const title = document.getElementById('mg-stage-title'); const instr = document.getElementById('mg-stage-instrument'); const quit = document.getElementById('mg-stage-quit'); if (!stage || !body || !title || !instr || !quit) { console.warn('[minigames] start(%s): stage DOM not mounted — cannot launch', gameId); return; } stage.classList.remove('hidden'); body.innerHTML = ''; title.textContent = resolvedTitle; instr.textContent = ''; mountHUD(''); const container = document.createElement('div'); container.className = 'mg-game-root'; body.appendChild(container); active = { spec, modifiers, resolvedTitle, startedAt: performance.now(), lastOpts: { modifiers } }; // Compute elapsed time at quit so the recorded duration reflects actual play // time rather than always logging 0ms (which would bypass end()'s fallback). quit.onclick = () => end({ score: 0, durationMs: active ? Math.round(performance.now() - active.startedAt) : 0, modifiers, meta: { reason: 'quit' }, }); try { await spec.start({ container, modifiers, // Convenience pass-through for the SDK so games don't have to // touch window.feedBackMinigames inside their start handler. sdk: window.feedBackMinigames, }); } catch (e) { console.error('[minigames] minigame start() threw:', e); // Compute elapsed time so failed starts record meaningful duration rather // than always logging 0ms and bypassing end()'s fallback calculation. const errDuration = active ? Math.round(performance.now() - active.startedAt) : 0; await end({ score: 0, durationMs: errDuration, modifiers, meta: { reason: 'error', error: String(e) } }); } } async function end(result) { if (!active) return; const { spec, modifiers, resolvedTitle, startedAt, lastOpts } = active; active = null; // Tolerate callers that pass no result (or null/undefined) — normalise to {}. const res = (result && typeof result === 'object') ? result : {}; // Let the game tear itself down first. // Note: `await spec.stop && spec.stop()` has a precedence bug — it awaits // the function reference, not the call. Explicit check + await is correct. try { if (typeof spec.stop === 'function') await spec.stop(); } catch (e) { console.error(e); } // Also stop any timers started via scheduler. _timers.forEach(t => { clearTimeout(t); clearInterval(t); }); _timers.clear(); const _rawDur = Number(res.durationMs); const durationMs = (Number.isFinite(_rawDur) && _rawDur >= 0) ? Math.floor(_rawDur) : Math.round(performance.now() - startedAt); const score = Math.max(0, Math.floor(Number(res.score) || 0)); // Coerce meta to a plain object — the backend expects a JSON object/dict; // a non-object (string, array, null) would cause a Pydantic 422. const rawMeta = res.meta; const meta = (rawMeta && typeof rawMeta === 'object' && !Array.isArray(rawMeta)) ? rawMeta : {}; document.getElementById('mg-stage').classList.add('hidden'); document.getElementById('mg-stage-body').innerHTML = ''; let xpGained = 0; // Default best to this run's score so the summary shows a sensible value // even if the server is unreachable or the leaderboard fetch fails. let best = score; try { const submitted = await submitRun({ game_id: spec.id, score, duration_ms: durationMs, modifiers, meta, }); xpGained = submitted.xp_gained; // Best score: fetch leaderboard top entry. const lb = await getLeaderboard(spec.id, { limit: 1 }); best = (lb.runs && lb.runs[0] && lb.runs[0].score) || score; } catch (e) { console.error('[minigames] run submission failed:', e); } runSummary({ gameId: spec.id, resolvedTitle, score, xpGained, best, extra: res.summaryHtml || '', lastOpts, }); // Refresh profile strip. renderProfileStrip().catch(() => {}); } // ── Hub controller ──────────────────────────────────────────────────── function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // Serialise hub renders. Concurrent calls were causing duplicate tiles // because each call ran `grid.innerHTML = ''` then awaited a fetch then // appended its tile, and the async gap let other in-flight renders also // clear-then-append, multiplying the tile count by the number of races. // We let at most one render run at a time and coalesce trailing calls // into a single follow-up. let _hubRenderInFlight = false; let _hubRenderQueued = false; async function renderHub() { if (_hubRenderInFlight) { _hubRenderQueued = true; return; } _hubRenderInFlight = true; try { await _renderHubOnce(); } catch (e) { // _renderHubOnce() failing is non-fatal — the hub just won't update. // Swallow here so all fire-and-forget callsites don't need .catch(). console.error('[minigames] renderHub failed:', e); } finally { _hubRenderInFlight = false; if (_hubRenderQueued) { _hubRenderQueued = false; // Fire-and-forget; errors are swallowed above. renderHub().catch(() => {}); } } } async function _renderHubOnce() { const grid = document.getElementById('mg-grid'); const empty = document.getElementById('mg-empty'); if (!grid || !empty) return; // Fetch profile and server registry together. Registry fields (title, // tagline, thumbnail) take precedence over the JS-registered spec so // minigames that only register the minimal lifecycle spec still render // correctly — the canonical display metadata lives in plugin.json. const [profileResp, registryResp] = await Promise.all([ getProfile().catch(() => ({})), getServerRegistry().catch(() => ({ minigames: [] })), ]); await renderProfileStrip(profileResp).catch(() => {}); const perGame = (profileResp.totals && profileResp.totals.per_game) || {}; // Build a lookup from plugin_id → manifest entry for merging below. // Use Object.create(null) to prevent prototype-pollution if a plugin id // were ever '__proto__' or another special key. const manifestByGame = Object.create(null); (registryResp.minigames || []).forEach(m => { manifestByGame[m.plugin_id] = m; }); const list = Array.from(registered.values()); grid.innerHTML = ''; if (!list.length) { empty.classList.remove('hidden'); return; } empty.classList.add('hidden'); list.forEach(spec => { // Merge manifest fields so display metadata from plugin.json takes // precedence over anything in the JS registration (manifest is the // authoritative source; JS spec is a runtime fallback). const mSpec = manifestByGame[spec.id] || {}; const title = mSpec.title || spec.title || spec.id; const tagline = mSpec.tagline || spec.tagline || ''; const thumbnail = mSpec.thumbnail || spec.thumbnail || null; const tile = document.createElement('div'); // Reuse the library's .song-card class so minigame tiles look // identical to song cards (square art on top, text block below, // matching hover/focus behaviour). tile.className = 'song-card'; tile.tabIndex = 0; tile.setAttribute('role', 'button'); tile.setAttribute('aria-label', title); const stats = perGame[spec.id] || { runs: 0, best_score: 0 }; // Thumbnails are served via the minigame plugin's own asset route // (the FeedBack plugin loader only serves manifest-declared files, // so each minigame that ships extra assets must expose /assets/). // Thumbnails are served by the minigame plugin's own /assets/ route; // not every plugin ships one, so fall back to the placeholder on 404. const art = thumbnail ? `
${escapeHtml(title)}
` : `
🎮
`; tile.innerHTML = ` ${art}
${escapeHtml(title)}
${escapeHtml(tagline)}
Runs ${Number(stats.runs) || 0} Best ${Number(stats.best_score) || 0}
`; tile.onclick = () => start(spec.id); tile.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); start(spec.id); } }); grid.appendChild(tile); }); } // Accepts an already-fetched profile so callers can avoid a redundant // network round-trip when they already have the profile in hand. // Progression (spec 010): the strip shows lifetime Decibels earned (the // profile `xp` field mirrors the unified core store) — the level meter is // gone from the UI, but the backend xp/level fields stay for unlocks. async function renderProfileStrip(profile) { const xp = document.getElementById('mg-profile-xp'); if (!xp) return; const p = profile || (await getProfile()); xp.textContent = `${Number(p.xp || 0).toLocaleString()} dB`; } // ── SDK object ──────────────────────────────────────────────────────── const sdk = { __alive: true, register, start, end, scoring: { createContinuous, createDiscrete: (opts) => _wrapNoteDetector(opts), createChord: (opts) => _wrapNoteDetector(opts), }, ui: { mountHUD, runSummary, modifierPicker }, submitRun, getLeaderboard, getProfile, resetProfile, scheduler, // Read-only access to the registry for the hub-from-anywhere case. listRegistered: () => Array.from(registered.values()), }; window.feedBackMinigames = sdk; // Drain queue of plugins that loaded before us. (window.__feedBackMinigamesPending || []).forEach(register); window.__feedBackMinigamesPending = null; // ── Back-compat for pre-rename minigame plugins ─────────────────────── // Minigame plugins published before the slopsmith→feedBack rename (#537) // register against `window.slopsmithMinigames` and, when the SDK isn't up // yet, queue to `window.__slopsmithMinigamesPending`. The rename moved the // SDK to `window.feedBackMinigames` and only drains the feedBack queue, so // those plugins' specs — including community ones we don't control — get // stranded in the legacy queue and never register. Their FeedBarcade tiles // then render as dead "Loading…" placeholders that do nothing on click. // Alias the SDK under the old name and drain the legacy pending queue too. // register() is keyed on spec.id, so a plugin that queued under both names // still registers exactly once. window.slopsmithMinigames = sdk; (window.__slopsmithMinigamesPending || []).forEach(register); window.__slopsmithMinigamesPending = null; window.dispatchEvent(new CustomEvent('feedBack-minigames-ready')); // Legacy event name for any pre-rename listener still bound to it. window.dispatchEvent(new CustomEvent('slopsmith-minigames-ready')); // ── Wire hub render to screen lifecycle ─────────────────────────────── // FeedBack mounts plugin screens with id "plugin-" and // routes there via showScreen() / window.feedBack.navigate(). const SCREEN_ID = `plugin-${PLUGIN_ID}`; // Non-scoring teardown: called when navigation happens mid-run so that // microphone streams, timers, and stage DOM are cleaned up without submitting // a zero-score run. async function teardownActiveSession(reason) { if (!active && !starting && !_summaryCleanup) return; // Increment the generation so any in-flight _startInner() bails at its // next await point without mounting the stage or calling spec.start(). _startGeneration++; const spec = active && active.spec; active = null; starting = false; // Dismiss the summary modal and remove its document-level keydown listener // if the user navigated away while the post-run summary was open. if (_summaryCleanup) { try { _summaryCleanup(); } catch (e) {} } try { if (spec && typeof spec.stop === 'function') await spec.stop(); } catch (e) { console.error('[minigames] teardown spec.stop failed:', e); } _timers.forEach(t => { clearTimeout(t); clearInterval(t); }); _timers.clear(); const stage = document.getElementById('mg-stage'); if (stage) stage.classList.add('hidden'); const stageBody = document.getElementById('mg-stage-body'); if (stageBody) stageBody.innerHTML = ''; const summaryEl = document.getElementById('mg-summary'); if (summaryEl) summaryEl.classList.add('hidden'); console.info('[minigames] active session torn down (reason=' + reason + ')'); } if (window.feedBack && typeof window.feedBack.on === 'function') { window.feedBack.on('screen:changed', (e) => { const id = e && e.detail && e.detail.id; if (id === SCREEN_ID) { renderHub(); } else if (active || starting || _summaryCleanup) { // Teardown covers: active run, in-flight startup, and post-run summary // left open while the user navigated away. // // Exception: a "chart-backed" minigame (spec.usesPlayer) navigates to // the player ON PURPOSE and drives it — it overlays its own HUD there // and ends the run itself via sdk.end() (and handles abandonment FROM // the player from inside the game). Don't tear it down for that one hop. // Navigating to any OTHER screen still tears down here as usual — which // covers abandoning the pre-run setup UI before the player ever loads. if (id === 'player' && active && active.spec && active.spec.usesPlayer) { return; } teardownActiveSession('screen-changed').catch((err) => { console.error('[minigames] teardown failed:', err); }); } }); } // Initial render in case the DOM is already mounted (hot reload / direct load). if (document.getElementById('mg-grid')) { renderHub(); } // ── Inject a top-level nav link (not the plugin dropdown) ───────────── // The user wants Minigames alongside Library / Favorites / Upload / // Settings — not buried in the "Plugins" dropdown. We removed `nav` // from plugin.json so the auto-injected dropdown item is gone; this // block adds a top-level link instead, and is idempotent so plugin // reloads or hot-refreshes don't double-add. // Each anchor is added independently — the mobile menu DOM may not exist // on the first call (e.g. plugin loads before the mobile nav is rendered), // so we must NOT short-circuit on "desktop already exists". We retry on // a short cadence so the mobile link gets added when its anchor appears. function installNavLink() { const navigateToHub = (e) => { if (e) e.preventDefault(); if (window.feedBack && typeof window.feedBack.navigate === 'function') { window.feedBack.navigate(SCREEN_ID); } else if (typeof window.showScreen === 'function') { window.showScreen(SCREEN_ID); } }; // Desktop: insert just before the plugin dropdown span (visual order // becomes Library / Favorites / Upload / Minigames / [Plugins…] / // Settings). if (!document.getElementById('mg-nav-link-desktop')) { const pluginsAnchor = document.getElementById('nav-plugins'); if (pluginsAnchor && pluginsAnchor.parentElement) { const a = document.createElement('a'); a.id = 'mg-nav-link-desktop'; a.href = '#'; a.className = 'text-sm text-gray-400 hover:text-white transition'; a.textContent = 'Minigames'; a.addEventListener('click', navigateToHub); pluginsAnchor.parentElement.insertBefore(a, pluginsAnchor); } } // Mobile: insert just before the mobile plugin block (which itself // sits inside #mobile-menu). if (!document.getElementById('mg-nav-link-mobile')) { const mobileAnchor = document.getElementById('mobile-nav-plugins'); if (mobileAnchor && mobileAnchor.parentElement) { const m = document.createElement('a'); m.id = 'mg-nav-link-mobile'; m.href = '#'; m.className = 'text-gray-400 hover:text-white'; m.textContent = 'Minigames'; m.addEventListener('click', (e) => { navigateToHub(e); const mobileMenu = document.getElementById('mobile-menu'); if (mobileMenu) mobileMenu.classList.add('hidden'); }); mobileAnchor.parentElement.insertBefore(m, mobileAnchor); } } } installNavLink(); // The feedBack plugin loader rebuilds the dropdown when plugins // hot-reload — re-install on a short delay then settle. setTimeout(installNavLink, 250); setTimeout(installNavLink, 1500); // Re-install the nav link whenever the nav containers are mutated (e.g. // after loadPlugins() clears and rebuilds #nav-plugins / #mobile-nav-plugins). // We watch the desktop nav parent and the body for the mobile-menu insertion, // debouncing with rAF so rapid successive mutations fire a single re-install. (function watchNavMutations() { if (typeof MutationObserver === 'undefined') return; let _rafPending = false; const onMutation = () => { if (_rafPending) return; _rafPending = true; requestAnimationFrame(() => { _rafPending = false; // Only re-install when our link is actually gone (idempotent guard // inside installNavLink checks id existence, but this outer check // avoids calling the function on unrelated mutations). if (!document.getElementById('mg-nav-link-desktop') || !document.getElementById('mg-nav-link-mobile')) { installNavLink(); } }); }; const obs = new MutationObserver(onMutation); // Observe the top-level nav bar and the body (mobile menu is inserted lazily). const nav = document.querySelector('nav') || document.body; obs.observe(nav, { childList: true, subtree: true }); })(); })();