Skip to content

Settle no-shows as forfeits; auto-stop dead-engine gauntlets#15

Merged
vikramarun merged 5 commits into
mainfrom
claude/chessescrow-contract-review-043eaf
Jul 12, 2026
Merged

Settle no-shows as forfeits; auto-stop dead-engine gauntlets#15
vikramarun merged 5 commits into
mainfrom
claude/chessescrow-contract-review-043eaf

Conversation

@vikramarun

Copy link
Copy Markdown
Owner

What & why

Two protections for wagered games where a player's engine never actually plays. Both came out of a review of whether ChessEscrow covers every game mode — the contract does; these close server-side gaps in how it's driven. They share the room.rs / GameOutcome plumbing, so they land together.

1. No-show forfeit (settle at reap instead of waiting 24h)

Previously, a wagered game that spawned but never started (one side never readied within the 60s reap window) was reaped with winner: None and did not settle the escrow — both stakes sat locked behind the contract's 24h claimTimeout, and the player who showed up was never told they'd won.

Now the reap settles on-chain immediately:

  • One side readied, the other never showedsettleGame(winner = the side that showed up). The no-show loses its stake to the player who showed up.
  • Neither readiedwinner = None → both refunded (draw).

Adds GameEndReason::Forfeit, and lets finish() settle a never-started game (empty PGN, ply 0, so it's never rated). Side effect: a dispatched-but-dead tournament bot now forfeits its pairing instead of drawing it — consistent with the existing dispatch-time forfeit.

2. Gauntlet no-move guard (stop a dead engine from bleeding the stake)

If a gauntlet seat loses a game without ever making a move (dead/offline/hung engine, including a no-show forfeit), its session auto-stops so it can't lose the stake game after game. A genuine loss (moves were played) does not stop it.

  • Detected in record_outcome using the ply count (White plays iff ply ≥ 1, Black iff ply ≥ 2 — the same invariant behind the existing rated = ply ≥ 2 rule).
  • Server-side backstop: queue_join rejects (409) any re-queue against a stopped session, so the guard holds even if a buggy client keeps trying.
  • Client shows a panel explaining the stop and offering a clean restart.

Confirmed design decisions

  • A pre-dispatch agent vanish still refunds as a draw (abort_started_game unchanged) — at that point nobody has connected, so there's no confirmed "player who showed up".
  • Forfeit wins are raked like any other win (they settle through the normal settleGame path).

Tests

  • +2 room unit tests — reap forfeit-winner selection (readied side wins; neither → refund).
  • +2 matchmaking tests — auto-stop on a no-move loss (both colors) vs. keep running after a contested loss.
  • Full Rust suite green (34 server tests + game-engine/ledger/protocol/persistence).
  • Web validated by inspection only — node_modules isn't installed in this worktree, and the auto-stop UI isn't reachable in an isolated preview (needs the live staked flow).

Risk / rollout

  • Money-path change. The escrow is opened on-chain before the room spawns, so settleGame always has an open game to settle; settlement goes through the existing durable outbox → settleGame path (idempotent — a double-settle reverts AlreadySettled and is treated as success).
  • No contract change; no migration.

🤖 Generated with Claude Code

Two protections for wagered games where a player's engine never actually
plays. They share the room.rs / GameOutcome plumbing, so they land together.

No-show forfeit: a wagered game that spawns but never starts (one side
never readies within the 60s reap window) now settles on-chain at the reap
instead of leaving both stakes locked behind the contract's 24h
claimTimeout. The side that showed up and readied wins the stake via
settleGame; if neither readied, both are refunded (draw). Adds a Forfeit
game-end reason and lets finish() settle a never-started game (empty PGN,
ply 0, so it is never rated). As a side effect a dispatched-but-dead
tournament bot now forfeits its pairing instead of drawing it, matching the
existing dispatch-time forfeit.

Gauntlet no-move guard: if a gauntlet seat loses a game without ever making
a move (dead/offline/hung engine, including a no-show forfeit), its session
auto-stops so a broken engine can't bleed the stake game after game. A real
loss (moves were played) does not stop it. Enforced server-side --
queue_join rejects (409) any re-queue against a stopped session, so the
guard holds even if the client keeps trying -- with a client panel that
explains the stop and offers a clean restart. GameOutcome now carries the
ply count so record_outcome can tell a contested loss from a no-move
forfeit (White plays iff ply>=1, Black iff ply>=2).

Both confirmed as intended: a pre-dispatch agent vanish still refunds as a
draw, and forfeit wins are raked like any other win.

Tests: +2 room unit tests (reap forfeit-winner selection) and +2
matchmaking tests (auto-stop on a no-move loss vs. keep running after a
contested loss). Full Rust suite green.

Co-Authored-By: Claude Opus 4.8 <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 12, 2026 4:45pm

Request Review

Addresses four issues found reviewing the prior commit.

Reap no longer confiscates a present player's stake. reap_forfeit_winner
now forfeits only to a side that is still connected AND readied while its
opponent is genuinely absent (not connected); every ambiguous case -- nobody
ready, both present but one slow to ready, or a side that readied then
dropped its connection -- refunds both. Previously it decided real money from
the monotonic ready flags alone, so a disconnected player could win a present
opponent's stake, and a present-but-slow player lost theirs.

Gauntlet stop backstop is now enforced at match time, not just at queue
entry. queue_join re-checks BOTH sides' session status right before opening a
wagered game, so a stopped session (owner-stopped or auto-stopped after a
no-move forfeit) can no longer be dragged into a new game via a ticket that
was already waiting in the queue -- and the entry-gate/auto-stop race can't
slip an extra staked game through either.

Web: on a 409 the gauntlet loop now reflects the stop locally instead of
depending on a stats refetch that could fail and strand the UI on a spinner.

Reap unit tests rewritten for the presence-aware signature (present+ready vs.
absent forfeits; ambiguous cases refund). Full Rust suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the second review pass over the reap fix.

Finding 1 (a regression from the reap fix): a gauntlet engine that connects
but never readies (hung at init) reaped as a draw, and the loss-only
auto-stop never fired -- so the session looped forever on refunded draws,
bleeding oracle gas and never progressing. GameOutcome now carries each
seat's "showed up" (connected AND readied) bit; record_outcome stops a
session on a never-started draw only when ITS OWN seat no-showed, sparing an
opponent that did show up (both made 0 moves, so plies alone can't tell them
apart). Zero-move losses still auto-stop as before.

Finding 4: added a test that parks a waiting ticket, stops its session, then
pairs an opponent and asserts no game is created -- guarding the pair-time
backstop against a future regression that drops the check.

Finding 2 left as-is by decision (an engine that plays a move then hangs is
out of the "never makes a move" spec) with the misleading "hung engine"
comment corrected to state the zero-move-only scope.

Full Rust suite green (36 server tests, +2 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues from the third review pass.

Idempotent finish_and_enqueue (money + data integrity). A wagered game
aborted at seat dispatch can be resurrected by a late-connecting agent whose
WebSocket keeps the room alive long enough to reap; the reap would then
overwrite the 'aborted' row and enqueue a SECOND settlement -- a phantom
win/loss in player_stats, or a stake confiscation if the abort's inline
refund had failed (the abort refund has no retry; the reap's forfeit does).
The UPDATE now finalizes only a non-terminal game (status NOT IN
finished/aborted) and skips the settlement_outbox enqueue when no row
changed, so a reap can never override an abort.

showed_up now keys on latched readiness (wready/bready) instead of
occupied && ready. A gauntlet seat that readied then briefly dropped its
socket before the game started is no longer mistaken for a no-show and
auto-stopped; only a seat that NEVER readied (dead/hung at init) stops.

Full Rust suite green (36 server tests). The persistence idempotency guard
is not covered by a local unit test (needs DATABASE_URL) -- the SQL runs
under CI Postgres.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings in PR #13 (web UX pass) + PR #14 (leaderboard/claim). No conflicts:
the shared files (gauntlet page, persistence, matchmaking) merge cleanly in
non-overlapping regions. Verified: Rust workspace builds and the full suite
passes on the merged tree.
@vikramarun
vikramarun merged commit b0f6c01 into main Jul 12, 2026
3 checks passed
@vikramarun
vikramarun deleted the claude/chessescrow-contract-review-043eaf branch July 12, 2026 16:47
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