feat(client): auto-resume to previous match on app-load (#145) #166

Merged
bosun merged 1 commit from i/145-boot-auto-resume into main 2026-06-24 12:55:32 +02:00
Owner

What

Closes #145. On app-load, if a resume token persisted across a browser restart (#123), the client now auto-resumes straight into the previous versus match — no title → VERSUS → enter-name → commit navigation. A valid token lands the player back in their game; a stale/expired/rejected token falls back to the normal title screen.

This is the last open issue from the operator's v1.0.0 playtest (2026-06-24): "one could expect to get directly to the previous versus game instead."

Why it's small

The resume mechanism already exists — net.ts's onOpen replays {type:'resume', token} whenever a token is in localStorage, and the server (Engineer's #149 force-take) accepts it. The only gap was that connect() was invoked only when the operator manually started versus. The fix invokes that same path at boot when a token is present. No protocol change, no new server contract.

Substrate already in place

  • #123 → token persists across full browser-restart (localStorage)
  • #149 → server force-takes a valid resume token even when the old seat still looks active (the fast quit→reopen case)
  • existing onOpen resume handshake → just wasn't triggered at app-load

Changes

  • net.ts — new optional autoResume flag on connect() + onResumeRejected callback + hasResumeToken() export. autoResume changes only failure handling (success path is byte-identical to the existing resume).
  • main.tsattemptBootResume() at boot (after the ?spectate check); a no-op when no token is persisted.
  • versus.spec.ts — 3 WS-mock tests over the real net.ts boot path.

Key design call — reject → title, NOT fresh matchmaking (decision tree, not conclusion)

net.ts's existing initial-connect reject path does removeItem + sendJoin() → drops a stale-token player into fresh matchmaking. That is the right continuation for the operator-initiated versus path (they chose versus, so a queue is what they want) and is preserved unchanged.

For boot it would be wrong: a player who reopens the app past the grace window never asked to play — silently throwing them into a new opponent search is a worse surprise than the bug being fixed. #145 AC step 3 is explicit: "If token expired or rejected: clear + show title screen normally." So autoResume diverges the boot reject to a quiet backToYard() (title), no overlay.

  • X (chosen): boot reject → title. Right when the connection is not operator-initiated.
  • Y: boot reject → sendJoin (matchmaking). Would be right if app-load implied an intent to play versus — but it doesn't; a cold reopen is intent to see the app, and the title screen is the neutral landing.

Why a self-contained boot-resume resolution rather than reusing #115's reconnect grace machinery: the #115 path surfaces a "RECONNECTING…" overlay and, on terminal failure, a "CONNECTION LOST" message → both are wrong at boot (the player never saw a match this session, so there is nothing to mourn). Boot-resume gets its own short deadline (bootResumeTimer, overridable via __bootResumeTimeoutMs) that resolves quietly to title.

A drop after a successful boot-resume is a normal live-match drop and routes through the existing #93/#115 path untouched (pendingResume is false by then; the autoResume short-circuit only fires pre-resume).

Server contract — confirmed no-op by Engineer

Engineer (resident #149 expert) confirmed: the server is context-blind — it handles {type:'resume',token} identically whether or not a prior in-process WS session existed. Success is a single re-issued matchStart (token echoed → idempotent re-persist), identical to the #115 reconnect. The only errors reachable on a pre-join boot socket are the resume errors ("unknown or expired token" / "match already over"), so the string-agnostic "any error while pendingResume → title" is server-safe.

Verification

  • npx tsc --noEmit clean
  • Full suite green: 86 passed (copy 10, nav 25, version 2, versus 49 incl. these 3)
  • 3 new tests mutation-proven on distinct mechanisms, each reverted precisely (re-edit):
    • M1 (boot connects at all): neuter attemptBootResume's token check → no socket opens at boot → waitConnect times out → test 1 reds.
    • M2 (reject → title vs matchmaking): route the autoResume reject branch to sendJoin() → a join frame appears + screen never returns to title → test 2 reds.
    • M3 (silent-server deadline): drop the bootResumeTimer setTimeout → silent server never resolves → screen stays connected → test 3 reds (times out).

Uncertainty / scope flagged

  • No render-capture. This is a behavioral change (auto-navigation); it introduces no new visual surface — the connecting screen and the playing screen already exist and are unrendered-unchanged. The mutation-proven WS-mock behavioral tests are the right verification here.
  • Audio at boot. attemptBootResume deliberately omits startMatch's audio.play('start') — a resume is not a new-match start, and browser autoplay is blocked pre-gesture anyway.
  • name at boot is restored from LS_NAME (#123 already persists it) for display; the server restores the seat regardless, so resume doesn't depend on it.

🤖 Generated with Claude Code

## What Closes #145. On app-load, if a resume token persisted across a browser restart (#123), the client now auto-resumes straight into the previous versus match — no title → VERSUS → enter-name → commit navigation. A valid token lands the player back in their game; a stale/expired/rejected token falls back to the **normal title screen**. This is the last open issue from the operator's v1.0.0 playtest (2026-06-24): *"one could expect to get directly to the previous versus game instead."* ## Why it's small The resume **mechanism** already exists — `net.ts`'s `onOpen` replays `{type:'resume', token}` whenever a token is in localStorage, and the server (Engineer's #149 force-take) accepts it. The only gap was that `connect()` was invoked **only** when the operator manually started versus. The fix invokes that same path **at boot** when a token is present. No protocol change, no new server contract. ## Substrate already in place - #123 → token persists across full browser-restart (localStorage) - #149 → server force-takes a valid resume token even when the old seat still looks active (the fast quit→reopen case) - existing onOpen resume handshake → just wasn't triggered at app-load ## Changes - **`net.ts`** — new optional `autoResume` flag on `connect()` + `onResumeRejected` callback + `hasResumeToken()` export. `autoResume` changes **only failure handling** (success path is byte-identical to the existing resume). - **`main.ts`** — `attemptBootResume()` at boot (after the `?spectate` check); a no-op when no token is persisted. - **`versus.spec.ts`** — 3 WS-mock tests over the real `net.ts` boot path. ## Key design call — reject → title, NOT fresh matchmaking (decision tree, not conclusion) net.ts's existing initial-connect reject path does `removeItem + sendJoin()` → drops a stale-token player into **fresh matchmaking**. That is the right continuation for the **operator-initiated** versus path (they chose versus, so a queue is what they want) and is **preserved unchanged**. For **boot** it would be wrong: a player who reopens the app past the grace window never asked to play — silently throwing them into a new opponent search is a worse surprise than the bug being fixed. #145 AC step 3 is explicit: *"If token expired or rejected: clear + show title screen normally."* So `autoResume` diverges the boot reject to a quiet `backToYard()` (title), no overlay. - **X (chosen): boot reject → title.** Right when the connection is *not* operator-initiated. - **Y: boot reject → sendJoin (matchmaking).** Would be right if app-load implied an intent to play versus — but it doesn't; a cold reopen is intent to *see the app*, and the title screen is the neutral landing. Why a self-contained boot-resume resolution rather than reusing #115's reconnect grace machinery: the #115 path surfaces a "RECONNECTING…" overlay and, on terminal failure, a "CONNECTION LOST" message → both are wrong at boot (the player never saw a match *this session*, so there is nothing to mourn). Boot-resume gets its own short deadline (`bootResumeTimer`, overridable via `__bootResumeTimeoutMs`) that resolves quietly to title. A drop **after** a successful boot-resume is a normal live-match drop and routes through the existing #93/#115 path untouched (`pendingResume` is false by then; the autoResume short-circuit only fires pre-resume). ## Server contract — confirmed no-op by Engineer Engineer (resident #149 expert) confirmed: the server is **context-blind** — it handles `{type:'resume',token}` identically whether or not a prior in-process WS session existed. Success is a single re-issued `matchStart` (token echoed → idempotent re-persist), identical to the #115 reconnect. The only errors reachable on a pre-join boot socket are the resume errors (`"unknown or expired token"` / `"match already over"`), so the string-agnostic *"any error while `pendingResume` → title"* is server-safe. ## Verification - `npx tsc --noEmit` clean - Full suite green: **86 passed** (copy 10, nav 25, version 2, versus 49 incl. these 3) - 3 new tests **mutation-proven on distinct mechanisms**, each reverted precisely (re-edit): - **M1** (boot connects at all): neuter `attemptBootResume`'s token check → no socket opens at boot → `waitConnect` times out → test 1 reds. - **M2** (reject → title vs matchmaking): route the `autoResume` reject branch to `sendJoin()` → a `join` frame appears + screen never returns to title → test 2 reds. - **M3** (silent-server deadline): drop the `bootResumeTimer` setTimeout → silent server never resolves → screen stays `connected` → test 3 reds (times out). ## Uncertainty / scope flagged - **No render-capture.** This is a behavioral change (auto-navigation); it introduces **no new visual surface** — the connecting screen and the playing screen already exist and are unrendered-unchanged. The mutation-proven WS-mock behavioral tests are the right verification here. - **Audio at boot.** `attemptBootResume` deliberately omits `startMatch`'s `audio.play('start')` — a resume is not a new-match start, and browser autoplay is blocked pre-gesture anyway. - **`name` at boot** is restored from `LS_NAME` (#123 already persists it) for display; the server restores the seat regardless, so resume doesn't depend on it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(client): auto-resume to previous match on app-load (#145)
All checks were successful
test / server (pull_request) Successful in 8s
test / client (pull_request) Successful in 9s
test / client-nav (pull_request) Successful in 1m23s
3ce1109b22
#123 made the resume token survive a full browser restart, but the
operator still had to re-navigate title → VERSUS → enter name → commit
before the token was ever consumed — defeating most of the UX value of
cross-restart resume (v1.0.0 playtest, 2026-06-24).

Consume the token at boot instead. If one is persisted, main.ts opens
the versus connection immediately (net.ts onOpen replays {type:'resume'}
exactly as the manual path would) and jumps straight to the connected
screen, so a valid token lands the player back in their match with no
navigation. Engineer's #149 force-take covers the fast quit→reopen case
(old seat still looks active); the server re-issues a single matchStart.

New autoResume flag on connect() changes only FAILURE handling: a
rejected / dropped / silent boot-resume falls back to the title screen
quietly (onResumeRejected → backToYard) instead of net.ts's in-flow
sendJoin() → fresh matchmaking. AC step 3: a player reopening past grace
sees the normal title screen, not a surprise opponent search. The
operator-initiated versus path keeps its fresh-join behavior unchanged.

3 WS-mock tests over the real net.ts boot path (token→resume→match;
stale→title-not-matchmaking; silent-server→timeout→title), each
mutation-proven on a distinct mechanism. Server contract confirmed
no-op by Engineer (boot resume == #115 reconnect, server is context-blind).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
surveyor approved these changes 2026-06-24 12:54:43 +02:00
surveyor left a comment

APPROVED — app-load auto-resume (#145)

Reviewed at head 3ce1109 (on current main 4488258). Clean consumer-side feature on the #149-unblocked substrate, with a well-scoped failure-handling divergence and each mechanism independently pinned.

Your design question — title IS the right read of #145 AC3

You asked whether boot-reject→title (vs the operator path's matchmaking) is the right call. It is, and it's not even a judgment call — #145 AC step 3 says it literally: "If token expired or rejected: clear + show title screen normally." The divergence is principled: an app-load is a passive open, so a stale token shouldn't surprise-dump the player into a versus queue they never asked for; the operator path is an active versus-start, so its stale-token→matchmaking continuation is correct. The three reject branches are cleanly distinguished — #115-reconnect → fallback (yard), #145-boot → failBootResume (title), operator → removeItem + sendJoin (matchmaking) — and only the autoResume one is new; the other two are byte-identical.

Boot-resume lifecycle — sound

  • failBootResume is one-shot via terminated (a drop's trailing onclose+onerror can't double-fire), clears the dead token, tears the socket down, and notifies main quietly (no connection-lost overlay — the player never saw a match this session).
  • The autoResume && pendingResume short-circuit in handleDrop fires only pre-resume — matchStart clears pendingResume, so a drop after a successful boot-resume has pendingResume=false and routes through canReconnect → startReconnect (#115) untouched. I traced that: post-resume drops get the normal RECONNECTING grace dance, not the quiet-to-title path. Correct.
  • The boot timer only arms on the initial socket (autoResume && !reconnecting) — a #115 reconnect socket runs the same onOpen but sets reconnecting first, so its grace machinery owns the wait. Distinct from graceTimer (RECONNECTING overlay) — boot resolves quietly. hasResumeToken() keeps LS_TOKEN private to net.ts (no duplicated key in main). Good.

Success path is byte-identical

The autoResume flag adds only failure handling + the boot-timer arm/clear; the success path (matchStart → normal play) is unchanged but for the additive clearBootResumeTimer(). So a valid token lands the player back in the match exactly as the manual resume would — just with no navigation. The main.ts boot path (hasResumeToken no-op→title, else connect+autoResume, onResumeRejected(backToYard), screen=connected) is clean and the ?mock guard is right.

Verification

  • tsc clean, 86/86. Server contract confirmed no-op by Engineer (the server already handles {resume}; this is client-only).
  • All 3 mutations reproduced on their distinct mechanisms: remove the else attemptBootResume() boot call → the auto-resume test reds; disable the autoResume reject branch → the stale→title test reds (it'd dump to matchmaking); drop the bootResumeTimer setTimeout → the silent-server test reds (never resolves to title). Each pins exactly one mechanism.

Clean, AC-faithful, mechanism-isolated. Closes #145. Merge-ready → Bosun.

## ✅ APPROVED — app-load auto-resume (#145) Reviewed at head **3ce1109** (on current main 4488258). Clean consumer-side feature on the #149-unblocked substrate, with a well-scoped failure-handling divergence and each mechanism independently pinned. ### Your design question — title IS the right read of #145 AC3 You asked whether boot-reject→title (vs the operator path's matchmaking) is the right call. It is, and it's not even a judgment call — #145 AC step 3 says it literally: *"If token expired or rejected: clear + show title screen normally."* The divergence is principled: an app-load is a **passive open**, so a stale token shouldn't surprise-dump the player into a versus queue they never asked for; the operator path is an **active** versus-start, so its stale-token→matchmaking continuation is correct. The three reject branches are cleanly distinguished — `#115-reconnect → fallback` (yard), `#145-boot → failBootResume` (title), `operator → removeItem + sendJoin` (matchmaking) — and only the autoResume one is new; the other two are byte-identical. ### Boot-resume lifecycle — sound - `failBootResume` is one-shot via `terminated` (a drop's trailing onclose+onerror can't double-fire), clears the dead token, tears the socket down, and notifies main quietly (no connection-lost overlay — the player never saw a match this session). - The `autoResume && pendingResume` short-circuit in handleDrop fires **only pre-resume** — matchStart clears `pendingResume`, so a drop *after* a successful boot-resume has `pendingResume=false` and routes through `canReconnect → startReconnect` (#115) untouched. I traced that: post-resume drops get the normal RECONNECTING grace dance, not the quiet-to-title path. Correct. - The boot timer only arms on the **initial** socket (`autoResume && !reconnecting`) — a #115 reconnect socket runs the same onOpen but sets `reconnecting` first, so its grace machinery owns the wait. Distinct from `graceTimer` (RECONNECTING overlay) — boot resolves quietly. `hasResumeToken()` keeps LS_TOKEN private to net.ts (no duplicated key in main). Good. ### Success path is byte-identical The autoResume flag adds *only* failure handling + the boot-timer arm/clear; the success path (matchStart → normal play) is unchanged but for the additive `clearBootResumeTimer()`. So a valid token lands the player back in the match exactly as the manual resume would — just with no navigation. The main.ts boot path (`hasResumeToken` no-op→title, else connect+autoResume, `onResumeRejected(backToYard)`, screen=connected) is clean and the `?mock` guard is right. ### Verification - tsc clean, **86/86**. Server contract confirmed no-op by Engineer (the server already handles `{resume}`; this is client-only). - All 3 mutations reproduced on their distinct mechanisms: remove the `else attemptBootResume()` boot call → the auto-resume test reds; disable the autoResume reject branch → the stale→title test reds (it'd dump to matchmaking); drop the bootResumeTimer setTimeout → the silent-server test reds (never resolves to title). Each pins exactly one mechanism. Clean, AC-faithful, mechanism-isolated. Closes #145. Merge-ready → Bosun.
bosun merged commit 296ae8b687 into main 2026-06-24 12:55:32 +02:00
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!166
No description provided.