fix: repair chess multiplayer and build the missing tournament engine
Four independent defects each broke competitive chess on their own, and they
compounded. Verified against live production (Supabase, the Swiss API and the
CapRover deployment), not inferred from code alone. Full diagnosis in FIX_PLAN.md.
A. Match completion was rejected for every decisive game.
The client sends result 'win'|'loss'|'draw'; game.php's $validResults did not
contain 'win' or 'loss', so every won or lost game got a 400. That list was
also wrong in its own right — timeout_white, abandon_white, resign_white,
checkmate_* and bot_* are not in the public.match_result enum. And
handleComplete ignored the write's return value, so a Postgres rejection was
invisible and the endpoint still reported success. Consequence: no rating, no
coins, no XP, matches stuck in_progress forever, tournament results lost, and
the winner shown a fabricated +12.
The client now reports only *how* the game ended. The server derives the
winner from the final position (the mated side is the side to move), from who
resigned, or from whose clock expired, validates against the real enum, and
verifies the write landed. A losing player can no longer report a win.
B. Both players could be assigned White — "playing against yourself".
`playerColor = color || 'w'` defaulted both players to White whenever a caller
supplied no colour, and the poll only ingests the opponent's move while it is
*not* your turn, so two Whites never saw each other and each played a private
board. The async correction also wrote gameState.playerColor while the clock
closures still read a stale local copy.
Colour now has one source of truth. game.php returns my_color computed
server-side; the board stays inert until the server confirms it. Fixed the
callers that passed no colour (refresh recovery, group invites — which also
lacked mode:'live' and so started a bot game) and stopped lobby.js guessing
from isHost.
C. Finished matches were never released. unmountGame never tore down the match
session, so localStorage kept the recovery key; combined with A, every app
start dragged players back into a dead game with no colour, straight into B.
D. Hand-offs were destroyed. matchmaking.php deleted all of a player's queue
rows including one already matched and carrying a match_id, so the waiting
player never learned their match and their opponent sat alone. Tournament
create-or-join did a read-then-insert with no lock, so both paired players
could create their own match. Match ids are now derived from
(tournament, round, board): both clients compute the same id and the primary
key decides, so exactly one match exists per pairing.
E. There was no tournament engine at all. Nothing created a Swiss tournament,
consumed auto_start, generated pairings, advanced a round or computed
standings. api/swiss.php sent no Authorization header, so every call to the
external pairing service returned 401 and each getter swallowed it and
returned an empty array — which is why standings and pairings have always
rendered blank.
includes/tournament-engine.php is a native Dutch-system Swiss engine with no
external dependency: backtracking pairing that never repeats an opponent,
FIDE colour allocation, byes, Buchholz cut-1 / Buchholz / Sonneborn-Berger
tiebreaks, automatic round advance and final placings. api/tournament-admin.php
creates and starts tournaments; api/cron.php is the scheduler that acts on
auto_start and forfeits no-shows.
Also fixed
- Server authority: turn ownership, move-count monotonicity, FEN transition
sanity and a server-side clock, so a client can no longer overwrite the
position or report its own remaining time.
- Disconnect detection was dead: match-live marked the opponent active on every
poll, and match-session's abandon branch was unreachable behind the disconnect
branch's latch. Heartbeats are now recorded per player in game_state.
- Draw acceptance required no offer from the opponent — a way out of a lost game.
- Removed the in-process HTTP call back into our own Apache for tournament
reporting (deadlock risk, and it lost results silently when it failed).
- Three time controls in the picker (blitz_5_5, rapid_20_0, classical_45_45) are
not in the time_control enum; choosing one created no match.
- Auth did an upstream GoTrue call plus a ban query on every request, including
the 2s poll. Now verifies HS256 locally when the secret is set, otherwise
memoises briefly.
- curl_close() removed repo-wide: deprecated in 8.5, where its notice lands in
the middle of every JSON body. Dockerfile now turns display_errors off.
Tests: tests/run.sh — 5 suites against a disposable local Postgres carrying the
production schema. Covers 2-100 player tournaments, 167 real master-game move
transitions (no legal move is ever rejected), the pairing race, JWT verification,
and a full two-client game and tournament over HTTP.
Co-Authored-By:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
FIX_PLAN.md
0 → 100644
api/cron.php
0 → 100644
api/tournament-admin.php
0 → 100644
includes/chess.php
0 → 100644
tests/README.md
0 → 100644
tests/authstub.php
0 → 100644
tests/engine_shim.php
0 → 100644
tests/gen_fens.mjs
0 → 100644
tests/pgshim.php
0 → 100644
tests/real_games.json
0 → 100644
tests/router.php
0 → 100644
tests/run.sh
0 → 100755
tests/schema.sql
0 → 100644
tests/test_auth.php
0 → 100644
tests/test_e2e.mjs
0 → 100644
tests/test_integration.php
0 → 100644
tests/test_replay.php
0 → 100644
tests/test_swiss.php
0 → 100644
Please register or sign in to comment