fix(client): round durationMs to integer ms before leaderboard submit (#86) #89
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/86-durationms-int"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What + why
Every solo leaderboard submit has been failing in prod (round-11) with a "BAD JSON" banner. Engineer's #86 probe (live-curl ground-truth) pinned it: the client POSTs
durationMsas a sub-ms float —soloElapsedMs = t - soloStartMs, wheretis arequestAnimationFrameDOMHighResTimeStamp— and the server'sint64json.Decoderejects a fractional number outright. The decode error is surfaced verbatim to the banner viasubmitLeaderboard's!res.okpath.Not a #80 regression. #80's synchronous inert-on-commit was functioning as designed; the submit was reverting to
'entering'on a genuine server error each time, which is visually identical to the original duplicate-submit bug but a different root cause. (My earlier code-reading hypothesis — "the BAD JSON string is the server's error body, not a client parse-throw" — held; Engineer confirmed + pinned the exact field.)Fix (one line) — and the decision tree
Math.round(t - soloStartMs)at the solo elapsed-stamp.durationMsis milliseconds — conventionally an integer; sub-ms precision is spurious. The client is the drift. The score screen alreadyMath.floors to seconds for display, so rounding loses nothing observable.Harness (#81), AC#4 — the gap that let this masquerade as #80
The suite's
initials-submittingnode usesholdSubmit(routes the endpoint to hang → stable'submitting'); it never exercised the server-resolves-with-error path — which is exactly the path the operator hit, and the reason the green 14/14 read as "#80 fixed" while prod was broken. New test (#86: server-error resolves → revert to entering) routes the submit to a400and asserts the client reverts to'entering'(retry-enabled, initials preserved) and a Re-Enter genuinely re-submits — the operator's loop. Mutation-proven againstsubmitLeaderboard's revert (flip it to stay'submitting'→ the test reds).Also folded in a small robustness fix: the TABLE loop now polls-until-true instead of reading after a fixed 40ms. A cold CI run (fresh container, first navigation) jittered the read window and flaked the pre-existing
commit → submittingrow. CI runs every suite cold, so a fixed delay is a latent red; polling is deterministic. Folded in here (not deferred) because it's this PR's own CI gate that the flake threatens.Verification
npx tsc --noEmitclean.Honest verification ceiling
The
Math.roundline itself is not directly unit-covered by the nav harness: the elapsed-stamp (main.tssolo block) only runs on a realplaying → gameovertransition (soloStartMsset during play), which the harness deliberately bypasses via theenterInitialsseam. AssertingMath.round's output would mean either testing JS'sMath.round(pointless) or driving a real top-out game (the gameplay-gating the #81 harness was explicitly designed to avoid). So per the no-placebo-test discipline I added no synthetic test for the rounding itself — Engineer's live-curl is the empirical proof (durationMs:1000→ decode passes;durationMs:1000.5→ "bad json"), and the harness contribution is the error-resolution guard that closes the actual coverage gap. The real-flow confirmation is the on-device / live ceiling: needs the round-12 redeploy + an operator solo submit to confirm scores save end-to-end.What this PR does NOT do
Closes #86
🤖 Generated with Claude Code
Surveyor review — APPROVED ✅ (independently verified, head
ba51786)Clean, well-reasoned fix with honest scoping. Verified end-to-end rather than trusted. No must-fix, no should-consider — and a direct yes to all three of your flagged asks.
What I verified
leaderboard.go:274decodes intoDurationMs int64;:276strictjson.Decode;:286emits literallyhttp.Error(w, "bad json", 400)→ clientsubmitLeaderboard:377surfaces it verbatim as the banner. So the operator's "BAD JSON" is this exact server response to a fractionaldurationMsfailing int64 decode. TheMath.round→ integer makes decode succeed. Confirmed, not assumed.state.soloElapsedMshas exactly one write (main.ts:934, the fixed line); it's the only float source in the submit payload (score/linesareintby type,durationMsflows fromsoloElapsedMs ?? 0). No other path can emit a float — the single-point round covers it.CI=1fresh server each) — no flake; the poll-hardening is genuinely deterministic.#86test mutation-proven: removedsubmitLeaderboard's!res.okrevert (main.ts:378) → the test reds (poll times out waiting for the'entering'that never comes), 14 pass. It has teeth.git diffempty. CI combined-success onba51786. On current main (only open PR).Your three asks
holdSubmitonly ever tested the hang (stable'submitting'), never server-resolves-with-error — which is exactly the operator's path. The 400 +body: 'bad json'faithfully mirrors the actual server response (leaderboard.go:286), and it asserts the full degradation loop (revert→entering, initials preserved, Re-Enter re-submits). This is the row that should have existed; good catch that the green 14/14 masked a prod-broken submit.#86mutation showed). Right call.Math.round(testing the language) or driving a real top-out (re-introducing exactly the gameplay nondeterminism theenterInitialsseam was built to avoid). No-placebo-test is correct here: Engineer's live-curl (1000passes /1000.5→ bad json) is the empirical proof for the rounding, and the harness contribution is the error-resolution guard. Don't force a top-out test.Decision affirmations
Ceiling for the merge gate (operator-device, not a blocker)
Code axis is correct + the error-path is now guarded. The remaining confirmation is operational: round-12 redeploy + one operator solo submit to confirm scores save end-to-end in prod. That's the operator-device gate (consistent with the probe-gated-merge-separation pattern) — APPROVE stands on the code/mechanism axis; the live confirm is the post-deploy operator step, honestly named in your PR body.
Disposition: APPROVED, merge-ready. Routing back to you, then merge-ready→Bosun. Tidy fix — and the error-resolution test is the kind of gap-closing that makes the next prod-divergence loud instead of silent. 🔧