Skip to content

Game replay + settlement confirmation#18

Merged
vikramarun merged 4 commits into
mainfrom
claude/game-replay-settlement
Jul 13, 2026
Merged

Game replay + settlement confirmation#18
vikramarun merged 4 commits into
mainfrom
claude/game-replay-settlement

Conversation

@vikramarun

Copy link
Copy Markdown
Owner

Two product gaps closed on top of the recent board/UX work: reviewing finished games, and confirming a wager actually settled.

Backend

  • GET /games/{id} — full game detail (wallets, stake, result, time control, settlement_status, result_hash) + the move list. Public and read-rate-limited; axum routes the static /games/live ahead of this dynamic /games/{id}.
  • persistence game_detail(id) + game_moves(id) queries.

Replay (finished games)

  • /game/[id] fetches the game once and branches: a finished game renders a navigable replay (GameReplay), an in-progress one the live spectator (LiveSpectator, extracted from the old page).
  • Replay reconstructs the position / last-move / check at every ply via chessops and offers a clickable move list, ←/→/Home/End keyboard nav, transport buttons (⏮ ◀ ▶ ⏭), and per-move clocks — reusing the same Chessboard/PlayerBar/board helpers as the live views.
  • Reachable from the profile game-history "Review" link and the live spectator's game-over "Review game" action.

Settlement confirmation

  • After a wagered game ends, SeatGame polls GET /games/{id} until settlement is settled/failed and resolves the banner to "Settled on-chain ✓ — you won X USDC" / draw / loss, or a "delayed — recover on-chain" state with an escrow link (per the chosen scope: status + guidance + link, no in-app recover button).
  • The replay of a settled wagered game shows the same outcome + the RATED tag + stake.
  • Factored a shared contractUrl(chainId, address) explorer helper (used by the settlement link and SiteFooter, replacing its inline map).

Verification

tsc + production next build clean, server tests green. Verified live in-browser against real casual and settled-wager games:

  • replay navigation (click a move, keyboard ←/→/Home/End, transport buttons), per-move clocks, check/last-move/material cues;
  • a settled-wager replay showing both wallets, "Stake 1 USDC · RATED", and "Black wins · checkmate — Settled on-chain ✓";
  • profile-history "Review" link + opponent link as two distinct, correct anchors (switched away from a whole-row click after finding the nested-link event-bubbling was fragile).

🤖 Generated with Claude Code

Backend:
- GET /games/{id}: full game detail (wallets, stake, result, time control,
  settlement_status, result_hash) + the move list. Public + read-rate-limited;
  axum routes the static /games/live ahead of this dynamic /games/{id}.
- persistence: game_detail(id) + game_moves(id) queries.

Replay (finished games):
- /game/[id] fetches the game once and branches: a finished game renders a
  navigable replay (GameReplay), an in-progress one the live spectator
  (LiveSpectator, extracted from the old page). Replay reconstructs the
  position/last-move/check at every ply via chessops and offers a clickable move
  list, ←/→/Home/End keyboard nav, transport buttons, and per-move clocks —
  reusing the same Chessboard/PlayerBar/board helpers as the live views.
- Reachable from the profile game-history "Review" link and the live
  spectator's game-over "Review game" action.

Settlement confirmation:
- After a wagered game ends, SeatGame polls GET /games/{id} until settlement is
  settled/failed and resolves the banner to "Settled on-chain ✓ — you won X
  USDC" / draw / loss, or a "delayed — recover on-chain" state with an escrow
  link. The replay of a settled wagered game shows the same outcome.
- Factored a shared contractUrl(chainId, address) explorer helper (used by the
  settlement link and SiteFooter, replacing its inline map).

Verified: tsc + production next build clean; server tests green; the endpoint
checked against real casual + settled-wager games; replay navigation (click,
keyboard, transport), per-move clocks, check/last-move/material, and the
settled-wager banner all confirmed live in-browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openchess Ready Ready Preview, Comment Jul 13, 2026 7:21pm

Request Review

Review of the replay+settlement PR found no correctness bugs; these address the
low-severity items worth acting on:

