Skip to content

fix(m13): Phase 1 — co-op survives real life (disconnects, ghosts, leaks) - #27

Merged
ksdisch merged 8 commits into
mainfrom
fix/phase1-coop-lifecycle
Jul 11, 2026
Merged

fix(m13): Phase 1 — co-op survives real life (disconnects, ghosts, leaks)#27
ksdisch merged 8 commits into
mainfrom
fix/phase1-coop-lifecycle

Conversation

@ksdisch

@ksdisch ksdisch commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Phase 1 of the 2026-07-09 audit fix plan — the disconnect/ghost lifecycle arc. Closes F-05, F-06, F-07, F-08, F-16, F-17, F-18, F-21, F-35, F-36 (+ bounds the rooms map, F-26-adjacent). Follows Phase 0 (#26).

What changed

  1. Heartbeat sweep (server/server.ts, F-08/F-26): isAlive/pong + periodic terminate(). A ghost phone (locked on cellular, never FINs) is swept, its slot frees, and the documented same-code manual rejoin actually works; ghost games can no longer grow rooms unboundedly. RELAY_HEARTBEAT_MS env override (default 30s) lets the smoke fast-forward.
  2. Scene handler lifecycle (F-05/F-06): Lobby + Planet keep the onMessage unsubscribe and release it on SHUTDOWN; both net clients isolate each handler in its own try/catch. Kills the N+1 re-cast / corrupted solveTimings telemetry on replay and the Lobby stale-handler exception that starved Planet of every later message.
  3. Phone knows its socket died (F-07): PhoneNetClient.onClose (fires only post-open, never for a deliberate reconnect) + send() returns a boolean. New disconnected phase: "Connection lost" panel with one-tap Rejoin + "Enter a different code". A solve on a dead link routes there instead of faking "Cast!" and minting stardust.
  4. Game surfaces relay loss (F-17) + rejoin restores the indicator (F-16): GameNetClient.onClose (also fires on boot-time failure) → Lobby status line "Relay unreachable — reload to retry"; Planet indicator "● connection lost". phone-joined turns the indicator back green.
  5. Protocol commit (F-18/F-35/F-36, all sides in one commit per the boundary rule): server-originated {type:'peer-disconnected', peer} replaces the {type:'error'} + includes('phone') substring hack; dead game-ready/availablePowers deleted (zero producers/consumers); decorative role fields dropped (the audit's "validate-or-drop" — roles are implied by the handshake). No relayForward rule — server-originated, never peer-forwarded.
  6. Pure RoomRegistry (F-21): room lifecycle extracted from the connection handler (mirroring relayForward), generic over the member handle, 13 colocated Vitest cases (create/join/occupied/not-found/close cleanup/post-sweep rejoin/room-count bound). Sockets stay in server.ts; error strings byte-identical. Bonus fix that fell out: a phone orphaned by its game leaving is released, so the same socket can join a fresh room (previously stuck 'already in a room' forever).

Gate (all green)

  • npm run typecheck clean · npm run test 175/175 (162 → +13 registry) · npm run build clean
  • Extended smoke:relay: existing 8 steps + graceful-close → peer-disconnected; ghost (autoPong:false) swept by heartbeat (close 1006, game notified, slot freed); same-code rejoin → cast round-trips — the couch-playtest failure mode, over live sockets
  • Two-client co-op, live (repo-pinned Playwright MCP against npm run dev, real Chromium × 2 tabs): join KDSJRR → spellbook → QuickMath solve → freeze cast crossed the wire (lastCastPower, freeze cue) → phone tab closed → game shows red "● phone disconnected" (typed path) → same-code rejoin → "● phone linked" restored (F-16) → Trivia solve → illuminate cast landed (darkZonePresent:false) → relay killed under both clients → phone renders "Connection lost / Rejoin CUMSAF" (F-07) and Planet shows "● connection lost"; a fresh Lobby boot against the dead relay shows "Relay unreachable" (F-17 boot path)

Notes for review

  • "Client cases" in the audit's Vitest gate are covered by the live two-client drive + smoke rather than unit tests: both net clients are thin wrappers over browser WebSocket, and the repo convention (CLAUDE.md) keeps browser-API/framework classes out of Vitest. Same substitution style Phase 0 used.
  • Implementation order within the PR: the protocol commit (audit item 5) landed before the phone/game UI items (3–4) so the typed peer-disconnected handling was written once, not twice. All six items are in.
  • PR #23/#24 ordering (audit coordination note): still unanswered. Phase 1's files (server/*, net/client.ts ×2, Lobby.ts, Planet.ts create-block, protocol.ts, App.tsx) barely overlap the m12 generator branches (Hub.ts/testBridge.ts/planets/); whichever lands second should rebase — expected conflicts: none to trivial.
  • Deliberately NOT done (audit's own list): no reconnect framework — manual rejoin post-F-07/F-08 is the design; F-15 (solo indicator) is Phase 2; F-28/F-29 (test typecheck, hook arms for server/) are Phase 4.

🤖 Generated with Claude Code

ksdisch and others added 8 commits July 10, 2026 22:40
A phone that sleeps on cellular never FINs; without a heartbeat its
socket stays OPEN, room.phone stays claimed, and the documented manual
rejoin gets 'room already has a phone' until kernel TCP times out.
Standard isAlive/pong tracking + a periodic terminate() sweep; the
existing 'close' cleanup then frees the phone slot or deletes the room
(also bounding the rooms map against ghost games, F-26-adjacent).
RELAY_HEARTBEAT_MS env override lets the smoke test fast-forward the
sweep instead of waiting out the 30s default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h net clients (F-05, F-06)

Lobby and Planet subscribed to the shared net client every create() and
never unsubscribed: Planet's stale handlers re-cast every phone solve
N+1 times (stacked banners + corrupted solveTimings telemetry), and
Lobby's stale handler threw on destroyed Text objects — which, because
the fan-out shared one try/catch and Lobby registered first, silently
dropped that message for every later handler (the '● phone
disconnected' indicator could never fire). Scenes now keep the
unsubscribe and release it on SHUTDOWN; both clients isolate each
handler in its own try/catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… role fields (F-18, F-35, F-36)

Peer departure was overloaded onto {type:'error'} and sniffed by
msg.message.includes('phone') on the game side, while the phone treated
a transient 'game disconnected' like any fatal relay error. The server
now sends {type:'peer-disconnected', peer} from its close handler —
server-originated, so no relayForward rule (the relay still never reads
state). Planet flips its link indicator on the typed message; the phone
shows a purpose-written notice and routes to fresh code entry (rooms
die with their game, so the old code is dead by definition).

Also per the audit: game-ready/availablePowers had no producer or
consumer anywhere (F-35), and the create/join role fields were never
read by the server — roles are implied by the handshake (F-36, the
'drop' arm of validate-or-drop). Both removed; protocol + server + both
clients + fixtures in this one commit per the boundary rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e-tap rejoin (F-07)

The phone never learned its socket died: no 'close' listener existed
(the 'error' listener is removed once open) and send() silently no-oped
when the socket wasn't OPEN — so a player returning to a backgrounded
tab could solve a puzzle, see the big 'Cast!', and mint stardust while
the laptop received nothing, indefinitely.

PhoneNetClient now surfaces 'close' via onClose (guarded to fire only
after a successful open and never for a deliberate close/reconnect) and
send() returns whether the frame reached an OPEN socket. The App flips
to a 'disconnected' phase that remembers the room code — the relay
preserves the room, so rejoining is one tap — and onSolved routes a
dead-socket solve there instead of faking cast-feedback or minting
stardust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… link indicator (F-16, F-17)

GameNetClient logged socket death to the console and nothing else:
relay down at boot left the Lobby on 'Connecting to relay…' forever,
and a mid-planet drop kept the indicator green while send() no-oped.
New single-consumer onClose surface (the active scene owns the link UI,
cleared on SHUTDOWN like the message unsubscribe): Lobby flips its
status line, Planet flips the indicator to '● connection lost'. A
rejoining phone now also turns the indicator back green (F-16) — the
red '● phone disconnected' used to stick even though the theme was
re-announced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…+ Vitest (F-21)

Room lifecycle (create/join/leave, membership, code minting) moves out
of the socket layer into server/roomRegistry.ts — generic over the
member handle, no ws import, no game state — mirroring how relayForward
already isolates the forwarding policy. server.ts keeps only parse →
registry/allowlist → send. 13 colocated cases cover create/join/
occupied/not-found/close cleanup and the post-sweep contract (phone
slot freed → same-code rejoin; game gone → room deleted, count
bounded) — the exact handler where both P0 crashes lived untested.

Behavioral fix that fell out: a phone orphaned by its game leaving used
to stay flagged 'already in a room' on that socket forever; the
registry releases it with the room, so it can join a fresh code.

Error strings and log lines are byte-identical to before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ouch scenario

Three new live-socket steps (the Phase 1 gate's named scenario): a
graceful phone close reaches the game as the dedicated
peer-disconnected message; a ghost phone (autoPong:false — never
answers pings, like a locked phone on cellular) is terminated by the
heartbeat sweep with abnormal close 1006, the game is notified, and the
slot frees; a fresh phone then rejoins the SAME room code and a cast
round-trips. RELAY_HEARTBEAT_MS=300 fast-forwards the sweep so the
whole scenario adds ~a second instead of a 30s wait.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ksdisch
ksdisch merged commit ea5bb75 into main Jul 11, 2026
@ksdisch
ksdisch deleted the fix/phase1-coop-lifecycle branch July 29, 2026 01:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant