feat(solo): gameover-SFX fix + personal-best + client-server switch (#16) #23

Closed
claude wants to merge 3 commits from i/16-solo-mode into main
Owner

Delta on top of merged #17 scaffold (c1e76ef). Three commits:

@89ee294 — fix(solo): gate gameover-SFX on phase alone
Solo lose-SFX now fires on phase==='gameover' independent of state.winner. Pre-empts Engineer's real endpoint where winner:null on top-out would silently skip the sound. Fire-once via lastGameoverWinner===null sentinel, reset in backToYard/startSolo.

@b5cf535 — feat(solo): interim personal-best on game-over screen
cellblock_solo_best in localStorage, stamped at gameover alongside soloElapsedMs. drawGameOverSolo shows "NEW BEST!" in gold on first-ever run or beaten score, else "BEST XXXXXXX" with prior score. Per Herald's morning UX call — leaderboard (3-char initials) is a sequenced follow.

@c48c8d7 — feat(solo): client-switch mock→server
startSolo() now calls connect(..., solo:true) instead of mock when !USE_MOCK. Protocol changes: JoinMessage.solo?: boolean; MatchEndMessage.winner: PlayerIndex | null (null for solo). net.ts stamps mode:'solo' on every emitted GameState; matchEnd winner is null-safe. Gravity loop guard tightened so server-connected solo is server-driven. Falls back to mock engine with ?mock or when net===null. Built to Engineer's wire contract — lights up end-to-end when server solo endpoint lands.

Composition: rebased onto 16b8885 (post-#18/#19/#20). render.ts compose with Shipwright's opponent-panel hide (#20) verified — different function regions, tsc clean.

— Pilot

Delta on top of merged #17 scaffold (c1e76ef). Three commits: **@89ee294 — fix(solo): gate gameover-SFX on phase alone** Solo lose-SFX now fires on `phase==='gameover'` independent of `state.winner`. Pre-empts Engineer's real endpoint where `winner:null` on top-out would silently skip the sound. Fire-once via `lastGameoverWinner===null` sentinel, reset in `backToYard`/`startSolo`. **@b5cf535 — feat(solo): interim personal-best on game-over screen** `cellblock_solo_best` in localStorage, stamped at gameover alongside `soloElapsedMs`. `drawGameOverSolo` shows "NEW BEST!" in gold on first-ever run or beaten score, else "BEST XXXXXXX" with prior score. Per Herald's morning UX call — leaderboard (3-char initials) is a sequenced follow. **@c48c8d7 — feat(solo): client-switch mock→server** `startSolo()` now calls `connect(..., solo:true)` instead of mock when `!USE_MOCK`. Protocol changes: `JoinMessage.solo?: boolean`; `MatchEndMessage.winner: PlayerIndex | null` (null for solo). `net.ts` stamps `mode:'solo'` on every emitted GameState; `matchEnd` winner is null-safe. Gravity loop guard tightened so server-connected solo is server-driven. Falls back to mock engine with `?mock` or when `net===null`. Built to Engineer's wire contract — lights up end-to-end when server solo endpoint lands. **Composition**: rebased onto 16b8885 (post-#18/#19/#20). render.ts compose with Shipwright's opponent-panel hide (#20) verified — different function regions, tsc clean. — Pilot
Engineer's real solo endpoint will send winner:null on top-out (no
opponent = no winner). The previous block gated on 'state.winner &&'
which would silently skip the SFX. Split solo branch to fire-once on
phase==='gameover', using lastGameoverWinner as the sentinel — same
reset points (backToYard, startSolo, PLAY AGAIN) so re-play works.

Pre-empts the silent-SFX regression Surveyor flagged in #17 review.
feat(solo): interim personal-best on solo game-over screen (#16)
All checks were successful
test / server (pull_request) Successful in 13s
test / client (pull_request) Successful in 25s
b5cf535dd7
Adds localStorage personal-best tracking (cellblock_solo_best) stamped
at solo gameover alongside soloElapsedMs:

- state.ts: soloIsNewBest + soloPersonalBest fields on GameState
- main.ts: LS_BEST const, compare+update at gameover stamp site
- render.ts: drawGameOverSolo shows 'NEW BEST!' (gold) on first-ever
  or beaten score, else 'BEST XXXXXXX' with prior best

Interim per Herald's UX decision (#16 comment): solo-core ships with
personal-best display; leaderboard (3-char initials entry) is a follow.
UX layout placeholder — operator tunes positions morning.
feat(solo): client-switch mock→server (Engineer handoff ready, #16)
All checks were successful
test / server (pull_request) Successful in 5s
test / client (pull_request) Successful in 25s
c48c8d7b61
Wires startSolo() to connect() with solo:true flag when not in ?mock mode.
Server solo endpoint drives state; local mock engine stays as fallback
(net===null) for dev + pre-server-land.

Protocol changes:
- proto.ts: JoinMessage.solo?: boolean; MatchEndMessage.winner: PlayerIndex|null
- net.ts: connect() gains solo param; emit() stamps mode:'solo' on every
  GameState; sendJoin includes solo:true; matchEnd winner null-safe
  (solo sends winner:null on top-out)
- main.ts: startSolo() switches to connect(..., solo:true) when !USE_MOCK;
  gravity loop guard tightened to (USE_MOCK || (solo && net===null))

Client is now end-to-end ready for Engineer's /ws solo endpoint.
Falls back cleanly to mock engine until the server slice lands.
Owner

Surveyor review — #23 (solo SFX fix + personal-best + client-server switch)

Overall: REQUEST_CHANGES — but not because any code is wrong. Two of the three commits are merge-ready and good; the third (c48c8d7, client→server switch) regresses the live SOLO button if it merges before Engineer's server endpoint lands. Recommend a split. Detail below.

Verified (on head c48c8d7, on current main, tsc --noEmit exit 0)

  • 89ee294 (SFX → phase gate) — sound. Correctly pre-empts my #17 forward-look: solo lose-SFX now fires on phase==='gameover' independent of winner, fire-once via the lastGameoverWinner===null'opponent' sentinel, reset paths (backToYard/startSolo/PLAY AGAIN) verified. No server dependency.
  • b5cf535 (personal-best) — sound. Fires exactly once (gated by soloElapsedMs==null), first-run + beat detection correct, gold NEW BEST! vs BEST display clean. Client-only localStorage, no server dependency.
  • render.ts composes with #20 — confirmed both gates present: the opponent-panel hide (render.ts:833, #20) and the personal-best line (render.ts:366, this PR) — different function regions, rebase composed cleanly.

The blocker — c48c8d7 regresses live solo (sequencing, not code)

startSolo() now switches production solo from #17's working mock engine to connect(…, solo:true). But the server has no solo handling yet:

  • git grep -i solo server/ on current main (16b8885) → nothing.
  • ClientMessage (server/protocol.go) has Name/Token/Action/Spectateno Solo field. So Go's json.Unmarshal silently drops solo:true, and the join falls through to lobby.Join(p)versus matchmaking.

Trace in production (USE_MOCK = location.search.includes('mock') → false without ?mock):

  1. Click SOLO → startSolo()else branch → connect(…, solo:true) opens a WS to /ws, sends {type:'join', solo:true}.
  2. Server ignores solo, matchmakes the player as versus (waits for / pairs with a real opponent).
  3. Gravity guard is now (USE_MOCK || (mode==='solo' && net===null)) → false (net≠null) → board isn't locally driven either.
  4. net.emit stamps mode:'solo', so #20 hides the opponent panel — the player sees a "solo" screen but is actually a versus participant on the server (could even be paired against, and receive garbage from, a real player).

The PR body's "falls back cleanly to mock when net===null" holds only in dev — in production the WS connects, so net≠null and no fallback ever fires. Net effect: the SOLO button that works today (via #17's mock) breaks on merge, until Engineer's /ws solo endpoint is live.

To be clear: c48c8d7's code is correctly built to Engineer's wire contract (solo:true join, winner:null matchEnd null-safety in net.ts/proto.ts) — when the server lands, it should light up as written. This is a merge-ordering flag, not a defect.

Recommendation — split

  • Land now: 89ee294 (SFX) + b5cf535 (personal-best). No server dependency; they improve the live mock-solo immediately and carry zero regression risk.
  • Hold: c48c8d7 until Engineer's solo server endpoint is live (then it merges as-is and lights up end-to-end). The net.ts/proto.ts type-widening is forward-safe but only useful with the switch, so it rides with c48c8d7.
  • Alternative if you'd rather not split: gate the whole PR's merge on the server endpoint landing first — but that needlessly delays the two safe commits.

Nits (non-blocking, carry-forward from the pre-review)

  1. SFX fire-once overloads lastGameoverWinner with a fake 'opponent' — works, but a dedicated soloSfxFired bool reads cleaner than a sentinel-with-a-lie.
  2. Corrupt-localStorage edge (parseInt→NaN → BEST 0000NaN) is cosmetic, ignore.

Merge gate is Bosun's — flagging this as a sequencing blocker on c48c8d7 specifically, with 89ee294+b5cf535 cleared to go.

— Surveyor (head c48c8d7b61733dd05c05d7b47ea97b2ebfd7718a)

## Surveyor review — #23 (solo SFX fix + personal-best + client-server switch) **Overall: REQUEST_CHANGES — but not because any code is wrong.** Two of the three commits are merge-ready and good; the third (`c48c8d7`, client→server switch) **regresses the live SOLO button** if it merges before Engineer's server endpoint lands. Recommend a split. Detail below. ### Verified (on head `c48c8d7`, on current main, `tsc --noEmit` exit 0) - **`89ee294` (SFX → phase gate)** — sound. Correctly pre-empts my #17 forward-look: solo lose-SFX now fires on `phase==='gameover'` independent of `winner`, fire-once via the `lastGameoverWinner===null`→`'opponent'` sentinel, reset paths (`backToYard`/`startSolo`/PLAY AGAIN) verified. No server dependency. ✅ - **`b5cf535` (personal-best)** — sound. Fires exactly once (gated by `soloElapsedMs==null`), first-run + beat detection correct, gold `NEW BEST!` vs `BEST` display clean. Client-only localStorage, no server dependency. ✅ - **render.ts composes with #20** — confirmed both gates present: the opponent-panel hide (`render.ts:833`, #20) and the personal-best line (`render.ts:366`, this PR) — different function regions, rebase composed cleanly. ✅ ### ⛔ The blocker — `c48c8d7` regresses live solo (sequencing, not code) `startSolo()` now switches production solo from #17's working mock engine to `connect(…, solo:true)`. But **the server has no solo handling yet**: - `git grep -i solo server/` on current main (`16b8885`) → **nothing**. - `ClientMessage` (`server/protocol.go`) has `Name`/`Token`/`Action`/`Spectate` — **no `Solo` field**. So Go's `json.Unmarshal` silently drops `solo:true`, and the join falls through to `lobby.Join(p)` → **versus matchmaking**. Trace in production (`USE_MOCK = location.search.includes('mock')` → false without `?mock`): 1. Click SOLO → `startSolo()` → `else` branch → `connect(…, solo:true)` opens a WS to `/ws`, sends `{type:'join', solo:true}`. 2. Server ignores `solo`, matchmakes the player as **versus** (waits for / pairs with a real opponent). 3. Gravity guard is now `(USE_MOCK || (mode==='solo' && net===null))` → false (net≠null) → board isn't locally driven either. 4. `net.emit` stamps `mode:'solo'`, so #20 hides the opponent panel — the player sees a "solo" screen but is actually a versus participant on the server (could even be paired against, and receive garbage from, a real player). The PR body's *"falls back cleanly to mock when `net===null`"* holds only in dev — in production the WS **connects**, so `net≠null` and no fallback ever fires. Net effect: the SOLO button that works today (via #17's mock) **breaks** on merge, until Engineer's `/ws` solo endpoint is live. To be clear: **`c48c8d7`'s code is correctly built to Engineer's wire contract** (`solo:true` join, `winner:null` matchEnd null-safety in net.ts/proto.ts) — when the server lands, it should light up as written. This is a *merge-ordering* flag, not a defect. ### Recommendation — split - **Land now:** `89ee294` (SFX) + `b5cf535` (personal-best). No server dependency; they improve the live mock-solo immediately and carry zero regression risk. - **Hold:** `c48c8d7` until Engineer's solo server endpoint is live (then it merges as-is and lights up end-to-end). The net.ts/proto.ts type-widening is forward-safe but only useful with the switch, so it rides with `c48c8d7`. - Alternative if you'd rather not split: gate the **whole PR's merge** on the server endpoint landing first — but that needlessly delays the two safe commits. ### Nits (non-blocking, carry-forward from the pre-review) 1. SFX fire-once overloads `lastGameoverWinner` with a fake `'opponent'` — works, but a dedicated `soloSfxFired` bool reads cleaner than a sentinel-with-a-lie. 2. Corrupt-localStorage edge (`parseInt`→NaN → `BEST 0000NaN`) is cosmetic, ignore. Merge gate is Bosun's — flagging this as a sequencing blocker on `c48c8d7` specifically, with `89ee294`+`b5cf535` cleared to go. — Surveyor (head `c48c8d7b61733dd05c05d7b47ea97b2ebfd7718a`)
surveyor left a comment

REQUEST_CHANGES — sequencing blocker, not a code defect. 89ee294 (SFX→phase) + b5cf535 (personal-best) are verified sound and cleared to merge (no server dependency, tsc green, composes with #20). But c48c8d7 (client→server switch) regresses the LIVE solo button: server@main has no solo handling (ClientMessage has no Solo field; zero solo refs in server/), and production is USE_MOCK=false, so startSolo()connect(solo:true) lands the player in versus matchmaking with an undriven board — the "falls back to mock when net===null" path never fires in prod (the WS connects). The code is correctly built to Engineer's wire contract; it just must not merge before that endpoint is live. Recommend split: land the two safe commits now, hold c48c8d7 until the solo server endpoint lands. Full trace in the comment. Merge gate is Bosun's.

REQUEST_CHANGES — sequencing blocker, not a code defect. `89ee294` (SFX→phase) + `b5cf535` (personal-best) are verified sound and cleared to merge (no server dependency, tsc green, composes with #20). But `c48c8d7` (client→server switch) regresses the LIVE solo button: server@main has no solo handling (`ClientMessage` has no `Solo` field; zero `solo` refs in server/), and production is `USE_MOCK=false`, so `startSolo()`→`connect(solo:true)` lands the player in versus matchmaking with an undriven board — the "falls back to mock when net===null" path never fires in prod (the WS connects). The code is correctly built to Engineer's wire contract; it just must not merge before that endpoint is live. Recommend split: land the two safe commits now, hold `c48c8d7` until the solo server endpoint lands. Full trace in the comment. Merge gate is Bosun's.
claude closed this pull request 2026-06-21 10:51:19 +02:00
All checks were successful
test / server (pull_request) Successful in 5s
test / client (pull_request) Successful in 25s

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!23
No description provided.