mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
feat(career): hours-per-genre odometer — honest wall-clock play time (#942)
Career v2, WS2. Nothing measured play time before (the achievements plugin's final-position shortcut double-counts loops and mis-reads seeks). Now: - stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔ pause/stop/ended spans (single spans clamp at 2h against suspend inflation) and piggybacks them as `seconds` on the POSTs it already sends; failed POSTs restore the accumulator; a session reset flushes first so time can't re-attribute to the next song/arrangement. - POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the scored and position branches, plus a new seconds-only branch for unscored plays that ran to the natural end — banks time WITHOUT touching the resume position (song:ended must not overwrite Continue) and still counts as playing today for the streak. - song_stats gains additive idempotent `seconds_total`; record_session/ touch_position accrue, new add_play_seconds() for the seconds-only path; the legacy-encoding stats merge sums seconds across duplicates. - Passports surface it: "14.2 h in Blues" under the badge stamp and on the shelf cover sub-line — a true fact that only grows, never a target or a meter (Stage 5 post-cap, per the career design). Tests: seconds accrual/validation/seconds-only branch (stats API), per-instrument-and-genre summing (career), fmtHours formatting (vm). Full suites: pytest 2480, JS 1165. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d26347981c
commit
0fc6a4beed
@@ -27,7 +27,60 @@
|
||||
let cur = null; // active session
|
||||
let recordedThisSession = false;
|
||||
|
||||
// Wall-clock play time (career hours odometer). Accrued across
|
||||
// play/resume ↔ pause/stop/ended spans — wall time, NOT song position:
|
||||
// position deltas double-count A-B loops and mis-read seeks.
|
||||
let playingSince = 0; // performance.now() at span start, 0 while not playing
|
||||
let accruedSeconds = 0; // played time not yet sent
|
||||
// Failed seconds keep their song identity — restoring them into the
|
||||
// global accumulator would let the NEXT song claim them after a session
|
||||
// switch. Bounded; oldest dropped beyond the cap (honest loss beats
|
||||
// misattribution).
|
||||
let pendingSeconds = []; // [{filename, arrangement, seconds}] awaiting retry
|
||||
|
||||
function queuePendingSeconds(filename, arrangement, seconds) {
|
||||
pendingSeconds.push({ filename, arrangement, seconds });
|
||||
if (pendingSeconds.length > 20) pendingSeconds.shift();
|
||||
}
|
||||
|
||||
function retryPendingSeconds() {
|
||||
if (!pendingSeconds.length) return;
|
||||
const batch = pendingSeconds;
|
||||
pendingSeconds = [];
|
||||
for (const body of batch) {
|
||||
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, body.seconds); });
|
||||
}
|
||||
}
|
||||
|
||||
function clockStart() { if (!playingSince) playingSince = performance.now(); }
|
||||
function clockStop() {
|
||||
if (!playingSince) return;
|
||||
const delta = (performance.now() - playingSince) / 1000;
|
||||
playingSince = 0;
|
||||
// A single unbroken span beyond 2h of wall clock is a suspend/sleep
|
||||
// artifact, not practice — clamp it.
|
||||
if (Number.isFinite(delta) && delta > 0) accruedSeconds += Math.min(delta, 7200);
|
||||
}
|
||||
// Take whatever has accrued (closing any open span) for sending; the
|
||||
// caller restores it if the POST fails so the time isn't lost.
|
||||
function takeSeconds() {
|
||||
clockStop();
|
||||
const s = Math.round(accruedSeconds);
|
||||
accruedSeconds = 0;
|
||||
return s > 0 ? s : 0;
|
||||
}
|
||||
// Unsent seconds belong to the outgoing song/arrangement — flush before
|
||||
// a session reset would re-attribute them.
|
||||
function flushSeconds() {
|
||||
const s = takeSeconds();
|
||||
if (!s) return;
|
||||
if (!cur || !cur.filename) return; // no session to attribute to — drop
|
||||
const body = { filename: cur.filename, arrangement: cur.arrangement, seconds: s };
|
||||
post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, s); });
|
||||
}
|
||||
|
||||
function reset(filename, arrangement) {
|
||||
flushSeconds();
|
||||
cur = {
|
||||
filename: filename || null,
|
||||
arrangement: Number.isFinite(arrangement) ? arrangement : 0,
|
||||
@@ -84,6 +137,7 @@
|
||||
if (!cur || !cur.filename || recordedThisSession) return;
|
||||
if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session
|
||||
recordedThisSession = true;
|
||||
const seconds = takeSeconds();
|
||||
const body = {
|
||||
filename: cur.filename,
|
||||
arrangement: cur.arrangement,
|
||||
@@ -94,7 +148,9 @@
|
||||
bestStreak: cur.bestStreak,
|
||||
lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime,
|
||||
};
|
||||
if (seconds) body.seconds = seconds;
|
||||
post(body).then(async (response) => {
|
||||
if (response == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds);
|
||||
await notifyProgression(response, body, !!natural);
|
||||
// Refresh the profile badge AFTER the progression state moved so
|
||||
// the rank/dB it renders are post-award values.
|
||||
@@ -112,7 +168,10 @@
|
||||
// Allow 0: restarting a song and stopping at the very beginning must be
|
||||
// able to clear a stale Continue offset. Only negatives are invalid.
|
||||
if (!Number.isFinite(position) || position < 0) return;
|
||||
post({ filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position });
|
||||
const seconds = takeSeconds();
|
||||
const body = { filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position };
|
||||
if (seconds) body.seconds = seconds;
|
||||
post(body).then((r) => { if (r == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds); });
|
||||
}
|
||||
|
||||
// ── Session lifecycle ─────────────────────────────────────────────────--
|
||||
@@ -164,13 +223,28 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Play-time clock ───────────────────────────────────────────────────--
|
||||
sm.on('song:play', () => { clockStart(); retryPendingSeconds(); });
|
||||
sm.on('song:resume', clockStart);
|
||||
|
||||
// ── Finalize / resume-position ────────────────────────────────────────--
|
||||
sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true));
|
||||
sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time));
|
||||
sm.on('song:ended', (e) => {
|
||||
clockStop();
|
||||
finalizeScored(e && e.detail && e.detail.time, true);
|
||||
// Unscored natural end: no finalize POST and no position touch
|
||||
// (Continue must not point at the end of the song) — bank the play
|
||||
// time on its own.
|
||||
flushSeconds();
|
||||
});
|
||||
sm.on('song:pause', (e) => {
|
||||
clockStop();
|
||||
touchPosition(e && e.detail && e.detail.time);
|
||||
});
|
||||
sm.on('song:stop', (e) => {
|
||||
// Record the scored session if it wasn't already (e.g. user closed the
|
||||
// player before the track ended), then persist the resume position.
|
||||
// Not a natural end — no calibration-retry prompt for deliberate quits.
|
||||
clockStop();
|
||||
const t = e && e.detail && e.detail.time;
|
||||
finalizeScored(t, false);
|
||||
touchPosition(t);
|
||||
|
||||
Reference in New Issue
Block a user