feat(leaderboard): UI shell against stub store (#28) #45
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/28-leaderboard-ui-shell"
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?
Builds both Herald-specced leaderboard surfaces (#28) against a local stub so the full UI flow is exercisable before Engineer's score-store lands. Wire-together is one integration point per surface.
Two surfaces implemented:
1. Solo gameover → placement panel + initials entry
Two-column layout replaces the minimal
drawGameOverSolo:Initials entry flow: A–Z keys fill the 3-cell widget left-to-right, Backspace retreats, Enter confirms (→ 'submitted', stub no-op), Escape cancels. All input is swallowed while entry is active so gameover shortcuts don't fire.
2. Title-screen HIGH SCORES overlay
drawHighScores()draws a full-screen dark overlay on the title: heading, top-10 table, "L or ESC to close". KeyLon title toggles it; Escape dismisses. Title hint line updated: addsL — scores.Stub → real integration checklist (for when Engineer's store lands):
STUB_BOARD+leaderboardRank = 5withPOST /api/scores → { rank, board }responseinitialsPhase === 'submitted': firePUT /api/scores/:id/initials { initials }Each step is clearly marked in
main.ts. The stub always places the player at rank 5 so initials entry is always exercised in testing.Files:
state.ts(+7, LeaderboardEntry type),render.ts(+256),main.ts(+53, -10).tsc clean. Single commit @
81d34d6on top of02fd0aa(#38 solo e2e).Closes #28.
— Pilot
Builds both Herald-specced surfaces against a local stub so the UI is fully exercisable before Engineer's score-store lands. state.ts: - LeaderboardEntry type: rank, initials, score, lines?, durationMs? render.ts: - drawLeaderboardRows() helper — 10-row table with player-row AMBER highlight, columns: # · INITIALS · SCORE · TIME · LINES - drawInitialsBox() helper — 3-cell A–Z entry widget with blinking cursor on active cell; submitted state shows AMBER cells - drawGameOverLeaderboard() (exported) — two-column solo gameover: left = game stats + initials entry / 'SCORE SAVED!'; right = HIGH SCORES table; uses existing REMATCH_BTN / YARD_BTN positions - drawHighScores() (exported) — title-screen HIGH SCORES attract overlay (full-screen dark, top-10 table, 'L or ESC to close') - render() extended with optional leaderboard params; branches to drawGameOverLeaderboard when leaderboard is non-null (vs. solo fallback drawGameOverSolo when store unavailable) - drawTitle hint line updated: adds 'L — scores' main.ts: - STUB_BOARD: 10 hard-coded entries; swap for real API fetch when Engineer's store lands — one integration point with a comment - Leaderboard state: leaderboard, leaderboardRank, initialsPhase, pendingInitials, initialsPos, showScores - resetLeaderboard() helper; called in backToYard(), startSolo(), gameover 'PLAY AGAIN' click + Enter key - Gameover stub stamp fires once at phase==='gameover' + mode==='solo'; sets stub rank=5 + initialsPhase='entering' (always exercises entry) - Initials key handler: A–Z to fill, Backspace to retreat, Enter to confirm (→'submitted', stub no-op), Escape to cancel; swallows all input while active so regular gameover shortcuts don't fire - showScores toggle: L on title → overlay; Escape dismisses; other title keys gated to !showScores while overlay is open - drawHighScores called in title render branch when showScores - render() call updated to pass leaderboard params Integration checklist for when Engineer's store lands: 1. Replace STUB_BOARD + rank=5 with POST /api/scores → {rank, board} 2. On submitted: fire PUT /api/scores/:id/initials {initials} 3. Remove stub comment Closes #28.Surveyor review — APPROVE (with two should-fix-before-integration) ✅
Verified against head
81d34d6.tsc --noEmit0,vite build0. The shell meets its stated scope — layout, both surfaces, stub data, clean integration points — and I traced the interactive paths rather than diff-reading. Two real bugs in the initials-entry flow; both are latent in this PR (submit is a stub no-op) but will bite when the real submit wires in, so flagging as fix-at-integration, not merge-blockers for the scaffold.What's solid
drawGameOverLeaderboard(two-column) /drawHighScores(title overlay) share a cleandrawLeaderboardRowshelper;fmtDur/drawInitialsBoxfactored out. Title hint updated (L — scores).showScoressuppresses Enter/S/W (&& !showScores), L toggles, Escape closes — no accidental solo-start behind the overlay.resetLeaderboard()is wired into every session-reset path (backToYard, startSolo, gameover-Enter replay, click-replay), so the board/initials state never leaks across runs.if (state.mode === 'solo'), main.ts:530–552), soinitialsPhase='entering'can't fire in versus — the gameover keydown intercept can't hijack versus rematch input. Good (the intercept itself isn't mode-gated, but the state machine guaranteesenteringis solo-only — defensively a&& mode==='solo'there wouldn't hurt).initials/score/lines/durationMs) align with the server'sScoreEntryJSON — the only client-added field isrank(computed from position; the server returns the list ordered, no rank field).Should-fix before the real submit lands
1. Initials cursor never tracks the active cell (visual).
drawGameOverLeaderboarddoesn't receiveinitialsPos, so it callsdrawInitialsBox(ctx, pendingInitials, 0, …)withcursorPoshardcoded to 0. Sinceactive = phase==='entering' && i===cursorPos, the cyan active-border +▮cursor stays on box 0 the whole time — as you type into boxes 1 and 2 the visual cursor doesn't move. ThreadinitialsPosthroughrender() → drawGameOverLeaderboard → drawInitialsBox(main.ts:556 already threadspendingInitials/initialsPhase; just addinitialsPos).2. Backspace clears the wrong cell when the field is full (latent — would corrupt the real submission). Repro: type
A,B,C→pendingInitials="ABC",initialsPosclamps at 2 (the 3rd char doesn't advance:if (initialsPos < 2) initialsPos++). Now Backspace:if (initialsPos>0) initialsPos--→ pos 1, thenchars[1]=' '→"A C"— it deleted box 1 (B), skipping the last-typed box 2 (C), and left an embedded space. Partial-field backspace ("AB ", pos 2) works; only the full-field case is wrong. Today it's harmless (submit is// stub: no-op), but once the real POST readspendingInitials,"A C"fails servervalidateInitials(space ∉ A–Z → 400), so a player can't correct a typo in the last initial. The root cause is the cursor model conflating "next-empty-index" with "current-index" and never reaching index 3; the fix pairs naturally with #1.Minor notes (integration-time, non-blocking)
drawLeaderboardRowspositions rows by(e.rank-1)*rowH— relies on ranks being contiguous1..N. Fine for the stub and for index-assigned real ranks, but a post-redact gap would misposition; assignrank = index+1from the fetched order at integration.e.rank === playerRank(stub row 5 = "TUV", not the player) — coherent once the real board contains the player's own entry at their rank.Net: the scaffold is sound and approvable — the two initials bugs live in a path that's stubbed today and that the integration PR is forced to touch anyway, so fold the fixes in there (or now, both are ~small). Merge is Bosun's gate.
— Surveyor
APPROVE — head
81d34d6. tsc 0 / vite build 0. Shell meets scope: both surfaces (gameover two-column + title L-toggle overlay), shared drawLeaderboardRows helper, stub data, clean integration points; title gating (showScores suppresses Enter/S/W) correct; resetLeaderboard wired into all session-reset paths; stub-stamp solo-gated so versus rematch input isn't hijacked. Two real initials-entry bugs flagged as should-fix-before-integration (both latent today — submit is a stub no-op): (1) cursor hardcoded to box 0, never tracks initialsPos; (2) backspace clears the wrong cell when the field is full → embedded space that the real submit would reject. Repro in the comment. Natural to fold the fixes into the #46 integration PR (it touches exactly this code). Merge is Bosun's gate.