- Money clarity: the wager UI quoted the gross pot credit (2*stake - rake =
  "1.99 USDC") as what you "win"/"net", but that includes your own returned
  stake — the real profit is stake - rake ("+0.99"). Replaced payoutForStake
  with a profitForStake helper and reworded the stake modal, the pre-game
  callout, and the settled banner to the honest profit figure (win +0.99 /
  draw 0 / lose -stake). Removed the now-unused payoutForStake.
- Replay keyboard nav no longer hijacks Arrow/Home/End while a text input is
  focused.
- GET /games/{id} doc-comment corrected: it returns any game's (already-public)
  detail, not only finished games.

Left as-is (cosmetic, documented): material() shows a phantom captured pawn
after a promotion — a correct fix needs move history.

Verified: tsc + production next build clean; the stake modal shows
"Win +0.99 USDC" for a 1 USDC stake in the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vikramarun

Copy link
Copy Markdown
Owner Author

Reviewed (multi-angle) — no correctness bugs, 3 low-severity fixes applied

Ran a rigorous correctness review over the new endpoint, replay indexing, settlement-poll lifecycle, routing, DB types, and fetch/unmount races. All verified correct — no high/medium bugs. Also spot-checked live: /games/live still routes ahead of /games/{id}, a bad UUID → 400 (not a panic), a missing game → 404.

