diff --git a/static/v3/live-performance-hud.js b/static/v3/live-performance-hud.js index f9abb90..66ef0e7 100644 --- a/static/v3/live-performance-hud.js +++ b/static/v3/live-performance-hud.js @@ -118,6 +118,14 @@ // "Waiting for notes" overlay for the whole song. let revealed = false; let counters = createCounters(); + // Position-aware ledger: { t: chart-note time, hit } per judged note, + // so a BACKWARD reposition (Restart button / scrub-back) can rebuild the + // visible tally to reflect only the notes up to the new playhead instead + // of keeping the stale cumulative total. Mirrors the notedetect HUD's own + // ledger (note:hit/note:miss carry the judgment, incl. noteTime). The + // running `counters` stay incremental for the live path; the ledger is + // only replayed on a seek. + let ledger = []; const els = domEls || { root: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-hud') : null, percent: typeof document !== 'undefined' ? document.getElementById('v3-live-performance-percent') : null, @@ -134,6 +142,39 @@ function resetCounters() { counters = createCounters(); + ledger = []; + } + + // Chart-note time for a judgment event, or null when unknown (argless + // test calls, or a judgment without timing). note:hit/note:miss carry + // the notedetect judgment object as `detail`. + function judgmentTime(e) { + const d = e && e.detail; + if (!d) return null; + if (Number.isFinite(d.noteTime)) return d.noteTime; + if (d.chartNote && Number.isFinite(d.chartNote.t)) return d.chartNote.t; + return null; + } + + // Rebuild counters from the ledger up to (excluding) chart time `t`. + // Drop judgments at/after `t` so replaying forward re-counts them, then + // replay survivors in time order through the same hit/streak rules. + function rebuildToPosition(t) { + if (!Number.isFinite(t)) return; + ledger = ledger.filter((e) => !(Number.isFinite(e.t) && e.t >= t)); + const sorted = ledger.slice().sort((a, b) => (a.t == null ? -Infinity : a.t) - (b.t == null ? -Infinity : b.t)); + counters = createCounters(); + for (const e of sorted) { + if (e.hit) { + counters.hits++; + counters.streak++; + if (counters.streak > counters.bestStreak) counters.bestStreak = counters.streak; + } else { + counters.misses++; + counters.streak = 0; + } + } + paint(); } function currentStats() { @@ -180,18 +221,20 @@ paint(); } - function onHit() { + function onHit(e) { if (!active) return; reveal(); + ledger.push({ t: judgmentTime(e), hit: true }); counters.hits++; counters.streak++; if (counters.streak > counters.bestStreak) counters.bestStreak = counters.streak; paint(); } - function onMiss() { + function onMiss(e) { if (!active) return; reveal(); + ledger.push({ t: judgmentTime(e), hit: false }); counters.misses++; counters.streak = 0; paint(); @@ -207,6 +250,22 @@ sm.on('song:ended', () => { hideSession(); }); sm.on('note:hit', onHit); sm.on('note:miss', onMiss); + // Restart / scrub-back: rebuild the tally to the new playhead. song:seek + // is core's single repositioning funnel ({ from, to, reason }). Only a + // BACKWARD jump recomputes; a forward seek leaves earlier notes counted. + // Skip loop-wrap (drill mode) so a practiced A-B loop keeps accumulating, + // matching the notedetect HUD. + sm.on('song:seek', (e) => { + if (!active) return; + const d = (e && e.detail) || {}; + if (d.reason === 'loop-wrap') return; + const to = Number(d.to); + if (!Number.isFinite(to)) return; + const from = Number(d.from); + const movedBack = Number.isFinite(from) ? (to < from - 0.05) : true; + if (!movedBack) return; + rebuildToPosition(to); + }); return { getCounters: () => ({ ...counters }), @@ -216,6 +275,7 @@ hideSession, onHit, onMiss, + rebuildToPosition, paint, els, }; diff --git a/tests/js/live_performance_hud.test.js b/tests/js/live_performance_hud.test.js index 423bc9f..c22ebd5 100644 --- a/tests/js/live_performance_hud.test.js +++ b/tests/js/live_performance_hud.test.js @@ -67,6 +67,64 @@ test('reset counters via bindRuntime song lifecycle', () => { assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 }); }); +test('backward song:seek rebuilds the tally to the new position', () => { + const listeners = new Map(); + const sm = { + on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); }, + emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); }, + }; + const runtime = hud.bindRuntime(sm); + sm.emit('song:loading', { filename: 'song.archive' }); + + // Notes judged at t = 1..5 (miss at t=4), each carried on the event detail. + sm.emit('note:hit', { noteTime: 1 }); + sm.emit('note:hit', { noteTime: 2 }); + sm.emit('note:hit', { noteTime: 3 }); + sm.emit('note:miss', { noteTime: 4 }); + sm.emit('note:hit', { noteTime: 5 }); + assert.equal(runtime.getCounters().hits, 4); + assert.equal(runtime.getCounters().misses, 1); + + // Restart-style backward seek to t=3 → keep only t=1,2 (both hits). + sm.emit('song:seek', { from: 5, to: 3, reason: 'song-restart' }); + assert.equal(runtime.getCounters().hits, 2); + assert.equal(runtime.getCounters().misses, 0); + assert.equal(runtime.getCounters().streak, 2); + + // Restart to the very top → 0 notes. + sm.emit('song:seek', { from: 3, to: 0, reason: 'song-restart' }); + assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 }); +}); + +test('a FORWARD song:seek does not roll back the tally', () => { + const listeners = new Map(); + const sm = { + on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); }, + emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); }, + }; + const runtime = hud.bindRuntime(sm); + sm.emit('song:loading', { filename: 'song.archive' }); + sm.emit('note:hit', { noteTime: 1 }); + sm.emit('note:hit', { noteTime: 2 }); + sm.emit('song:seek', { from: 2, to: 30, reason: 'seek-by' }); + assert.equal(runtime.getCounters().hits, 2, 'forward seek keeps earlier hits'); +}); + +test('loop-wrap seek is ignored (drill mode keeps accumulating)', () => { + const listeners = new Map(); + const sm = { + on(event, fn) { const l = listeners.get(event) || []; l.push(fn); listeners.set(event, l); }, + emit(event, detail) { (listeners.get(event) || []).forEach((fn) => fn({ detail })); }, + }; + const runtime = hud.bindRuntime(sm); + sm.emit('song:loading', { filename: 'song.archive' }); + sm.emit('note:hit', { noteTime: 11 }); + sm.emit('note:hit', { noteTime: 12 }); + // A-B drill loop wraps backward to loopA — must NOT reset the tally. + sm.emit('song:seek', { from: 12, to: 10, reason: 'loop-wrap' }); + assert.equal(runtime.getCounters().hits, 2, 'loop-wrap leaves the cumulative tally intact'); +}); + test('DOM text updates after hit and miss events', () => { class El { constructor(id) {