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:
Byron Gamatos
2026-07-13 15:12:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d26347981c
commit 0fc6a4beed
11 changed files with 289 additions and 31 deletions
+49
View File
@@ -452,3 +452,52 @@ def test_award_xp_negative_reversal_clamps_at_zero(server):
db.award_xp(50, "minigames")
assert db.award_xp(-50, "minigames") == 0 # exact reversal
assert db.award_xp(-999, "minigames") == 0 # over-reverse clamps at 0
# ── Wall-clock play-time accrual (career hours odometer) ─────────────────────
def test_seconds_accrue_on_scored_and_position_posts(client):
r = client.post("/api/stats", json={"filename": "s.archive", "score": 400,
"accuracy": 0.6, "seconds": 120})
assert r.status_code == 200
assert r.json()["stats"]["seconds_total"] == pytest.approx(120)
# Position-only touch accrues too.
r2 = client.post("/api/stats", json={"filename": "s.archive",
"lastPlayPosition": 12.5, "seconds": 30})
assert r2.json()["stats"]["seconds_total"] == pytest.approx(150)
# A POST without seconds leaves the total alone.
r3 = client.post("/api/stats", json={"filename": "s.archive", "lastPlayPosition": 20.0})
assert r3.json()["stats"]["seconds_total"] == pytest.approx(150)
def test_seconds_only_post_accrues_without_touching_position(client):
client.post("/api/stats", json={"filename": "s.archive", "lastPlayPosition": 42.0})
r = client.post("/api/stats", json={"filename": "s.archive", "seconds": 90})
assert r.status_code == 200
row = r.json()["stats"]
assert row["seconds_total"] == pytest.approx(90)
# No plays counted, resume position untouched (song:ended must not
# overwrite Continue with the end-of-song offset).
assert row["plays"] == 0
assert row["last_position"] == pytest.approx(42.0)
# Still counts as playing today for the streak.
assert r.json()["progress"]["current_streak"] == 1
@pytest.mark.parametrize("bad", [True, "soon", -5, 0, 6 * 3600 + 1])
def test_seconds_validation_rejects_junk(client, bad):
r = client.post("/api/stats", json={"filename": "s.archive",
"lastPlayPosition": 1.0, "seconds": bad})
assert r.status_code == 400
@pytest.mark.parametrize("token", ["NaN", "Infinity"])
def test_seconds_validation_rejects_nonfinite(client, token):
# json= cannot serialize non-finite floats; python's json.loads (and thus
# the server's body parse) accepts the bare tokens, so send raw.
r = client.post(
"/api/stats",
content=f'{{"filename": "s.archive", "lastPlayPosition": 1.0, "seconds": {token}}}',
headers={"Content-Type": "application/json"},
)
assert r.status_code == 400