Applied the low-severity items worth acting on (commit e249a14):

  1. Money clarity (the one I'd insist on). The wager UI quoted the gross pot credit (2*stake − rake = "1.99 USDC") as what you "win"/"net", but that includes your own returned stake — real profit is stake − rake. Switched to a profitForStake helper and reworded the stake modal, pre-game callout, and settled banner to the honest figure: win +0.99 / draw 0 / lose −stake. Verified in-browser: the modal now reads "Win +0.99 USDC" for a 1 USDC stake. Removed the now-unused payoutForStake.
  2. Replay keyboard nav no longer hijacks Arrow/Home/End while a text input is focused.
  3. GET /games/{id} doc-comment corrected (it returns any game's already-public detail, not only finished games).

Left as-is (cosmetic, documented): material() shows a phantom captured pawn after a promotion — a correct fix needs move history and isn't worth the complexity.

tsc + production next build clean; server tests green.

Multi-angle review of the replay+settlement PR found no high/med correctness
bugs in the happy paths; these address the real items it surfaced:

- Aborted wagered games (escrow_open_failed / seat_dispatch_failed) route to
  replay but abort_game never clears settlement_status, so GameReplay showed a
  permanent, false "Settling on-chain…" plus the raw internal reason code
  ("Game over · escrow_open_failed"). Now an aborted game renders "Game didn't
  start — your stake was refunded" with no settlement line and no internal code.
- Payout copy consistency: the SiteFooter "Fees & payouts" panel and the home
  "How wagering works" step 3 still used the gross "both stakes minus the fee"
  framing this PR replaced everywhere else (the ~2x overstatement escrow.ts now
  warns against). Reworded both to the profit framing ("take your opponent's
  stake, less the fee; your own stake comes back").
- LiveSpectator: a game already finished at fetch time (DB race / reaped room)
  dead-ended on "not live" with no way forward — added a "Load replay" button.
- Defensive: fetchGame validates the response shape (status string + moves
  array) before use; GameReplay pushes a null last-move highlight for any move
  it couldn't apply, so a (hypothetical) bad UCI can't point at an unplayed move.

Flagged as follow-ups (not in this PR): extract the LiveSpectator/SeatGame
spectator reducer into a shared hook (pre-existing duplication, risky to refactor
here); add an oracle-verified badge to replay (needs persisting server_sig).

Verified: tsc + production next build clean; in-browser, an aborted staked game
shows "Game didn't start — refunded" (no settling banner / raw code), a settled
240-move game still replays with "Settled on-chain ✓", and footer + step 3 read
the profit framing (no gross copy remains).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vikramarun

Copy link
Copy Markdown
Owner Author

Reviewed (multi-angle: correctness + complexity/consistency)

Traced the endpoint, replay indexing, live-vs-replay routing, settlement poll, DB types, and every fee/payout string. No high/med correctness bug in the happy paths — verified: /games/live still routes ahead of /games/{id}, bad UUID → 400, replay frame arrays are total+1 and bounds-safe, the settlement poll is leak-free/StrictMode-safe. Fixed the real items it surfaced (commit 28c293f):

Correctness

  • Aborted wagered games showed a false permanent "Settling on-chain…" + a raw internal reason. abort_game (escrow_open_failed / seat_dispatch_failed) never clears settlement_status, so a staked aborted game replayed as "Game over · escrow_open_failed" + "Settling…". Now: "Game didn't start — your stake was refunded", no settlement line, no internal code. Verified in-browser with a crafted aborted game.
  • LiveSpectator dead-end: a game already finished at fetch time (DB race / reaped room) stranded on "not live" — added a "Load replay" button.
  • Defensive: fetchGame validates response shape before use; replay pushes a null last-move highlight for any move it couldn't apply.

Consistency (the one I'd insist on)

  • The SiteFooter "Fees & payouts" panel and the home "How wagering works" step 3 still used the gross "both stakes minus the fee" framing this PR replaced everywhere else — the ~2× overstatement escrow.ts now explicitly warns against. Reworded both to profit framing. Verified: no gross copy remains.

Flagged, not fixed (follow-ups):

  • Extract the LiveSpectator/SeatGame spectator reducer into a shared useSpectatorBoard hook — real ~45-line duplication (pre-existing, carried from the old page), but a risky money-path refactor to do inside this PR.
  • Add an oracle-verified badge to replay (the permanent view lacks the "✓ signed by oracle" the live views show) — needs persisting server_sig, which isn't stored today.

tsc + production next build clean; regression-checked that a settled 240-move game still replays with "Settled on-chain ✓".

Both review follow-ups:

1. Dedupe the spectator-frame reducer. LiveSpectator and SeatGame each carried
   their own copy of the board state + the game_start/opponent_moved/clock_sync/
   game_over switch (the legality-guarded move-application logic — a real
   board-corruption risk if the copies drift). Extracted it into
   lib/useSpectatorBoard: one hook owns fen/moves/lastUci/inCheck/clock/result/
   verified + the reducer; each view keeps its own socket, status, and terminal
   flag (their lifecycles genuinely differ — SeatGame also drives an engine
   seat), feeding frames in via applyFrame and getting game_over back through a
   callback.

2. Oracle-verified badge on the replay. The permanent finished-game view was the
   only place lacking the "✓ signed by oracle" trust signal the live/seat views
   show. The oracle signature over result_hash is now signed once and persisted
   (migration 0010 + finish_and_enqueue), exposed via GET /games/{id}, and
   GameReplay verifies it (recover signer == oracle) to show the same badge.

Verified: full Rust suite + production next build green; live-spectated a game
through the new hook (moves/clocks/turn/last-move all update), and its replay
shows "✓ Verified — signed by oracle 0x7099…79c8" with result_sig persisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vikramarun

Copy link
Copy Markdown
Owner Author

Both review follow-ups landed (commit af43637) — reviewed clean

1. Deduped the spectator-frame reducer into lib/useSpectatorBoard.ts. LiveSpectator and SeatGame had two copies of the legality-guarded move-application switch (a real board-corruption risk if they drift). One hook now owns the board state + reducer; each view keeps its own socket/status/terminal flag (their lifecycles genuinely differ — SeatGame also drives an engine seat + settlement poll). Net −143/+155, so it removed more than it added.

2. Oracle-verified badge on the replay. The permanent finished-game view was the only place lacking the "✓ signed by oracle" trust signal. The signature over result_hash is now signed once and persisted (migration 0010 + finish_and_enqueue), exposed via GET /games/{id}, and GameReplay verifies it (recover signer == oracle) to show the badge.

Verified end-to-end in-browser:

  • Live-spectated a game through the new hook — moves, clocks, active-turn, last-move all update (no regression).
  • Its replay shows "✓ Verified — signed by oracle 0x7099…79c8", with result_sig confirmed persisted in the DB.

Reviewed (correctness pass over the hook closures/deps/StrictMode, the sign_result-before-persist ordering, the migration, and all finish_and_enqueue callers): no bugs found. Key safety check: sign_result is a local EIP-191 sign (not an RPC), so signing before the durable settlement write can't hang it.

Full Rust suite + production next build green.

@vikramarun
vikramarun merged commit edfcf26 into main Jul 13, 2026
3 checks passed
@vikramarun
vikramarun deleted the claude/game-replay-settlement branch July 13, 2026 23:09
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.

2 participants