Re-Enter on initials shows BAD JSON error + player stays — server submit-handler contract drift probe #86
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/cellblock#86
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Behavior (operator-observed 2026-06-22 post-round-11 deploy)
After entering initials at highscore entry + pressing Enter:
NOT a #80 regression (Shipwright code-reading hypothesis)
PR #84's #80 fix (synchronous submitting-phase inert-on-commit) is functioning as designed. The visual symptom is identical to the original #80 bug but the root cause is upstream.
Shipwright's reasoning (from code-reading of merged main, not yet probed live):
!res.oksurfaces SERVER's error-body TEXT VERBATIM as banner (line 377), then reverts initialsPhase→'entering' for retry (line 378)/leaderboard/submitis rejecting the client request as decode-error each time → submit→server-error→revert→retry loop, visually identical to the original #80 bug but DIFFERENT root causeHypothesized root cause: contract drift between client + server
Client (main.ts:374) POSTs
{initials, score, lines, durationMs}as JSON.If the server's submit-handler decode-schema has drifted from this contract (e.g. DisallowUnknownFields, field rename, strict decode), a Go strict-decoder rejects valid-looking JSON as a decode error → "bad JSON" surfaces verbatim to the banner.
Suspicious newer fields per Shipwright:
lines,durationMs(may be additions post-server-schema).Sharpened probe (3-step)
(a) Live curl
/leaderboard/submitwith the real client payload{initials, score, lines, durationMs}against https://cellblock.frankenbit.de → capture status + body. Ground truth.(b) Engineer: diff server submit-handler decode-schema in cellblock/server/leaderboard.go vs the client's 4-field payload. Identify the rejected field(s) or strict-decode flag.
(c) Shipwright: close harness gap with an error-resolution-path row (currently
toSubmitting/holdSubmitHANGs the endpoint; never tests server-resolves-with-error path). Substrate-claim-vs-empirical-reality gap empirically located by this issue.Lane + size
Engineer lane primary (server schema diff + likely fix). Shipwright lane secondary (harness gap close). Sizes unclear pending probe.
Acceptance criteria
{initials, score, lines, durationMs}payload is accepted (or client adjusts to server schema, whichever side is wrong per substrate-truth)Cross-refs
Anchor
2026-06-22 ~13:00 operator playtest on round-11 deployed substrate. Discovered + diagnosed by Shipwright via code-reading the merged substrate without live-probing (filed-rootcause-is-hypothesis correctly applied — confirm via probe before fix-shape).
Probe result: root cause CONFIRMED — float
durationMsvs serverint64decode (Shipwright's hypothesis verified, exact field pinned)Ground truth — live curl against the round-11 deployed
cellblock.frankenbit.de(pollution-free: both usedscore:-1so neither placed on the board):{…,"durationMs":1000}(int)400 "score must be non-negative"— decode passed, fell through to the score check{…,"durationMs":1000.5}(float)400 "bad json"— decode failed on the floatGo's
json.Decodeof a JSON float into the handler'sDurationMs int64(server/leaderboard.go) errors → the handler returns its"bad json"body. The client surfaces that verbatim as the banner (main.ts:377) then revertsinitialsPhase→'entering'(:378) — visually identical to #80 but a different root cause. NOT a #80 regression (PR #84 functioning as designed). Exact rejected field:durationMs.Client layer (source proof):
durationMs←state.soloElapsedMs(main.ts:413) =t - soloStartMs(main.ts:926), wheretis therequestAnimationFramecallback timestamp —loop(t: number)atmain.ts:850, driven byrequestAnimationFrame(loop)(:906/:997). That's aDOMHighResTimeStamp, i.e. a sub-millisecond float. SodurationMsis ~always fractional (e.g.60123.456) → every solo submit fails the decode, matching the operator's "each Enter shows BAD JSON" (consistent, not intermittent).Why surfaced post-round-11: the drift is latent — the server's
int64(#28) and the client's float elapsed only collide oncedurationMsrides the submit payload. Not load-bearing for the fix.Fix-shape recommendation (AC#2 — "whichever side is wrong per substrate-truth")
durationMsis a duration in milliseconds — conventionally an integer; sub-ms precision is spurious (the client alreadyMath.floors it to whole seconds for display,render.ts:550). So the client is the drift:state.soloElapsedMs = Math.round(t - soloStartMs)(main.ts:926). One line, loses nothing (display already floors), fixes at the source of the spurious precision.durationMs(decode viajson.Number/float64, truncate toint64). Postel-liberal — robust to any future client float. Not required if the client rounds; flag if belt-and-suspenders against recurrence is wanted.AC#1 (exact rejection mechanism) ✓. No code changed (probe-only per the probe-first cycle). Fix dispatched separately.
AC-tick pass — closed by PR #89 (squashed):
Follow-up needed: round-12 redeploy + operator solo submit to confirm scores save end-to-end (n=1 of operator-device-gate variant family for this fix).