mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +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
@@ -26,7 +26,8 @@ class FakeMetaDb:
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE song_stats (
|
||||
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
||||
last_played_at TEXT
|
||||
last_played_at TEXT,
|
||||
seconds_total REAL NOT NULL DEFAULT 0
|
||||
)"""
|
||||
)
|
||||
self.conn.execute(
|
||||
@@ -37,9 +38,10 @@ class FakeMetaDb:
|
||||
)
|
||||
|
||||
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||
genre="", arrangements=None, last_played_at=None):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy, last_played_at))
|
||||
genre="", arrangements=None, last_played_at=None, seconds_total=0):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy, last_played_at,
|
||||
seconds_total))
|
||||
if in_library:
|
||||
self.conn.execute(
|
||||
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
||||
|
||||
@@ -143,3 +143,15 @@ def test_drill_state_validation(client):
|
||||
huge = {"byNode": {"pad": "x" * (300 * 1024)}}
|
||||
assert client.post("/api/plugins/career/drill-state",
|
||||
json=huge).status_code == 413
|
||||
|
||||
|
||||
def test_hours_odometer_sums_seconds_per_instrument_and_genre(client, meta_db):
|
||||
both = [{"type": "lead", "name": "Lead"}, {"type": "bass", "name": "Bass"}]
|
||||
# Two lead arrangements' time sums; the bass row stays on the bass passport.
|
||||
meta_db.add("a.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=600)
|
||||
meta_db.add("b.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=300)
|
||||
meta_db.add("b.feedpak", 1, 0.9, genre="Blues", arrangements=both, seconds_total=1200)
|
||||
_open(client, "guitar")
|
||||
_open(client, "bass")
|
||||
assert _passport(client, "guitar")["seconds_total"] == 900
|
||||
assert _passport(client, "bass")["seconds_total"] == 1200
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user