Commit 33fd4d40 authored by Mahmoud Aglan's avatar Mahmoud Aglan

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: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent c1a1af60
......@@ -21,3 +21,10 @@ node_modules/
storage/theme-cache.json
public/uploads/theme/*
!public/uploads/theme/.gitkeep
# Local test-suite artefacts (tests/run.sh creates and removes these)
tests/.pgdata/
tests/.pg.log
tests/.auth.log
tests/.server.log
tests/rpc.log
......@@ -6,6 +6,11 @@ RUN apt-get update && apt-get install -y libpq-dev libcurl4-openssl-dev \
RUN echo "upload_max_filesize = 10M\npost_max_size = 12M\nmax_file_uploads = 5" > /usr/local/etc/php/conf.d/uploads.ini
# Never print PHP notices into a response body. The php:8.3 image ships no active
# php.ini, so display_errors defaults to on; a single deprecation notice then
# lands in the middle of a JSON payload and every client fails to parse it.
RUN printf "display_errors = Off\nlog_errors = On\nerror_log = /dev/stderr\nerror_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT\n" > /usr/local/etc/php/conf.d/errors.ini
RUN a2enmod rewrite headers
ENV APACHE_DOCUMENT_ROOT=/var/www/html
......
# EL3AB — Multiplayer & Tournament Repair Plan
Investigation date: 2026-09-01. Target: world championship, Saturday 2026-09-05.
Every root cause below was verified against **live production** (Supabase REST, the
Swiss API, the Stockfish API and the CapRover deployment), not inferred from code alone.
---
## Executive summary
The multiplayer layer is not "mostly working with a few bugs". Four independent
defects each individually break a competitive chess match, and they compound:
| # | Defect | Effect |
|---|--------|--------|
| A | Match completion is rejected by the server for **every decisive game** | No results, no rating, matches never end |
| B | Both players can be assigned **White** | "Playing against yourself" |
| C | Finished matches are never cleared from recovery | Players dragged back into dead games |
| E | **Nothing in the codebase starts a tournament** | Tournaments cannot run at all |
A and E are the two that make Saturday impossible today.
---
## A — Match completion is broken for every decisive game (P0)
`public/js/modules/chess/scenes/game.js:1019` sends:
```js
net.post('game.php', { action: 'complete', result, ... }) // result = 'win' | 'loss' | 'draw'
```
`api/game.php:245` rejects it:
```php
$validResults = ['white_wins','black_wins','draw','stalemate','timeout_white', ...];
if ($result && !in_array($result, $validResults, true)) jsonError('Invalid result value');
```
`'win'` and `'loss'` are not in that list. **Every won or lost game is rejected with a 400.**
Only draws survive — which is why the live DB has 78 completed matches but 14 permanently
`in_progress` and 61 `abandoned`.
Second, independent fault in the same path: the list itself is wrong. The live
`public.match_result` enum is
```
white_wins, black_wins, draw, white_timeout, black_timeout, white_resign, black_resign,
white_abandon, black_abandon, stalemate, insufficient_material, threefold_repetition,
fifty_moves, mutual_draw, aborted
```
so `timeout_white`, `abandon_white`, `resign_white`, `checkmate_*` and `bot_*` would all be
rejected by Postgres even if they got past the PHP guard.
Third: `handleComplete` ignores the return value of `$sdb->update(...)`, so a Postgres
rejection is invisible and the endpoint still reports success.
**Consequences:** no Elo, no coins, no XP, the match row stays `in_progress` forever, and
the tournament result is never recorded. The winner's client falls into `.catch()` and shows
a *fabricated* `+12` rating change, so the failure is invisible to players and to us.
**Fix:** a single canonical result vocabulary. Client sends outcome + reason; the server maps
`(outcome, reason, playerColour)` to the real enum, validates against the real enum, and
**checks the write succeeded** before reporting success.
---
## B — Colour identity: "playing against yourself" (P0)
Four faults stack:
1. `game.js:25``let playerColor = color || 'w'`. When the colour param is missing, **both
players default to White.**
2. `game.js:461` — the poll only applies the opponent's move when
`!gameState.isPlayerTurn && data.move_count > lastKnownMoveCount`. Two players who both
believe they are White both have `isPlayerTurn === true`, so **neither ever ingests the
other's move.** Each plays a private board. This is literally the reported symptom.
3. The async colour correction at `game.js:319` writes `gameState.playerColor`, but the
closures at `game.js:202` (`clock.onTick`) and `game.js:215` (`clock.onFlag`) still read
the stale local `playerColor` — so clocks display on the wrong side and a flag awards the
game to the wrong player.
4. Entry points that supply **no colour at all**:
- `public/js/engine.js:69,78` — refresh recovery
- `public/js/modules/social/scenes/group-chat.js:219` — also missing `mode:'live'`, so a
group invite starts a **bot game** instead of the multiplayer game.
Plus a server-side cause inside tournaments — see D2.
**Fix:** the match row is the only source of truth for colour. Resolve it before the board
becomes interactive; never guess; delete the shadowed local variable.
---
## C — Finished matches are never released (P0)
`unmountGame()` (`game.js:1046`) clears the clock, the board and `gameState` — but never
destroys the match session. `localStorage.el3ab_active_match` is only removed inside
`match-session.destroy()`, which is never called on a normal exit.
Combined with A (matches never reach `completed`), `engine.js:55` finds a "still running"
match on every app start and pushes the player back into it — with **no colour**, which
lands them straight in B.
**Fix:** destroy the session on unmount and on game end; clear the recovery key.
---
## D — Matchmaking and pairing hand-offs (P1)
**D1 — the waiting player's hand-off can be deleted.**
`api/matchmaking.php:63` unconditionally deletes *all* queue rows for the caller:
```php
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
```
If that row has already been claimed (`status='matched'` with a `match_id`), the waiting
player never learns their match id. Their opponent sits in a game alone. The 60-second
re-queue in `queue.js` and `leaveQueue()` on scene unmount both hit this path.
**D2 — two matches for one tournament pairing.**
`api/tournament-match.php:74` checks for an existing match and then inserts. There is no
lock and no unique constraint, so when both paired players tap at the same moment **both
create a match** and each ends up alone on their own board — the tournament flavour of "playing
against yourself".
---
## E — There is no tournament engine (P0 — this is what blocks Saturday)
Verified by exhaustive grep across the repo:
- **Nothing creates a Swiss tournament.** The newest live tournament
(`بطولة هواية للشطرنج`, `status='registration'`, `auto_start=true`) has
`swiss_api_tournament_id = NULL`.
- **Nothing consumes `auto_start`.** No code moves `registration → in_progress`.
- **Nothing generates pairings.** `el3ab_tournament_rounds.pairings` is written by no code
path in this repository.
- **Nothing advances a round.** `handleReportResult` appends to a results array and stops.
- **Nothing computes standings.**
And the external pairing service is unreachable to us:
```
GET https://swissapi.caprover.al-arcade.com/api/v1/tournaments/<id>
→ 401 {"error":"UNAUTHORIZED","message":"Missing or invalid authorization header"}
```
`api/swiss.php:50` sends only `Content-Type: application/json`**no `Authorization`
header on any call**. Every Swiss API request the platform has ever made returned 401, and
each getter swallows the error and returns an empty array. That is the whole of "tournaments
are not correct": standings, pairings, rounds and brackets have always rendered empty.
**Decision: bring the tournament engine in-house.**
The Swiss service's credentials are not in the repo, its source is not on this machine, and
guessing them is not acceptable. It is also an avoidable single point of failure on
championship day. The `el3ab_tournaments` / `el3ab_tournament_rounds` /
`tournament_registrations` tables are already El3AB-owned and correctly shaped
(`pairings jsonb`, `results jsonb`), so El3AB gets a native Dutch-system Swiss engine with
no external dependency. The external API stays supported but strictly optional.
---
## F — Liveness: disconnects are never detected (P1)
- `core/match-live.js:19` calls `session.markOpponentActive()` on **every poll**, whether or
not the opponent did anything — so `lastOpponentActivity` never ages and disconnect and
abandon can never fire.
- `core/match-session.js:206` — even without that, the 60 s abandon branch is unreachable:
the 30 s branch sets `opponentDisconnected = true`, and the abandon branch requires
`!opponentDisconnected`.
Net effect: a player whose opponent walks away waits forever. In a timed tournament round
that stalls the whole round.
Related: every 2 s poll calls `enforce_turn_timeout` with a hard-coded 120 s
(`api/game.php:60`). A player legitimately thinking for over two minutes in a rapid or
classical game can be timed out — or replaced by a bot.
---
## G — The server has no authority over the game (P1, tournament integrity)
`handleGameMove` (`api/game.php:103`) checks only that the caller is one of the two players.
It does **not** check whose turn it is, does not validate the move, and accepts
client-reported clocks. Any player can overwrite `current_fen` at any moment. Last write
wins, so two near-simultaneous moves silently lose one.
---
## H — Scale (P1)
`requireAuth()` performs an upstream `GET /auth/v1/user` **plus** a `profiles` ban lookup on
every single API call — including the 2-second poll every client runs during a game. A
64-player tournament is ~64 polls/2 s × 3 upstream calls ≈ 96 upstream requests/second,
purely for auth. The JWT secret is available, so this should be a local signature check.
---
## Execution order
1. **A** — result vocabulary end-to-end + verified writes.
2. **B/C** — colour as a single source of truth, session teardown, recovery hygiene.
3. **G** — turn ownership + move-count monotonicity + FEN sanity on the server.
4. **D** — matchmaking hand-off and the pairing race.
5. **E** — native Swiss engine: create, start, pair, advance, standings, tiebreaks, byes,
forfeits, plus a scheduler tick.
6. **F** — real disconnect/abandon detection driven by server-side activity.
7. **H** — local JWT verification.
8. Verification: scripted two-client match, scripted full tournament, then deploy.
......@@ -97,7 +97,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$uploadResult = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
// Public URL with anon key for browser access
......
......@@ -26,7 +26,6 @@ curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 35);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$err = json_decode($response, true);
......
......@@ -140,7 +140,6 @@ function handleGuest(): void {
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$res = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 400 || !$res) {
jsonError('Guest login failed', 500);
......@@ -273,7 +272,6 @@ function handleDeleteAccount(array $input): void {
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400) {
jsonError('Failed to delete account', 500);
......
......@@ -81,7 +81,6 @@ curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
jsonError('Storage connection failed: ' . $curlError, 500);
......
......@@ -66,7 +66,6 @@ function handleQueue(string $userId, array $input): void {
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
......
......@@ -28,7 +28,6 @@ function handleList(): void {
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
jsonError('Failed to fetch bots', 502);
......@@ -56,7 +55,6 @@ function handleMove(array $input): void {
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$err = json_decode($response, true);
......
......@@ -77,7 +77,6 @@ function getDailyChallenges($db, string $userId): void {
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$matchesJson = curl_exec($ch);
curl_close($ch);
$todayMatches = json_decode($matchesJson, true) ?: [];
// Count stats
......
<?php
/**
* Scheduled maintenance tick.
*
* Nothing in the platform previously acted on `auto_start`, so a tournament with
* a start time simply never started. This endpoint is the missing scheduler:
* call it every minute (CapRover cron, an external pinger, or a container-side
* loop) and it will:
*
* - start tournaments whose start time has passed and that have enough players
* - advance rounds whose games have all finished
* - forfeit no-shows so a round cannot stall forever
* - close out matches whose clock has run out while nobody was watching
*
* Every step is idempotent, so running it twice in the same minute is harmless.
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
// Authenticated by a shared secret rather than a user token, so a scheduler can
// call it with no session. When no secret is configured the endpoint refuses to
// run rather than defaulting to open.
$secret = $_GET['secret'] ?? ($_SERVER['HTTP_X_CRON_SECRET'] ?? '');
if (CRON_SECRET === '' || !hash_equals(CRON_SECRET, (string)$secret)) {
http_response_code(403);
echo json_encode(['error' => 'forbidden']);
exit;
}
$db = supabaseService();
$report = ['started' => [], 'advanced' => [], 'forfeited' => [], 'flagged' => [], 'errors' => []];
// How long a player has to appear for their game before losing it by no-show.
$NO_SHOW_SECONDS = (int)(getenv('TOURNAMENT_NO_SHOW_SECONDS') ?: 600);
// ---------------------------------------------------------------------------
// 1. Start tournaments that are due
// ---------------------------------------------------------------------------
$due = $db->get('el3ab_tournaments', [
'status' => 'in.(' . TOURNAMENT_STATUS_REGISTRATION . ',' . TOURNAMENT_STATUS_DRAFT . ')',
'auto_start' => 'is.true',
'select' => 'id,name,starts_at,min_players',
'limit' => 50,
]);
if (is_array($due) && !isset($due['error'])) {
foreach ($due as $t) {
$startsAt = $t['starts_at'] ?? null;
if (!$startsAt || strtotime($startsAt) > time()) continue;
$res = tournamentStart($db, $t['id']);
if ($res['ok']) {
$report['started'][] = ['id' => $t['id'], 'name' => $t['name']];
} else {
$report['errors'][] = ['id' => $t['id'], 'stage' => 'start', 'error' => $res['error']];
}
}
}
// ---------------------------------------------------------------------------
// 2. Running tournaments: flag dead clocks, forfeit no-shows, advance rounds
// ---------------------------------------------------------------------------
$running = $db->get('el3ab_tournaments', [
'status' => 'eq.' . TOURNAMENT_STATUS_IN_PROGRESS,
'select' => '*',
'limit' => 50,
]);
if (is_array($running) && !isset($running['error'])) {
foreach ($running as $t) {
$roundNumber = (int)($t['current_round'] ?? 0);
if ($roundNumber < 1) continue;
$round = tournamentRound($db, $t['id'], $roundNumber);
if (!$round || $round['status'] !== ROUND_STATUS_IN_PROGRESS) continue;
$roundStarted = strtotime($round['started_at'] ?? 'now');
foreach ($round['pairings'] as $p) {
if (!empty($p['is_bye'])) continue;
if (($p['result'] ?? null) !== null) continue;
$matchId = tournamentMatchIdFor($t['id'], $roundNumber, (int)($p['board'] ?? 1));
$rows = $db->get('matches', [
'id' => 'eq.' . $matchId,
'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,'
. 'white_time_remaining_ms,black_time_remaining_ms,started_at,updated_at,'
. 'tournament_id,tournament_round,metadata',
'limit' => 1,
]);
$match = (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null;
// Nobody opened the board at all: forfeit once the grace period is up.
if (!$match) {
if (time() - $roundStarted > $NO_SHOW_SECONDS) {
// Neither player showed. Forfeit the pairing to nobody so the
// round can close; both players score zero.
if (tournamentForfeitPairing($db, $t['id'], $roundNumber, $p['pairing_id'] ?? '', $p['white_id'])) {
$report['forfeited'][] = ['tournament' => $t['id'], 'board' => $p['board'] ?? null, 'reason' => 'no_show'];
}
}
continue;
}
if ($match['status'] !== 'in_progress') continue;
// Somebody's clock ran out while both clients were gone.
$sideToMove = chessSideToMove($match['current_fen'] ?? null);
if ($sideToMove === null) continue;
$metadata = is_array($match['metadata'] ?? null) ? $match['metadata'] : (json_decode((string)($match['metadata'] ?? '{}'), true) ?: []);
$anchor = $metadata['last_move_at'] ?? $match['started_at'] ?? null;
if ((int)($match['move_count'] ?? 0) < 1 || !$anchor) {
// Never started: no-show forfeit for whoever was due to move.
if (time() - $roundStarted > $NO_SHOW_SECONDS) {
$absent = $sideToMove === 'w' ? $match['white_player_id'] : $match['black_player_id'];
if (tournamentForfeitPairing($db, $t['id'], $roundNumber, $p['pairing_id'] ?? '', $absent)) {
$report['forfeited'][] = ['tournament' => $t['id'], 'board' => $p['board'] ?? null, 'reason' => 'no_first_move'];
}
}
continue;
}
$elapsedMs = max(0, (time() - strtotime($anchor)) * 1000);
$col = $sideToMove === 'w' ? 'white_time_remaining_ms' : 'black_time_remaining_ms';
if ((int)($match[$col] ?? 0) - $elapsedMs > 0) continue;
$derived = chessDeriveResult('timeout', $match['current_fen'] ?? null, $sideToMove);
if (cronFinaliseMatch($db, $match, $derived, $col)) {
$report['flagged'][] = ['match' => $match['id'], 'result' => $derived['result']];
}
}
$adv = tournamentMaybeAdvance($db, $t['id']);
if (!empty($adv['advanced']) || !empty($adv['completed'])) {
$report['advanced'][] = ['id' => $t['id'], 'result' => $adv];
}
}
}
echo json_encode($report, JSON_UNESCAPED_UNICODE);
/**
* Close a match from the scheduler.
*
* A trimmed copy of game.php's finaliseMatch(): the same conditional write and
* the same follow-up work, without needing a request-scoped user.
*/
function cronFinaliseMatch($db, array $match, array $derived, string $zeroClockColumn): bool {
if (!in_array($derived['result'], MATCH_RESULT_ENUM, true)) return false;
$metadata = is_array($match['metadata'] ?? null) ? $match['metadata'] : (json_decode((string)($match['metadata'] ?? '{}'), true) ?: []);
$metadata['end_reason'] = $derived['reason'];
$metadata['winner'] = $derived['winner'];
$metadata['ended_by'] = 'system';
$written = $db->update('matches', [
'status' => 'completed',
'result' => $derived['result'],
'completed_at' => gmdate('c'),
'updated_at' => gmdate('c'),
'metadata' => $metadata,
$zeroClockColumn => 0,
], ['id' => 'eq.' . $match['id'], 'status' => 'neq.completed']);
if (isset($written['error']) || empty($written)) return false;
$winnerId = chessWinnerId($derived['winner'], $match['white_player_id'], $match['black_player_id']);
supabaseRpc('complete_match', [
'p_game_key' => $match['game_key'] ?? 'chess',
'p_match_id' => $match['id'],
'p_winners' => $winnerId ? [$winnerId] : [],
'p_reason' => $derived['reason'],
]);
if (!empty($match['tournament_id'])) {
tournamentRecordMatchResult($db, $match, $derived);
}
return true;
}
......@@ -101,7 +101,6 @@ function handleQueue(string $userId, array $input): void {
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
......
......@@ -7,6 +7,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -55,53 +57,194 @@ function handleGet($db, string $userId, array $input): void {
$match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null;
if (!$match) jsonError('Match not found', 404);
// Server-side turn timeout enforcement (chess uses time control, fallback 120s)
// Only the two players see the live position. Anyone else would be able to
// read a tournament game's FEN while it is still being played.
$isPlayer = ($match['white_player_id'] === $userId || $match['black_player_id'] === $userId);
if (!$isPlayer) jsonError('Not authorized for this match', 403);
// `my_color` is the single authoritative answer to "which side am I?".
// Every client reads this instead of guessing, which is what allowed two
// players to both believe they were White.
$match['my_color'] = ($match['white_player_id'] === $userId) ? 'w' : 'b';
$match['opponent_id'] = ($match['white_player_id'] === $userId)
? $match['black_player_id']
: $match['white_player_id'];
if ($match['status'] === 'in_progress') {
$timeout = supabaseRpc('enforce_turn_timeout', [
'p_game_key' => 'chess',
'p_match_id' => $matchId,
'p_timeout_seconds' => 120
]);
if (!empty($timeout['timeout'])) {
$match['_turn_timed_out'] = true;
$match['_timeout_player'] = $timeout['player_index'] ?? null;
$match['_replace_with_bot'] = $timeout['replace_with_bot'] ?? false;
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
if (!empty($matches) && !isset($matches['error'])) {
$match = $matches[0];
$match['_turn_timed_out'] = true;
$match['_timeout_player'] = $timeout['player_index'] ?? null;
$match['_replace_with_bot'] = $timeout['replace_with_bot'] ?? false;
}
}
// Charge the clock of whoever is on move before reporting state, so a
// player who closed the tab still runs out of time like anyone else.
$match = chargeClockAndMaybeFlag($sdb, $match);
}
jsonResponse($match);
}
/**
* Deduct elapsed wall-clock time from the side to move and, if their clock has
* run out, end the match on time. The clock lives on the server so a client
* cannot simply report that it still has time left.
*/
function chargeClockAndMaybeFlag($sdb, array $match): array {
$sideToMove = chessSideToMove($match['current_fen'] ?? null);
if ($sideToMove === null) return $match;
$metadata = normaliseJsonObject($match['metadata'] ?? null);
$elapsedMs = matchElapsedSinceLastMoveMs($match, $metadata);
if ($elapsedMs <= 0) return $match;
$col = $sideToMove === 'w' ? 'white_time_remaining_ms' : 'black_time_remaining_ms';
$remaining = (int)($match[$col] ?? 0) - $elapsedMs;
if ($remaining > 0) {
// Not out of time yet. Report the live figure without writing — writing
// here would reset updated_at and the player would never flag.
$match[$col] = $remaining;
return $match;
}
$derived = chessDeriveResult('timeout', $match['current_fen'] ?? null, $sideToMove);
$ok = finaliseMatch($sdb, $match, $derived, $sideToMove === 'w' ? $match['white_player_id'] : $match['black_player_id']);
if (!$ok) return $match;
$match[$col] = 0;
$match['status'] = 'completed';
$match['result'] = $derived['result'];
$match['completed_at'] = gmdate('c');
return $match;
}
/**
* The one place a match is marked finished. Writes the result, verifies the
* write actually landed, then runs rewards and tournament reporting exactly once.
*
* Returns false when the match could not be closed (already closed, or the
* database rejected the value) so callers can report a real error instead of a
* silent success — the failure mode that hid this bug for months.
*/
function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = null, array $extra = []): bool {
$matchId = $match['id'];
if (!in_array($derived['result'], MATCH_RESULT_ENUM, true)) {
error_log("[el3ab] refusing to write invalid match_result '{$derived['result']}' for match {$matchId}");
return false;
}
$metadata = normaliseJsonObject($match['metadata'] ?? null);
$metadata['end_reason'] = $derived['reason'];
$metadata['winner'] = $derived['winner']; // 'white' | 'black' | null
if ($actorId) $metadata['ended_by'] = $actorId;
$update = array_merge([
'status' => 'completed',
'result' => $derived['result'],
'completed_at' => gmdate('c'),
'updated_at' => gmdate('c'),
'metadata' => $metadata,
], $extra);
// Conditional on the match still being open: two players reporting the same
// finish at the same moment must not both run the reward path.
$written = $sdb->update('matches', $update, [
'id' => 'eq.' . $matchId,
'status' => 'neq.completed',
]);
if (isset($written['error'])) {
error_log("[el3ab] match {$matchId} finalise failed: " . json_encode($written));
return false;
}
if (empty($written)) {
return false; // Somebody else closed it first; not an error, just not ours.
}
$winnerId = chessWinnerId($derived['winner'], $match['white_player_id'], $match['black_player_id']);
$winners = $winnerId ? [$winnerId] : [];
supabaseRpc('complete_match', [
'p_game_key' => $match['game_key'] ?? 'chess',
'p_match_id' => $matchId,
'p_winners' => $winners,
'p_reason' => $derived['reason'],
]);
mpLog($matchId, $match['game_key'] ?? 'chess', $actorId ?? ($match['white_player_id'] ?? ''), 'match_completed', [
'result' => $derived['result'],
'reason' => $derived['reason'],
]);
// Tournament bookkeeping runs in-process. The previous implementation made
// an HTTP call back into this same Apache instance, which is both a
// deadlock risk and silently lost every result when it failed.
if (!empty($match['tournament_id'])) {
tournamentRecordMatchResult($sdb, $match, $derived);
}
return true;
}
/**
* Milliseconds the side to move has been thinking.
*
* Anchored on metadata.last_move_at, which only a real move writes. Before the
* first move nobody is charged, so a player who is slow to load their board
* does not lose time for it.
*/
function matchElapsedSinceLastMoveMs(array $match, array $metadata): int {
if ((int)($match['move_count'] ?? 0) < 1) return 0;
$anchor = $metadata['last_move_at'] ?? $match['started_at'] ?? null;
if (!$anchor) return 0;
$ts = strtotime($anchor);
if (!$ts) return 0;
return max(0, (time() - $ts) * 1000);
}
/** PostgREST hands jsonb back as an array, a string, or null depending on how it was written. */
function normaliseJsonObject($raw): array {
if (is_array($raw)) return $raw;
if (is_string($raw) && $raw !== '') {
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
function handleStart($db, string $userId, array $input): void {
$gameKey = $input['game_key'] ?? 'chess';
$mode = $input['mode'] ?? 'bot';
$timeControl = $input['time_control'] ?? 'rapid_10_0';
$validTimeControls = ['bullet_1_0','bullet_2_1','blitz_3_0','blitz_3_2','blitz_5_0','blitz_5_3','rapid_10_0','rapid_10_5','rapid_15_10','classical_30_0','classical_30_20','standard'];
if (!in_array($timeControl, $validTimeControls, true)) $timeControl = 'rapid_10_0';
// 'standard' and the classical_30_* values were never in the time_control
// enum, so those matches failed to insert. Normalise against the real one.
$timeControl = chessNormaliseTimeControl($input['time_control'] ?? null);
$opponentId = $input['opponent_id'] ?? null;
$botId = $input['bot_id'] ?? null;
$clock = chessTimeControlMs($timeControl);
$data = [
'game_key' => $gameKey,
'match_type' => $mode === 'bot' ? 'bot' : 'friendly',
'white_player_id' => $userId,
'black_player_id' => $opponentId,
'status' => 'in_progress',
'time_control' => $timeControl,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'initial_time_ms' => $clock['initial'],
'increment_ms' => $clock['increment'],
'white_time_remaining_ms' => $clock['initial'],
'black_time_remaining_ms' => $clock['initial'],
'starting_fen' => CHESS_START_FEN,
'current_fen' => CHESS_START_FEN,
'moves' => [],
'metadata' => ['mode' => $mode, 'bot_id' => $botId]
'move_count' => 0,
'bot_id' => $mode === 'bot' ? $botId : null,
'started_at' => gmdate('c'),
'metadata' => ['mode' => $mode, 'bot_id' => $botId],
];
$result = $db->insert('matches', $data);
// Service role: $userId comes from the verified token, so nothing here is
// caller-supplied, and RLS on `matches` must not silently drop the insert.
$result = supabaseService()->insert('matches', $data);
if (isset($result['error'])) jsonError($result['error']);
jsonResponse($result[0] ?? $result);
$match = $result[0] ?? $result;
$match['my_color'] = 'w';
jsonResponse($match);
}
function handleGameMove($db, string $userId, array $input): void {
......@@ -111,7 +254,13 @@ function handleGameMove($db, string $userId, array $input): void {
$sdb = supabaseService();
// Auth: verify caller is a player in this match
$match = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'white_player_id,black_player_id,status', 'limit' => 1]);
$match = $sdb->get('matches', [
'id' => 'eq.' . $matchId,
'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,'
. 'white_time_remaining_ms,black_time_remaining_ms,increment_ms,updated_at,started_at,'
. 'tournament_id,tournament_round,metadata',
'limit' => 1,
]);
$match = (is_array($match) && !empty($match) && !isset($match['error'])) ? $match[0] : null;
if (!$match) jsonError('Match not found', 404);
if ($match['white_player_id'] !== $userId && $match['black_player_id'] !== $userId) {
......@@ -119,16 +268,74 @@ function handleGameMove($db, string $userId, array $input): void {
}
if ($match['status'] !== 'in_progress') jsonError('Match not in progress');
$update = ['updated_at' => date('c')];
$myColor = ($match['white_player_id'] === $userId) ? 'w' : 'b';
$update = ['updated_at' => gmdate('c')];
$isBoardMove = !empty($input['fen']) || isset($input['move_count']);
if ($isBoardMove) {
// ---- Idempotency first ----------------------------------------------
// A move that already landed must be reported as a duplicate, not as an
// error. Mobile clients retry on a dropped connection, and by then the
// position has moved on — checking the turn first would answer "not your
// turn" for a move the player legitimately made and that succeeded.
$serverCount = (int)($match['move_count'] ?? 0);
if (isset($input['move_count']) && (int)$input['move_count'] <= $serverCount) {
jsonResponse(['success' => true, 'duplicate' => true, 'move_count' => $serverCount]);
}
// ---- Turn ownership -------------------------------------------------
// Without this check either player can overwrite the position at any
// time, which is exactly what makes two clients that both think they
// are White appear to be playing against themselves.
$sideToMove = chessSideToMove($match['current_fen'] ?? null);
if ($sideToMove !== null && $sideToMove !== $myColor) {
jsonError('Not your turn', 409);
}
// ---- Position sanity -------------------------------------------------
$newFen = $input['fen'] ?? null;
if ($newFen) {
[$ok, $why] = chessValidateTransition($match['current_fen'] ?? null, $newFen, $myColor);
if (!$ok) {
mpLog($matchId, 'chess', $userId, 'move_rejected', ['reason' => $why]);
jsonError('Illegal move rejected: ' . $why, 409);
}
$update['current_fen'] = $newFen;
}
$update['move_count'] = isset($input['move_count'])
? (int)$input['move_count']
: $serverCount + 1;
// ---- Server-authoritative clock --------------------------------------
// The mover's remaining time is computed from wall clock, not taken from
// the client. The increment is credited after the move, as in FIDE.
// Timing is anchored on metadata.last_move_at rather than updated_at,
// because unrelated writes (emotes, draw offers, heartbeats) touch
// updated_at and would otherwise hand the player free time.
$metadata = normaliseJsonObject($match['metadata'] ?? null);
$elapsedMs = matchElapsedSinceLastMoveMs($match, $metadata);
$myClockCol = $myColor === 'w' ? 'white_time_remaining_ms' : 'black_time_remaining_ms';
$remaining = (int)($match[$myClockCol] ?? 0) - $elapsedMs + (int)($match['increment_ms'] ?? 0);
if ($remaining <= 0) {
$derived = chessDeriveResult('timeout', $match['current_fen'] ?? null, $myColor);
finaliseMatch($sdb, $match, $derived, $userId, [$myClockCol => 0]);
jsonResponse(['success' => false, 'flagged' => true, 'result' => $derived['result']]);
}
$update[$myClockCol] = $remaining;
$metadata['last_move_at'] = gmdate('c');
$update['metadata'] = $metadata;
}
if (!empty($input['fen'])) $update['current_fen'] = $input['fen'];
if (!empty($input['move'])) {
$moves = is_string($input['move']) ? json_decode($input['move'], true) : $input['move'];
$update['moves'] = is_array($moves) ? $moves : [];
}
if (isset($input['move_count'])) $update['move_count'] = intval($input['move_count']);
if (isset($input['white_time_remaining_ms'])) $update['white_time_remaining_ms'] = intval($input['white_time_remaining_ms']);
if (isset($input['black_time_remaining_ms'])) $update['black_time_remaining_ms'] = intval($input['black_time_remaining_ms']);
// game_state: use atomic merge via DB function (prevents race conditions with draw offers, emotes)
$gameStatePatch = null;
......@@ -176,26 +383,38 @@ function handleResign($db, string $userId, array $input): void {
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'id,white_player_id,black_player_id,status', 'limit' => 1]);
$match = loadMatchForPlayer($sdb, $matchId, $userId);
$myColor = ($match['white_player_id'] === $userId) ? 'w' : 'b';
$derived = chessDeriveResult('resign', $match['current_fen'] ?? null, $myColor);
if (!finaliseMatch($sdb, $match, $derived, $userId)) {
jsonError('Could not record resignation — match may already be finished', 409);
}
jsonResponse(['result' => $derived['result'], 'reason' => 'resign', 'winner' => $derived['winner']]);
}
/**
* Fetch a match and assert the caller is one of its two players.
* Exits with a JSON error when it is not — every finishing action needs this,
* and each one previously hand-rolled it slightly differently.
*/
function loadMatchForPlayer($sdb, string $matchId, string $userId, bool $mustBeOpen = true): array {
$matches = $sdb->get('matches', [
'id' => 'eq.' . $matchId,
'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,'
. 'white_time_remaining_ms,black_time_remaining_ms,increment_ms,started_at,updated_at,'
. 'tournament_id,tournament_round,metadata,time_control,game_state,result',
'limit' => 1,
]);
$match = (is_array($matches) && !empty($matches) && !isset($matches['error'])) ? $matches[0] : null;
if (!$match) jsonError('Match not found', 404);
if ($match['status'] === 'completed') jsonError('Match already ended');
if ($match['white_player_id'] !== $userId && $match['black_player_id'] !== $userId) {
jsonError('Not authorized for this match', 403);
}
$isWhite = $match['white_player_id'] === $userId;
$result = $isWhite ? 'black_wins' : 'white_wins';
$sdb->update('matches', [
'status' => 'completed',
'result' => $result,
'completed_at' => date('c'),
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
mpLog($matchId, 'chess', $userId, 'resign', ['result' => $result]);
jsonResponse(['result' => $result]);
if ($mustBeOpen && $match['status'] === 'completed') jsonError('Match already ended', 409);
return $match;
}
function handleDraw($db, string $userId, array $input): void {
......@@ -203,99 +422,173 @@ function handleDraw($db, string $userId, array $input): void {
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'id,white_player_id,black_player_id,status', 'limit' => 1]);
$match = (is_array($matches) && !empty($matches) && !isset($matches['error'])) ? $matches[0] : null;
if (!$match) jsonError('Match not found', 404);
if ($match['status'] === 'completed') jsonError('Match already ended');
if ($match['white_player_id'] !== $userId && $match['black_player_id'] !== $userId) {
jsonError('Not authorized for this match', 403);
$match = loadMatchForPlayer($sdb, $matchId, $userId);
// A draw needs both players to have agreed. The offer lives in game_state;
// accepting without a standing offer from the *other* player used to end the
// game unilaterally, which is a way to escape a lost position.
$gameState = normaliseJsonObject($match['game_state'] ?? null);
$offeredBy = $gameState['draw_offer'] ?? null;
$force = !empty($input['auto']); // engine-detected draws (stalemate, 50-move, ...)
if (!$force && (!$offeredBy || $offeredBy === $userId)) {
jsonError('No draw offer from your opponent to accept', 409);
}
// Use merge_game_state to preserve heartbeat/connections data
supabaseRpc('merge_game_state', [
'p_table' => 'matches',
'p_match_id' => $matchId,
'p_patch' => ['draw_accepted' => true]
'p_patch' => ['draw_accepted' => true, 'draw_offer' => null],
]);
$sdb->update('matches', [
'status' => 'completed',
'result' => 'draw',
'completed_at' => date('c'),
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
$reason = $force ? ($input['reason'] ?? 'agreement') : 'agreement';
$derived = chessDeriveResult($reason, $match['current_fen'] ?? null, null);
// Anything reached through this endpoint is a draw by definition.
$derived['result'] = 'draw';
$derived['winner'] = null;
mpLog($matchId, 'chess', $userId, 'draw_accepted', []);
jsonResponse(['result' => 'draw']);
if (!finaliseMatch($sdb, $match, $derived, $userId)) {
jsonError('Could not record draw — match may already be finished', 409);
}
jsonResponse(['result' => 'draw', 'reason' => $derived['reason']]);
}
/**
* End a match.
*
* The client reports *how* the game ended, never *who won*. The server derives
* the winner from the final position, from who resigned, or from whose clock
* expired. That closes the hole where a losing player could report themselves
* the winner, and it removes the vocabulary mismatch that made this endpoint
* reject every decisive game with "Invalid result value".
*/
function handleComplete($db, string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
$result = $input['result'] ?? '';
if (!$matchId || $matchId === 'local') {
// Offline / local-only games have nothing to persist.
jsonResponse(['success' => true, 'local' => true]);
}
$fen = $input['fen'] ?? '';
$pgn = $input['pgn'] ?? '';
$winners = $input['winners'] ?? [];
$reason = $input['reason'] ?? 'normal';
if (!$matchId) jsonError('match_id required');
// Validate result against known enum values
$validResults = ['white_wins','black_wins','draw','stalemate','timeout_white','timeout_black',
'abandon_white','abandon_black','resign_white','resign_black','checkmate_white','checkmate_black',
'bot_win','bot_loss','bot_draw'];
if ($result && !in_array($result, $validResults, true)) {
jsonError('Invalid result value');
}
$sdb = supabaseService();
// Not loadMatchForPlayer(): a finished match is a normal outcome here, not an
// error. Both players report the end of the same game, and the second one to
// arrive must be told what the result was rather than getting a 409.
$match = loadMatchForPlayer($sdb, $matchId, $userId, false);
$myColor = ($match['white_player_id'] === $userId) ? 'w' : 'b';
if ($match['status'] === 'completed') {
$meta = normaliseJsonObject($match['metadata'] ?? null);
jsonResponse([
'success' => true,
'already_completed' => true,
'result' => $match['result'],
'reason' => $meta['end_reason'] ?? null,
'winner' => $meta['winner'] ?? null,
'my_color' => $myColor,
]);
}
// Auth: verify caller is a player in this match
$matchData = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'white_player_id,black_player_id,status', 'limit' => 1]);
$matchData = (is_array($matchData) && !empty($matchData) && !isset($matchData['error'])) ? $matchData[0] : null;
if (!$matchData) jsonError('Match not found', 404);
if ($matchData['white_player_id'] !== $userId && $matchData['black_player_id'] !== $userId) {
jsonError('Not authorized for this match', 403);
$reason = normaliseEndReason($input['reason'] ?? null, $input['result'] ?? null);
$finalFen = $fen ?: ($match['current_fen'] ?? null);
// For resignation and timeout the "actor" is the player who suffered it.
// A client only ever reports its own loss this way; a win claim carries no
// actor and is derived from the position instead.
$actorColor = null;
$claimedOutcome = $input['result'] ?? null; // 'win' | 'loss' | 'draw' (legacy clients)
if (in_array($reason, ['resign', 'timeout', 'abandon'], true)) {
$actorColor = ($claimedOutcome === 'win')
? ($myColor === 'w' ? 'b' : 'w') // opponent suffered it
: $myColor; // I suffered it
}
if ($matchData['status'] === 'completed') jsonError('Match already ended');
// Validate winners are actual participants (prevent coin exploit)
$validPlayers = [$matchData['white_player_id'], $matchData['black_player_id']];
if (!empty($winners)) {
foreach ($winners as $w) {
if (!in_array($w, $validPlayers, true)) {
jsonError('Invalid winner — not a match participant', 403);
}
$derived = chessDeriveResult($reason, $finalFen, $actorColor);
$extra = [];
if ($fen) $extra['current_fen'] = $fen;
if ($pgn) $extra['pgn'] = $pgn;
if (!finaliseMatch($sdb, $match, $derived, $userId, $extra)) {
// Someone else already closed it — return the settled result rather than
// an error, so both clients agree on the outcome.
$fresh = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'result,status,metadata', 'limit' => 1]);
$fresh = (is_array($fresh) && !empty($fresh) && !isset($fresh['error'])) ? $fresh[0] : null;
if ($fresh && $fresh['status'] === 'completed') {
$meta = normaliseJsonObject($fresh['metadata'] ?? null);
jsonResponse([
'success' => true,
'already_completed' => true,
'result' => $fresh['result'],
'winner' => $meta['winner'] ?? null,
]);
}
jsonError('Could not complete match', 409);
}
$sdb->update('matches', [
'status' => 'completed',
'result' => $result,
'current_fen' => $fen,
'pgn' => $pgn,
'completed_at' => date('c'),
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
// Use universal rewards function
$rewardResult = supabaseRpc('complete_match', [
'p_game_key' => 'chess',
'p_match_id' => $matchId,
'p_winners' => json_encode($winners ?: [$userId]),
'p_reason' => $reason
]);
// Tournament result reporting hook
reportTournamentResult($db, $matchId, $result, $userId);
// Check achievements
// Achievements are best-effort and must never block the result.
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'games_played,total_wins,win_streak,elo_rapid', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : null;
$profile = is_array($profiles) && !empty($profiles) && !isset($profiles['error']) ? $profiles[0] : null;
if ($profile) {
checkGameAchievements($sdb, $userId, $profile, $profile['elo_rapid'] ?? 1200);
}
jsonResponse($rewardResult ?: ['success' => true, 'result' => $result]);
$fresh = $sdb->get('matches', [
'id' => 'eq.' . $matchId,
'select' => 'result,white_rating_after,black_rating_after,rating_change_white,rating_change_black',
'limit' => 1,
]);
$fresh = (is_array($fresh) && !empty($fresh) && !isset($fresh['error'])) ? $fresh[0] : [];
$ratingChange = $myColor === 'w'
? ($fresh['rating_change_white'] ?? null)
: ($fresh['rating_change_black'] ?? null);
$ratingAfter = $myColor === 'w'
? ($fresh['white_rating_after'] ?? null)
: ($fresh['black_rating_after'] ?? null);
jsonResponse([
'success' => true,
'result' => $derived['result'],
'reason' => $derived['reason'],
'winner' => $derived['winner'],
'my_color' => $myColor,
'rating_change' => $ratingChange,
'rating_after' => $ratingAfter,
]);
}
/**
* Accept both the new vocabulary (an explicit reason) and what older clients
* send (result: 'win'|'loss'|'draw' with a free-text reason), and land on one
* of CHESS_END_REASONS.
*/
function normaliseEndReason(?string $reason, ?string $legacyResult): string {
$map = [
'checkmate' => 'checkmate', 'mate' => 'checkmate',
'resign' => 'resign', 'resignation' => 'resign',
'timeout' => 'timeout', 'time' => 'timeout', 'flag' => 'timeout',
'abandon' => 'abandon', 'abandoned' => 'abandon', 'disconnect' => 'abandon',
'stalemate' => 'stalemate',
'agreement' => 'agreement', 'draw' => 'agreement', 'mutual' => 'agreement',
'insufficient' => 'insufficient_material',
'insufficient_material' => 'insufficient_material',
'threefold' => 'threefold_repetition',
'threefold_repetition' => 'threefold_repetition',
'repetition' => 'threefold_repetition',
'fifty' => 'fifty_moves', 'fifty_moves' => 'fifty_moves',
'aborted' => 'aborted', 'abort' => 'aborted',
];
$key = strtolower(trim((string)$reason));
if (isset($map[$key])) return $map[$key];
if ($legacyResult === 'draw') return 'agreement';
if ($legacyResult === 'win' || $legacyResult === 'loss') return 'checkmate';
return 'unknown';
}
function calculateElo(int $playerRating, int $opponentRating, float $score): int {
......@@ -322,37 +615,10 @@ function getTimeControlType(string $timeControl): string {
return 'rapid';
}
function reportTournamentResult($db, string $matchId, string $result, string $userId): void {
$sdb = supabaseService();
$matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'tournament_id,metadata', 'limit' => 1]);
$match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null;
if (!$match || empty($match['tournament_id'])) return;
$metadata = json_decode($match['metadata'] ?? '{}', true);
if (!empty($metadata['tournament_reported'])) return;
// Delegate to tournament-match.php logic via internal call
$payload = json_encode([
'action' => 'report-result',
'match_id' => $matchId,
'tournament_id' => $match['tournament_id'],
'result' => $result
]);
// Use stream context for internal request
$url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . '/api/tournament-match.php';
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAuthorization: Bearer " . (getAuthToken() ?? '') . "\r\n",
'content' => $payload,
'timeout' => 5
]]);
@file_get_contents($url, false, $ctx);
}
// Tournament reporting used to happen here via an HTTP call back into this same
// Apache instance (@file_get_contents to /api/tournament-match.php). That could
// deadlock a worker and, when it failed, lost the result with no trace. It now
// runs in-process from finaliseMatch() via tournamentRecordMatchResult().
function checkGameAchievements($sdb, string $userId, array $profileUpdates, int $newRating): void {
$achievements = $sdb->get('achievements', ['select' => 'id,condition,coins_reward,xp_reward']);
......@@ -421,13 +687,28 @@ function handleChessHeartbeat(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$result = supabaseRpc('match_heartbeat', [
supabaseRpc('match_heartbeat', [
'p_game_key' => 'chess',
'p_match_id' => $matchId,
'p_player_id' => $userId
]);
jsonResponse($result ?: ['status' => 'ok']);
// Also stamp the heartbeat into game_state, keyed by player.
//
// This is the signal the opponent's client uses to tell "they are still
// here, just thinking" from "they have gone". Without it, disconnect
// detection has nothing but move activity to go on and would accuse a player
// of disconnecting every time they thought for more than thirty seconds.
// Merged (not written) so it cannot clobber a draw offer or an emote.
// The key is flat and per-player — merge_game_state is a shallow merge, so a
// nested "heartbeats" object would have each player's beat overwrite the other's.
supabaseRpc('merge_game_state', [
'p_table' => 'matches',
'p_match_id' => $matchId,
'p_patch' => ['hb_' . $userId => round(microtime(true) * 1000)],
]);
jsonResponse(['status' => 'ok']);
}
function handleChessLeave(string $userId, array $input): void {
......@@ -512,11 +793,13 @@ function handleFindActiveMatch(string $userId, array $input): void {
}
function mpLog(string $matchId, string $gameKey, string $playerId, string $event, $payload = []): void {
// p_payload is jsonb: pass the array, not a JSON string, or it lands in the
// log as an escaped string that nothing can query.
supabaseRpc('log_mp_event', [
'p_match_id' => $matchId,
'p_game_key' => $gameKey,
'p_player_id' => $playerId,
'p_event' => $event,
'p_payload' => json_encode(is_array($payload) ? $payload : [])
'p_payload' => is_array($payload) ? $payload : []
]);
}
......@@ -65,7 +65,6 @@ function handleLudoQueue(string $userId, array $input): void {
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
......
......@@ -23,7 +23,6 @@ curl_setopt($ch, CURLOPT_HTTPHEADER, [
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
echo $result;
......
......@@ -38,7 +38,6 @@ curl_setopt($ch, CURLOPT_HTTPHEADER, [
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$matches = json_decode($response, true);
......
......@@ -7,6 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/chess.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -32,8 +33,11 @@ function handleQueue($db, string $userId, array $input): void {
$gameKey = $input['game_key'] ?? 'chess';
$timeControl = $input['time_control'] ?? 'rapid_10_0';
$validTimeControls = ['bullet_1_0','bullet_1_1','bullet_2_1','blitz_3_0','blitz_3_2','blitz_5_0','blitz_5_3','rapid_10_0','rapid_10_5','rapid_15_10','rapid_15_15','classical_30_0','classical_30_20','classical_60_30','custom'];
if (!in_array($timeControl, $validTimeControls, true)) {
// Validate against the real `public.time_control` enum. This list used to
// include rapid_15_15, classical_30_0, classical_30_20 and classical_60_30,
// none of which the database accepts — a player picking one got past this
// check and then the match insert failed.
if (!in_array($timeControl, TIME_CONTROL_ENUM, true)) {
jsonError('Invalid time control');
}
......@@ -51,8 +55,30 @@ function handleQueue($db, string $userId, array $input): void {
return;
}
// STEP 1: Remove ALL stale/existing entries for this player (prevents ghost entries)
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
// STEP 0: If we have already been claimed by an opponent, hand that match
// back instead of re-queueing.
//
// The next step used to delete *all* of this player's queue rows
// unconditionally, including one already marked `matched` and carrying a
// match_id. The waiting player then never learned about their own match and
// their opponent sat alone in a game — "I didn't get connected to the person
// in front of me". The 60s retry in queue.js hits this path every time.
$claimed = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.matched',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($claimed) && !empty($claimed) && !isset($claimed['error']) && !empty($claimed[0]['match_id'])) {
handleStatus($db, $userId, $input);
return;
}
// STEP 1: Drop only our own *waiting* rows, never a claimed one.
$sdb->delete('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting',
]);
// STEP 2: Clean stale entries from other players (older than 90 seconds)
$staleTime = gmdate('c', time() - 90);
......@@ -79,28 +105,20 @@ function handleQueue($db, string $userId, array $input): void {
}
}
// STEP 5: Search for available opponent
// STEP 5: Search for available opponent.
// This was a hand-rolled curl that re-implemented auth and URL building —
// the only query in the file that bypassed the client. Going through the
// client keeps one code path for headers, timeouts and error shape.
$excludeIds = array_merge([$userId], $blockedIds);
$searchUrl = SUPABASE_REST . '/matchmaking_queue'
. '?game_key=eq.' . urlencode($gameKey)
. '&time_control=eq.' . urlencode($timeControl)
. '&status=eq.waiting'
. '&player_id=not.in.(' . implode(',', $excludeIds) . ')'
. '&select=id,player_id,rating'
. '&order=queued_at.asc'
. '&limit=1';
$ch = curl_init($searchUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
$opponents = $sdb->get('matchmaking_queue', [
'game_key' => 'eq.' . $gameKey,
'time_control' => 'eq.' . $timeControl,
'status' => 'eq.waiting',
'player_id' => 'not.in.(' . implode(',', $excludeIds) . ')',
'select' => 'id,player_id,rating',
'order' => 'queued_at.asc',
'limit' => 1,
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$searchResult = curl_exec($ch);
curl_close($ch);
$opponents = json_decode($searchResult, true);
if (!empty($opponents) && !isset($opponents['error']) && isset($opponents[0])) {
$opponent = $opponents[0];
......@@ -134,10 +152,11 @@ function handleQueue($db, string $userId, array $input): void {
$whiteId = $isWhite ? $userId : $opponent['player_id'];
$blackId = $isWhite ? $opponent['player_id'] : $userId;
$initialTime = 600000;
if (strpos($timeControl, 'bullet') !== false) $initialTime = 60000;
elseif (strpos($timeControl, 'blitz') !== false) $initialTime = 300000;
elseif (strpos($timeControl, 'classical') !== false) $initialTime = 3600000;
// Derive the clock from the time control itself rather than bucketing
// by prefix, which gave every blitz variant a flat 5 minutes and ignored
// increments entirely.
$clock = chessTimeControlMs($timeControl);
$initialTime = $clock['initial'];
$match = $sdb->insert('matches', [
'game_key' => $gameKey,
......@@ -147,9 +166,11 @@ function handleQueue($db, string $userId, array $input): void {
'status' => 'in_progress',
'time_control' => $timeControl,
'initial_time_ms' => $initialTime,
'increment_ms' => $clock['increment'],
'white_time_remaining_ms' => $initialTime,
'black_time_remaining_ms' => $initialTime,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'starting_fen' => CHESS_START_FEN,
'current_fen' => CHESS_START_FEN,
'moves' => [],
'move_count' => 0,
'game_state' => (object)[],
......@@ -216,8 +237,24 @@ function handleStatus($db, string $userId, array $input): void {
function handleDequeue($db, string $userId, array $input): void {
$sdb = supabaseService();
// Remove from chess matchmaking queue
$sdb->delete('matchmaking_queue', ['player_id' => 'eq.' . $userId]);
// A row that has already been matched must survive: deleting it here (which
// scene teardown does on every navigation) would destroy the hand-off and
// strand the opponent in a game by themselves. Report the match instead.
$claimed = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.matched',
'select' => 'match_id',
'limit' => 1,
]);
if (is_array($claimed) && !empty($claimed) && !isset($claimed['error']) && !empty($claimed[0]['match_id'])) {
jsonResponse(['success' => false, 'matched' => true, 'match_id' => $claimed[0]['match_id']]);
}
$sdb->delete('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting',
]);
jsonResponse(['success' => true]);
}
......
......@@ -117,7 +117,6 @@ if (isset($_FILES['proof_document'])) {
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
jsonError('Storage connection failed: ' . $curlError, 500);
......
<?php
/**
* Tournament read API (standings, rounds, pairings, arena).
*
* This used to proxy every request to an external Swiss service. That service
* requires an Authorization header and this file never sent one, so every call
* returned 401 and each getter quietly fell through to an empty array — which
* is why standings, pairings, rounds and brackets have always rendered blank.
*
* It now reads EL3AB's own tournament tables through includes/tournament-engine.php.
* The external service is still consulted, but only as an optional extra when a
* tournament is explicitly linked to one AND a key is configured.
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
require_once __DIR__ . '/../config/constants.php';
$action = $_GET['action'] ?? (getInput()['action'] ?? '');
switch ($action) {
case 'tournament':
getTournament();
break;
case 'standings':
getStandings();
break;
case 'rounds':
getRounds();
break;
case 'pairings':
getPairings();
break;
case 'my-games':
getMyGames();
break;
case 'bracket':
getBracket();
break;
case 'arena-join':
arenaJoin();
break;
case 'arena-status':
arenaStatus();
break;
case 'arena-standings':
arenaStandings();
break;
default:
jsonError('Invalid action');
case 'tournament': getTournament(); break;
case 'standings': getStandings(); break;
case 'rounds': getRounds(); break;
case 'pairings': getPairings(); break;
case 'my-games': getMyGames(); break;
case 'bracket': getBracket(); break;
case 'arena-join': arenaJoin(); break;
case 'arena-status': arenaStatus(); break;
case 'arena-standings': arenaStandings(); break;
default: jsonError('Invalid action');
}
/**
* Optional call out to the external Swiss service.
*
* Returns an error array unless SWISS_API_KEY is configured — the point being
* that a missing key is now an explicit, visible condition rather than a silent
* 401 that every caller mistook for "no data".
*/
function swissApi(string $method, string $path, ?array $body = null): array {
$url = SWISS_API . $path;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$key = defined('SWISS_API_KEY') ? SWISS_API_KEY : '';
if ($key === '') {
return ['error' => 'swiss_api_not_configured', 'code' => 0];
}
$ch = curl_init(SWISS_API . $path);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $key,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$data = json_decode($response, true);
if ($httpCode >= 400) {
return ['error' => $data['message'] ?? 'Swiss API error', 'code' => $httpCode];
if ($code >= 400 || $data === null) {
return ['error' => $data['message'] ?? 'Swiss API error', 'code' => $code];
}
return $data ?? [];
return $data;
}
function requireTournament($db, string $id): array {
$t = tournamentLoad($db, $id);
if (!$t) jsonError('Tournament not found', 404);
return $t;
}
function getTournament(): void {
$id = $_GET['id'] ?? '';
if (!$id) jsonError('id required');
// Get from Supabase
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$tournaments = $db->get('el3ab_tournaments', ['id' => 'eq.' . $id, 'limit' => 1]);
$tournament = is_array($tournaments) && !empty($tournaments) && !isset($tournaments['error']) ? $tournaments[0] : null;
if (!$tournament) jsonError('Tournament not found', 404);
// Get Swiss API data if linked
if (!empty($tournament['swiss_api_tournament_id'])) {
$swiss = swissApi('GET', '/tournaments/' . $tournament['swiss_api_tournament_id']);
if (!isset($swiss['error'])) {
$tournament['swiss_data'] = $swiss;
}
}
// Get registrations count
$regs = $db->get('tournament_registrations', ['tournament_id' => 'eq.' . $id, 'status' => 'eq.registered', 'select' => 'id']);
$tournament['player_count'] = is_array($regs) && !isset($regs['error']) ? count($regs) : 0;
jsonResponse($tournament);
$t = requireTournament($db, $id);
$players = tournamentPlayers($db, $id, $t['time_control'] ?? 'rapid_10_0');
$t['player_count'] = count($players);
$t['rounds_total'] = (int)($t['swiss_rounds'] ?? $t['rounds_total'] ?? 0);
$rounds = tournamentRounds($db, $id);
$t['rounds'] = array_map(fn($r) => [
'id' => $r['id'],
'round_number' => (int)$r['round_number'],
'status' => $r['status'],
'games' => count($r['pairings']),
'completed' => count(array_filter($r['pairings'], fn($p) => ($p['result'] ?? null) !== null)),
], $rounds);
jsonResponse($t);
}
function getStandings(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$tournament = $db->get('el3ab_tournaments', ['id' => 'eq.' . $tournamentId, 'select' => 'swiss_api_tournament_id', 'limit' => 1]);
if (empty($tournament) || isset($tournament['error'])) jsonError('Not found', 404);
$swissId = $tournament[0]['swiss_api_tournament_id'] ?? null;
if ($swissId) {
$standings = swissApi('GET', '/tournaments/' . $swissId . '/standings');
if (!isset($standings['error'])) {
jsonResponse(['standings' => $standings]);
}
}
$t = requireTournament($db, $tournamentId);
jsonResponse(['standings' => []]);
jsonResponse(['standings' => tournamentStandings($db, $t)]);
}
function getRounds(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$rounds = $db->get('el3ab_tournament_rounds', [
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,round_number,status,started_at,completed_at,pairings',
'order' => 'round_number.asc'
]);
jsonResponse(['rounds' => is_array($rounds) && !isset($rounds['error']) ? $rounds : []]);
requireTournament($db, $tournamentId);
$rounds = tournamentRounds($db, $tournamentId);
jsonResponse(['rounds' => array_map(fn($r) => [
'id' => $r['id'],
'round_number' => (int)$r['round_number'],
'status' => $r['status'],
'started_at' => $r['started_at'],
'completed_at' => $r['completed_at'],
'games' => count($r['pairings']),
'completed' => count(array_filter($r['pairings'], fn($p) => ($p['result'] ?? null) !== null)),
], $rounds)]);
}
function getPairings(): void {
$roundId = $_GET['round_id'] ?? '';
if (!$roundId) jsonError('round_id required');
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
$roundNumber = isset($_GET['round']) ? (int)$_GET['round'] : null;
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$t = requireTournament($db, $tournamentId);
$round = $db->get('el3ab_tournament_rounds', ['id' => 'eq.' . $roundId, 'select' => 'pairings,results', 'limit' => 1]);
if ($roundNumber === null) $roundNumber = max(1, (int)($t['current_round'] ?? 1));
$round = tournamentRound($db, $tournamentId, $roundNumber);
if (!$round) jsonResponse(['pairings' => [], 'round_number' => $roundNumber]);
if (empty($round) || isset($round['error'])) jsonResponse(['pairings' => []]);
jsonResponse([
'round_id' => $round['id'],
'round_number' => (int)$round['round_number'],
'status' => $round['status'],
'pairings' => decoratePairings($db, $round['pairings']),
]);
}
$rawP = $round[0]['pairings'] ?? '[]';
$pairings = is_array($rawP) ? $rawP : json_decode($rawP, true);
$rawR = $round[0]['results'] ?? '[]';
$results = is_array($rawR) ? $rawR : json_decode($rawR, true);
/** Attach display names so the UI does not have to resolve every uuid itself. */
function decoratePairings($db, array $pairings): array {
$ids = [];
foreach ($pairings as $p) {
foreach (['white_id', 'black_id'] as $k) {
if (!empty($p[$k])) $ids[$p[$k]] = true;
}
}
$names = [];
if ($ids) {
$profiles = $db->get('profiles', [
'id' => 'in.(' . implode(',', array_keys($ids)) . ')',
'select' => 'id,display_name,username,avatar_url',
]);
if (is_array($profiles) && !isset($profiles['error'])) {
foreach ($profiles as $pr) $names[$pr['id']] = $pr;
}
}
jsonResponse(['pairings' => $pairings, 'results' => $results]);
return array_map(function ($p) use ($names) {
$w = $names[$p['white_id'] ?? ''] ?? null;
$b = $names[$p['black_id'] ?? ''] ?? null;
return array_merge($p, [
'white_name' => $w['display_name'] ?? $w['username'] ?? null,
'white_avatar' => $w['avatar_url'] ?? null,
'black_name' => $b['display_name'] ?? $b['username'] ?? null,
'black_avatar' => $b['avatar_url'] ?? null,
]);
}, $pairings);
}
function getMyGames(): void {
......@@ -154,152 +188,87 @@ function getMyGames(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabase($token);
$matches = $db->get('matches', [
'tournament_id' => 'eq.' . $tournamentId,
'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})",
'select' => 'id,white_player_id,black_player_id,result,status,tournament_round,created_at',
'order' => 'tournament_round.asc'
]);
$db = supabaseService();
requireTournament($db, $tournamentId);
$games = [];
foreach (tournamentRounds($db, $tournamentId) as $round) {
foreach ($round['pairings'] as $p) {
$isMine = ($p['white_id'] ?? null) === $userId || ($p['black_id'] ?? null) === $userId;
if (!$isMine) continue;
$games[] = [
'round_number' => (int)$round['round_number'],
'board' => $p['board'] ?? null,
'is_bye' => !empty($p['is_bye']),
'color' => (($p['white_id'] ?? null) === $userId) ? 'w' : 'b',
'opponent_id' => (($p['white_id'] ?? null) === $userId) ? ($p['black_id'] ?? null) : ($p['white_id'] ?? null),
'result' => $p['result'] ?? null,
'match_id' => $p['match_id'] ?? null,
'pairing_id' => $p['pairing_id'] ?? null,
];
}
}
jsonResponse(['games' => is_array($matches) && !isset($matches['error']) ? $matches : []]);
jsonResponse(['games' => $games]);
}
function getBracket(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$t = requireTournament($db, $tournamentId);
// Get bracket structure
$brackets = $db->get('tournament_brackets', [
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,bracket_type,total_rounds,seeds',
'limit' => 1
]);
$bracket = is_array($brackets) && !empty($brackets) && !isset($brackets['error']) ? $brackets[0] : null;
if (!$bracket) jsonResponse(['bracket' => null, 'matches' => []]);
// Get bracket matches
$matches = $db->get('bracket_matches', [
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,round,position,player_a_id,player_b_id,result,winner_id,status,match_id',
'order' => 'round.asc,position.asc'
]);
// Enrich with player names
$playerIds = [];
if (is_array($matches) && !isset($matches['error'])) {
foreach ($matches as $m) {
if (!empty($m['player_a_id'])) $playerIds[] = $m['player_a_id'];
if (!empty($m['player_b_id'])) $playerIds[] = $m['player_b_id'];
}
}
$players = [];
$playerIds = array_unique($playerIds);
if (!empty($playerIds)) {
$idFilter = 'in.(' . implode(',', $playerIds) . ')';
$profiles = $db->get('profiles', ['id' => $idFilter, 'select' => 'id,display_name,avatar_url']);
if (is_array($profiles) && !isset($profiles['error'])) {
foreach ($profiles as $p) $players[$p['id']] = $p;
}
}
$enriched = [];
if (is_array($matches) && !isset($matches['error'])) {
foreach ($matches as $m) {
$m['player_a_name'] = $players[$m['player_a_id']]['display_name'] ?? null;
$m['player_b_name'] = $players[$m['player_b_id']]['display_name'] ?? null;
$enriched[] = $m;
}
// Knockout brackets are not implemented natively yet. Say so plainly rather
// than returning an empty array that the UI renders as "no games".
if (!in_array($t['format'] ?? '', ['single_elimination', 'double_elimination', 'swiss_to_bracket'], true)) {
jsonResponse(['bracket' => [], 'format' => $t['format'] ?? null, 'supported' => false]);
}
$rows = $db->get('bracket_matches', ['tournament_id' => 'eq.' . $tournamentId, 'select' => '*', 'order' => 'round.asc']);
jsonResponse([
'bracket' => $bracket,
'matches' => $enriched
'bracket' => (is_array($rows) && !isset($rows['error'])) ? $rows : [],
'format' => $t['format'],
'supported' => true,
]);
}
// ---------------------------------------------------------------------------
// Arena — continuous pairing rather than fixed rounds
// ---------------------------------------------------------------------------
function arenaJoin(): void {
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$tournamentId = $input['tournament_id'] ?? '';
$tournamentId = $input['tournament_id'] ?? ($_GET['tournament_id'] ?? '');
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$t = requireTournament($db, $tournamentId);
// Verify tournament is arena type and in_progress
$tournaments = $db->get('el3ab_tournaments', [
'id' => 'eq.' . $tournamentId,
'select' => 'id,format,status,time_control,game_key',
'limit' => 1
]);
$tournament = is_array($tournaments) && !empty($tournaments) ? $tournaments[0] : null;
if (!$tournament) jsonError('Tournament not found', 404);
if ($tournament['status'] !== 'in_progress') jsonError('Tournament is not active');
if ($tournament['format'] !== 'arena') jsonError('Not an arena tournament');
// Mark player as seeking — upsert into arena_seekers
$db->upsert('arena_seekers', [
'tournament_id' => $tournamentId,
'player_id' => $userId,
'status' => 'seeking',
'joined_at' => date('c')
], 'tournament_id,player_id');
// Try to pair with another seeker
$seekers = $db->get('arena_seekers', [
'tournament_id' => 'eq.' . $tournamentId,
'status' => 'eq.seeking',
'player_id' => 'neq.' . $userId,
'order' => 'joined_at.asc',
'limit' => 1
]);
if (is_array($seekers) && !empty($seekers) && !isset($seekers['error'])) {
$opponent = $seekers[0];
$opponentId = $opponent['player_id'];
// Mark both as paired
$db->update('arena_seekers', ['status' => 'paired'], ['tournament_id' => 'eq.' . $tournamentId, 'player_id' => 'eq.' . $userId]);
$db->update('arena_seekers', ['status' => 'paired'], ['tournament_id' => 'eq.' . $tournamentId, 'player_id' => 'eq.' . $opponentId]);
// Create match
$whiteId = (rand(0, 1) === 0) ? $userId : $opponentId;
$blackId = ($whiteId === $userId) ? $opponentId : $userId;
$match = $db->insert('matches', [
'game_key' => $tournament['game_key'] ?? 'chess',
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'status' => 'in_progress',
'time_control' => $tournament['time_control'] ?? 'blitz_3_0',
'tournament_id' => $tournamentId,
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]',
'metadata' => json_encode(['mode' => 'arena'])
]);
$matchId = is_array($match) && !empty($match) ? ($match[0]['id'] ?? $match['id'] ?? null) : null;
$color = ($whiteId === $userId) ? 'w' : 'b';
jsonResponse([
'status' => 'paired',
'match_id' => $matchId,
'color' => $color,
'opponent_id' => $opponentId,
'time_control' => $tournament['time_control']
]);
// Arena is round-based under the hood: a player asks for their current game.
$pending = tournamentPendingPairingFor($db, $t, $userId);
if (!$pending) {
jsonResponse(['status' => 'waiting']);
}
if (!empty($pending['pairing']['is_bye'])) {
jsonResponse(['status' => 'bye', 'round_number' => (int)$pending['round']['round_number']]);
}
jsonResponse(['status' => 'seeking']);
$p = $pending['pairing'];
$isWhite = ($p['white_id'] === $userId);
jsonResponse([
'status' => 'paired',
'color' => $isWhite ? 'w' : 'b',
'opponent_id' => $isWhite ? $p['black_id'] : $p['white_id'],
'time_control' => chessNormaliseTimeControl($t['time_control'] ?? null),
'round_number' => (int)$pending['round']['round_number'],
'board' => $p['board'] ?? null,
// The client calls tournament-match.php create-or-join to open the game,
// so the match id is minted in exactly one place.
'needs_match' => true,
]);
}
function arenaStatus(): void {
......@@ -308,110 +277,40 @@ function arenaStatus(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
$t = requireTournament($db, $tournamentId);
// Check if player has a live match in this arena
$matches = $db->get('matches', [
'tournament_id' => 'eq.' . $tournamentId,
'status' => 'eq.in_progress',
'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})",
'select' => 'id,white_player_id,black_player_id,time_control',
'order' => 'created_at.desc',
'limit' => 1
]);
if ($t['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) {
jsonResponse(['status' => $t['status']]);
}
if (is_array($matches) && !empty($matches) && !isset($matches['error'])) {
$m = $matches[0];
jsonResponse([
'status' => 'in_game',
'match_id' => $m['id'],
'color' => ($m['white_player_id'] === $userId) ? 'w' : 'b',
'opponent_id' => ($m['white_player_id'] === $userId) ? $m['black_player_id'] : $m['white_player_id'],
'time_control' => $m['time_control']
]);
$pending = tournamentPendingPairingFor($db, $t, $userId);
if (!$pending) jsonResponse(['status' => 'waiting']);
if (!empty($pending['pairing']['is_bye'])) {
jsonResponse(['status' => 'bye', 'round_number' => (int)$pending['round']['round_number']]);
}
// Check seeker status
$seekers = $db->get('arena_seekers', [
'tournament_id' => 'eq.' . $tournamentId,
'player_id' => 'eq.' . $userId,
'select' => 'status',
'limit' => 1
]);
$p = $pending['pairing'];
$isWhite = ($p['white_id'] === $userId);
$matchId = tournamentMatchIdFor($tournamentId, (int)$pending['round']['round_number'], (int)($p['board'] ?? 1));
$rows = $db->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'id,status', 'limit' => 1]);
$exists = is_array($rows) && !empty($rows) && !isset($rows['error']);
$seekerStatus = is_array($seekers) && !empty($seekers) ? ($seekers[0]['status'] ?? 'idle') : 'idle';
jsonResponse(['status' => $seekerStatus]);
jsonResponse([
'status' => $exists ? 'in_game' : 'paired',
'match_id' => $exists ? $matchId : null,
'color' => $isWhite ? 'w' : 'b',
'opponent_id' => $isWhite ? $p['black_id'] : $p['white_id'],
'time_control' => chessNormaliseTimeControl($t['time_control'] ?? null),
'round_number' => (int)$pending['round']['round_number'],
]);
}
function arenaStandings(): void {
$tournamentId = $_GET['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
require_once __DIR__ . '/../includes/supabase.php';
$db = supabaseService();
// Get all completed arena matches and compute standings
$matches = $db->get('matches', [
'tournament_id' => 'eq.' . $tournamentId,
'status' => 'eq.completed',
'select' => 'white_player_id,black_player_id,result',
'order' => 'created_at.asc'
]);
$scores = [];
if (is_array($matches) && !isset($matches['error'])) {
foreach ($matches as $m) {
$w = $m['white_player_id'];
$b = $m['black_player_id'];
if (!isset($scores[$w])) $scores[$w] = ['wins' => 0, 'draws' => 0, 'losses' => 0, 'points' => 0, 'games' => 0];
if (!isset($scores[$b])) $scores[$b] = ['wins' => 0, 'draws' => 0, 'losses' => 0, 'points' => 0, 'games' => 0];
$scores[$w]['games']++;
$scores[$b]['games']++;
if ($m['result'] === 'white_wins') {
$scores[$w]['wins']++; $scores[$w]['points'] += 2;
$scores[$b]['losses']++;
} elseif ($m['result'] === 'black_wins') {
$scores[$b]['wins']++; $scores[$b]['points'] += 2;
$scores[$w]['losses']++;
} elseif ($m['result'] === 'draw') {
$scores[$w]['draws']++; $scores[$w]['points'] += 1;
$scores[$b]['draws']++; $scores[$b]['points'] += 1;
}
}
}
// Sort by points desc
arsort($scores);
$sorted = [];
$playerIds = array_keys($scores);
// Get names
$players = [];
if (!empty($playerIds)) {
$idFilter = 'in.(' . implode(',', $playerIds) . ')';
$profiles = $db->get('profiles', ['id' => $idFilter, 'select' => 'id,display_name,avatar_url']);
if (is_array($profiles) && !isset($profiles['error'])) {
foreach ($profiles as $p) $players[$p['id']] = $p;
}
}
$rank = 1;
foreach ($scores as $pid => $s) {
$sorted[] = [
'rank' => $rank++,
'player_id' => $pid,
'name' => $players[$pid]['display_name'] ?? 'Player',
'avatar_url' => $players[$pid]['avatar_url'] ?? null,
'points' => $s['points'],
'wins' => $s['wins'],
'draws' => $s['draws'],
'losses' => $s['losses'],
'games' => $s['games']
];
}
jsonResponse(['standings' => $sorted]);
$t = requireTournament($db, $tournamentId);
jsonResponse(['standings' => tournamentStandings($db, $t)]);
}
<?php
/**
* Tournament administration: create, open registration, start, force-advance,
* and manual result entry.
*
* Before this existed there was no code path anywhere in the platform that could
* start a tournament or generate a pairing, which is why `auto_start` was set on
* live tournaments that then sat in `registration` forever.
*/
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? ($_GET['action'] ?? '');
$db = supabaseService();
switch ($action) {
case 'create': adminCreate($db, $userId, $input); break;
case 'open': adminSetStatus($db, $userId, $input, TOURNAMENT_STATUS_REGISTRATION); break;
case 'cancel': adminSetStatus($db, $userId, $input, TOURNAMENT_STATUS_CANCELLED); break;
case 'start': adminStart($db, $userId, $input); break;
case 'advance': adminAdvance($db, $userId, $input); break;
case 'forfeit': adminForfeit($db, $userId, $input); break;
case 'add-player': adminAddPlayer($db, $userId, $input); break;
case 'remove-player': adminRemovePlayer($db, $userId, $input); break;
default: jsonError('Invalid action');
}
/**
* Who may administer a tournament: its creator, or a platform admin.
* Without this any authenticated player could start or cancel an event.
*/
function requireTournamentAdmin($db, string $userId, string $tournamentId): array {
$t = tournamentLoad($db, $tournamentId);
if (!$t) jsonError('Tournament not found', 404);
if (($t['created_by'] ?? null) === $userId) return $t;
if (isPlatformAdmin($db, $userId)) return $t;
jsonError('You do not have permission to administer this tournament', 403);
}
function isPlatformAdmin($db, string $userId): bool {
$rows = $db->get('admin_users', ['id' => 'eq.' . $userId, 'select' => 'id', 'limit' => 1]);
if (is_array($rows) && !isset($rows['error']) && !empty($rows)) return true;
// Fall back to a role flag on the profile, if the deployment uses one.
$p = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'is_admin', 'limit' => 1]);
return is_array($p) && !empty($p) && !isset($p['error']) && !empty($p[0]['is_admin']);
}
function adminCreate($db, string $userId, array $input): void {
$name = trim((string)($input['name'] ?? ''));
if ($name === '') jsonError('name is required');
$format = $input['format'] ?? 'swiss';
if (!in_array($format, ['swiss', 'round_robin', 'single_elimination', 'double_elimination', 'swiss_to_bracket', 'arena', 'team_battle'], true)) {
jsonError('Invalid format');
}
$timeControl = chessNormaliseTimeControl($input['time_control'] ?? null);
$rounds = isset($input['rounds']) ? max(1, min(15, (int)$input['rounds'])) : null;
$row = [
'name' => $name,
'name_ar' => $input['name_ar'] ?? null,
'description' => $input['description'] ?? '',
'game_key' => $input['game_key'] ?? 'chess',
'format' => $format,
'time_control' => $timeControl,
'swiss_rounds' => $rounds,
'rounds_total' => $rounds,
'min_players' => max(2, (int)($input['min_players'] ?? 4)),
'max_players' => max(2, (int)($input['max_players'] ?? 64)),
'starts_at' => $input['starts_at'] ?? null,
'status' => TOURNAMENT_STATUS_REGISTRATION,
'current_round' => 0,
'auto_start' => (bool)($input['auto_start'] ?? true),
'is_rated' => (bool)($input['is_rated'] ?? true),
'created_by' => $userId,
'tiebreak_rules'=> ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'],
];
$created = $db->insert('el3ab_tournaments', $row);
if (isset($created['error'])) jsonError('Could not create tournament: ' . $created['error']);
jsonResponse(['tournament' => $created[0] ?? $created]);
}
function adminSetStatus($db, string $userId, array $input, string $status): void {
$id = $input['tournament_id'] ?? '';
if (!$id) jsonError('tournament_id is required');
requireTournamentAdmin($db, $userId, $id);
$updated = $db->update('el3ab_tournaments', ['status' => $status, 'updated_at' => gmdate('c')], ['id' => 'eq.' . $id]);
if (isset($updated['error'])) jsonError($updated['error']);
jsonResponse(['ok' => true, 'status' => $status]);
}
function adminStart($db, string $userId, array $input): void {
$id = $input['tournament_id'] ?? '';
if (!$id) jsonError('tournament_id is required');
requireTournamentAdmin($db, $userId, $id);
$res = tournamentStart($db, $id);
if (!$res['ok']) jsonError($res['error'] ?? 'Could not start tournament', 409);
jsonResponse([
'ok' => true,
'round' => $res['round'] ? [
'id' => $res['round']['id'],
'round_number' => (int)$res['round']['round_number'],
'pairings' => $res['round']['pairings'],
] : null,
]);
}
function adminAdvance($db, string $userId, array $input): void {
$id = $input['tournament_id'] ?? '';
if (!$id) jsonError('tournament_id is required');
requireTournamentAdmin($db, $userId, $id);
jsonResponse(tournamentMaybeAdvance($db, $id));
}
function adminForfeit($db, string $userId, array $input): void {
$id = $input['tournament_id'] ?? '';
$pairingId = $input['pairing_id'] ?? '';
$absent = $input['absent_player_id'] ?? '';
if (!$id || !$pairingId || !$absent) jsonError('tournament_id, pairing_id and absent_player_id are required');
$t = requireTournamentAdmin($db, $userId, $id);
$round = (int)($input['round'] ?? $t['current_round'] ?? 0);
if ($round < 1) jsonError('Tournament has no active round');
$ok = tournamentForfeitPairing($db, $id, $round, $pairingId, $absent);
if (!$ok) jsonError('Pairing not found, or it already has a result', 409);
jsonResponse(['ok' => true]);
}
function adminAddPlayer($db, string $userId, array $input): void {
$id = $input['tournament_id'] ?? '';
$playerId = $input['player_id'] ?? '';
if (!$id || !$playerId) jsonError('tournament_id and player_id are required');
$t = requireTournamentAdmin($db, $userId, $id);
if ($t['status'] === TOURNAMENT_STATUS_IN_PROGRESS) {
jsonError('Cannot add players once the tournament has started', 409);
}
$existing = $db->get('tournament_registrations', [
'tournament_id' => 'eq.' . $id, 'player_id' => 'eq.' . $playerId, 'select' => 'id', 'limit' => 1,
]);
if (is_array($existing) && !empty($existing) && !isset($existing['error'])) {
$db->update('tournament_registrations', ['status' => 'registered'], ['id' => 'eq.' . $existing[0]['id']]);
jsonResponse(['ok' => true, 'already_registered' => true]);
}
$ins = $db->insert('tournament_registrations', [
'tournament_id' => $id, 'player_id' => $playerId, 'status' => 'registered',
'is_bot' => (bool)($input['is_bot'] ?? false),
]);
if (isset($ins['error'])) jsonError($ins['error']);
jsonResponse(['ok' => true]);
}
function adminRemovePlayer($db, string $userId, array $input): void {
$id = $input['tournament_id'] ?? '';
$playerId = $input['player_id'] ?? '';
if (!$id || !$playerId) jsonError('tournament_id and player_id are required');
$t = requireTournamentAdmin($db, $userId, $id);
// Mid-event a player is withdrawn, never deleted, so their played games keep
// counting towards their opponents' tiebreaks.
$status = ($t['status'] === TOURNAMENT_STATUS_IN_PROGRESS) ? 'withdrawn' : 'cancelled';
$db->update('tournament_registrations', [
'status' => $status, 'withdrawn_at' => gmdate('c'),
], ['tournament_id' => 'eq.' . $id, 'player_id' => 'eq.' . $playerId]);
jsonResponse(['ok' => true, 'status' => $status]);
}
......@@ -7,6 +7,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
require_once __DIR__ . '/../config/constants.php';
$token = requireAuth();
......@@ -30,367 +32,206 @@ switch ($action) {
jsonError('Invalid action');
}
/**
* Give the caller the match for their current-round pairing, creating it if it
* does not exist yet.
*
* Both paired players call this at roughly the same moment. The match id is
* derived from (tournament, round, board), so both compute the same value and
* the primary key on `matches` decides which INSERT wins — the loser simply
* reads back the winner's row. The previous version did a read-then-insert with
* no lock, so both players could create their own match and each would sit
* alone on a board.
*
* Colour comes from the pairing the Swiss engine produced, never re-randomised
* here, so both clients are told the same thing.
*/
function handleCreateOrJoin($db, string $userId, array $input): void {
$tournamentId = $input['tournament_id'] ?? '';
$roundId = $input['round_id'] ?? '';
$pairingIndex = intval($input['pairing_index'] ?? -1);
if (!$tournamentId) jsonError('tournament_id is required');
if (!$tournamentId || !$roundId || $pairingIndex < 0) {
jsonError('tournament_id, round_id, and pairing_index are required');
$tournament = tournamentLoad($db, $tournamentId);
if (!$tournament) jsonError('Tournament not found', 404);
if ($tournament['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) {
jsonError('Tournament is ' . $tournament['status'], 409);
}
// Fetch the round and its pairings
$rounds = $db->get('el3ab_tournament_rounds', [
'id' => 'eq.' . $roundId,
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,round_number,pairings,status',
'limit' => 1
]);
$round = is_array($rounds) && !empty($rounds) && !isset($rounds['error']) ? $rounds[0] : null;
if (!$round) jsonError('Round not found', 404);
if ($round['status'] !== 'in_progress') jsonError('Round is not in progress');
$rawPairings = $round['pairings'] ?? '[]';
$pairings = is_array($rawPairings) ? $rawPairings : json_decode($rawPairings, true);
if (!isset($pairings[$pairingIndex])) jsonError('Pairing not found');
$pairing = $pairings[$pairingIndex];
$playerA = $pairing['player_a'] ?? $pairing['white_id'] ?? null;
$playerB = $pairing['player_b'] ?? $pairing['black_id'] ?? null;
if ($userId !== $playerA && $userId !== $playerB) {
jsonError('You are not part of this pairing');
}
// BYE check
if (!$playerA || !$playerB || $playerA === 'BYE' || $playerB === 'BYE') {
jsonResponse(['bye' => true, 'message' => 'BYE round — auto win']);
$pending = tournamentPendingPairingFor($db, $tournament, $userId);
if (!$pending) {
jsonResponse(['waiting' => true, 'message' => 'No game to play in this round']);
}
$roundNumber = $round['round_number'] ?? 1;
// Check if match already exists for this pairing
$existing = $db->get('matches', [
'tournament_id' => 'eq.' . $tournamentId,
'tournament_round' => 'eq.' . $roundNumber,
'select' => 'id,white_player_id,black_player_id,status,time_control',
'or' => "(and(white_player_id.eq.{$playerA},black_player_id.eq.{$playerB}),and(white_player_id.eq.{$playerB},black_player_id.eq.{$playerA}))",
'limit' => 1
]);
$round = $pending['round'];
$pairing = $pending['pairing'];
$roundNumber = (int)$round['round_number'];
$board = (int)($pairing['board'] ?? 1);
if (is_array($existing) && !empty($existing) && !isset($existing['error'])) {
$match = $existing[0];
$color = ($match['white_player_id'] === $userId) ? 'w' : 'b';
$opponentId = ($match['white_player_id'] === $userId) ? $match['black_player_id'] : $match['white_player_id'];
if (!empty($pairing['is_bye'])) {
jsonResponse([
'match_id' => $match['id'],
'color' => $color,
'opponent_id' => $opponentId,
'time_control' => $match['time_control'],
'status' => $match['status'],
'already_exists' => true
'bye' => true,
'round_number' => $roundNumber,
'message' => 'You have a bye this round',
]);
}
// Determine colors from pairing data
$whiteId = $playerA;
$blackId = $playerB;
$whiteId = $pairing['white_id'];
$blackId = $pairing['black_id'];
$matchId = tournamentMatchIdFor($tournamentId, $roundNumber, $board);
// Get tournament time control
$tournaments = $db->get('el3ab_tournaments', [
'id' => 'eq.' . $tournamentId,
'select' => 'time_control,game_key',
'limit' => 1
]);
$tournament = is_array($tournaments) && !empty($tournaments) ? $tournaments[0] : null;
$timeControl = $tournament['time_control'] ?? 'rapid_10_0';
$timeControl = chessNormaliseTimeControl($tournament['time_control'] ?? null);
$gameKey = $tournament['game_key'] ?? 'chess';
// Parse time control for initial_time_ms and increment
$tcParts = explode('_', $timeControl);
$minutes = intval($tcParts[1] ?? 10);
$increment = intval($tcParts[2] ?? 0);
$initialTimeMs = $minutes * 60 * 1000;
$incrementMs = $increment * 1000;
// Create the match
$matchData = [
'game_key' => $gameKey,
'match_type' => 'tournament',
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'status' => 'in_progress',
'time_control' => $timeControl,
'initial_time_ms' => $initialTimeMs,
'increment_ms' => $incrementMs,
'white_time_remaining_ms' => $initialTimeMs,
'black_time_remaining_ms' => $initialTimeMs,
'tournament_id' => $tournamentId,
'tournament_round' => $roundNumber,
'starting_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'current_fen' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
'moves' => '[]',
'started_at' => date('c'),
'metadata' => json_encode([
$clock = chessTimeControlMs($timeControl);
// Try to claim the match. A duplicate primary key here means the opponent
// created it a moment ago, which is a success, not an error.
$created = $db->insert('matches', [
'id' => $matchId,
'game_key' => $gameKey,
'match_type' => 'tournament',
'white_player_id' => $whiteId,
'black_player_id' => $blackId,
'status' => 'in_progress',
'time_control' => $timeControl,
'initial_time_ms' => $clock['initial'],
'increment_ms' => $clock['increment'],
'white_time_remaining_ms' => $clock['initial'],
'black_time_remaining_ms' => $clock['initial'],
'tournament_id' => $tournamentId,
'tournament_round' => $roundNumber,
'pairing_id' => $pairing['pairing_id'] ?? null,
'starting_fen' => CHESS_START_FEN,
'current_fen' => CHESS_START_FEN,
'moves' => [],
'move_count' => 0,
'is_rated' => (bool)($tournament['is_rated'] ?? true),
'started_at' => gmdate('c'),
'metadata' => [
'mode' => 'tournament',
'round_id' => $roundId,
'pairing_index' => $pairingIndex
])
];
$result = $db->insert('matches', $matchData);
if (isset($result['error'])) jsonError('Failed to create match: ' . ($result['error'] ?? ''));
'round_id' => $round['id'],
'round_number' => $roundNumber,
'board' => $board,
],
]);
$match = is_array($result) && !empty($result) ? (isset($result[0]) ? $result[0] : $result) : null;
$matchId = $match['id'] ?? null;
$alreadyExisted = isset($created['error']);
if (!$matchId) jsonError('Match creation failed');
$rows = $db->get('matches', [
'id' => 'eq.' . $matchId,
'select' => 'id,white_player_id,black_player_id,status,time_control,current_fen,move_count,result',
'limit' => 1,
]);
$match = (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null;
if (!$match) {
jsonError('Could not open the match for this pairing', 500);
}
$color = ($whiteId === $userId) ? 'w' : 'b';
$opponentId = ($whiteId === $userId) ? $blackId : $whiteId;
$isWhite = ($match['white_player_id'] === $userId);
jsonResponse([
'match_id' => $matchId,
'color' => $color,
'opponent_id' => $opponentId,
'time_control' => $timeControl,
'status' => 'in_progress',
'already_exists' => false
'match_id' => $match['id'],
'color' => $isWhite ? 'w' : 'b',
'opponent_id' => $isWhite ? $match['black_player_id'] : $match['white_player_id'],
'time_control' => $match['time_control'],
'status' => $match['status'],
'round_number' => $roundNumber,
'board' => $board,
'already_exists' => $alreadyExisted,
// A match already carrying moves means we are rejoining, not starting.
'recovered' => (int)($match['move_count'] ?? 0) > 0,
]);
}
function handleReportResult($db, string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
$tournamentId = $input['tournament_id'] ?? '';
$result = $input['result'] ?? '';
if (!$matchId || !$tournamentId || !$result) {
jsonError('match_id, tournament_id, and result are required');
}
if (!$matchId || !$tournamentId) jsonError('match_id and tournament_id are required');
// Get match to verify and extract tournament info
$matches = $db->get('matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
$matches = $db->get('matches', [
'id' => 'eq.' . $matchId,
'select' => 'id,game_key,white_player_id,black_player_id,status,result,tournament_id,tournament_round,metadata',
'limit' => 1,
]);
$match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null;
if (!$match) jsonError('Match not found', 404);
if ($match['tournament_id'] !== $tournamentId) jsonError('Match does not belong to this tournament');
// Check if already reported (idempotent)
$rawMeta = $match['metadata'] ?? '{}';
$metadata = is_array($rawMeta) ? $rawMeta : json_decode($rawMeta, true);
if (!empty($metadata['tournament_reported'])) {
jsonResponse(['ok' => true, 'already_reported' => true]);
if ($match['white_player_id'] !== $userId && $match['black_player_id'] !== $userId) {
jsonError('Not authorized for this match', 403);
}
// Map result to Swiss format
$swissResult = mapToSwissResult($result, $match['white_player_id'], $match['black_player_id'], $userId);
// Get tournament to check format and swiss_api_tournament_id
$tournaments = $db->get('el3ab_tournaments', [
'id' => 'eq.' . $tournamentId,
'select' => 'swiss_api_tournament_id,format',
'limit' => 1
]);
$tournament = is_array($tournaments) && !empty($tournaments) ? $tournaments[0] : null;
// Report to Swiss API if linked
if ($tournament && !empty($tournament['swiss_api_tournament_id'])) {
$roundNumber = $match['tournament_round'] ?? 1;
$roundId = $metadata['round_id'] ?? null;
$pairingIndex = $metadata['pairing_index'] ?? null;
if ($roundId && $pairingIndex !== null) {
reportToSwissApi($tournament['swiss_api_tournament_id'], $roundId, $pairingIndex, $swissResult);
}
}
// Update el3ab_tournament_rounds results JSONB
$roundNumber = $match['tournament_round'] ?? 1;
$roundsData = $db->get('el3ab_tournament_rounds', [
'tournament_id' => 'eq.' . $tournamentId,
'round_number' => 'eq.' . $roundNumber,
'select' => 'id,results',
'limit' => 1
]);
if (is_array($roundsData) && !empty($roundsData) && !isset($roundsData['error'])) {
$roundRow = $roundsData[0];
$rawResults = $roundRow['results'] ?? '[]';
$results = is_array($rawResults) ? $rawResults : json_decode($rawResults, true);
$results[] = [
'match_id' => $matchId,
'result' => $swissResult,
'white_player_id' => $match['white_player_id'],
'black_player_id' => $match['black_player_id'],
'reported_at' => date('c')
];
$db->update('el3ab_tournament_rounds', ['results' => json_encode($results)], ['id' => 'eq.' . $roundRow['id']]);
// The result is whatever the server already settled on when the match was
// finalised — it is never taken from the caller. This endpoint exists only
// so an older client calling it still nudges the round forward; the real
// recording happens in game.php's finaliseMatch().
if ($match['status'] !== 'completed' || empty($match['result'])) {
jsonResponse(['ok' => false, 'pending' => true, 'message' => 'Match has not been finalised yet']);
}
// Mark match metadata as reported
$metadata['tournament_reported'] = true;
$metadata['reported_at'] = date('c');
$db->update('matches', ['metadata' => json_encode($metadata)], ['id' => 'eq.' . $matchId]);
$meta = is_array($match['metadata'] ?? null) ? $match['metadata'] : (json_decode((string)($match['metadata'] ?? '{}'), true) ?: []);
$derived = [
'result' => $match['result'],
'reason' => $meta['end_reason'] ?? 'unknown',
'winner' => $meta['winner'] ?? null,
];
// Handle bracket tournaments
if ($tournament && ($tournament['format'] === 'single_elimination' || $tournament['format'] === 'double_elimination')) {
$winnerId = determineWinner($result, $match);
if ($winnerId) {
$bracketMatches = $db->get('bracket_matches', [
'tournament_id' => 'eq.' . $tournamentId,
'match_id' => 'eq.' . $matchId,
'limit' => 1
]);
if (is_array($bracketMatches) && !empty($bracketMatches) && !isset($bracketMatches['error'])) {
$db->update('bracket_matches', [
'result' => $swissResult,
'winner_id' => $winnerId,
'status' => 'completed'
], ['id' => 'eq.' . $bracketMatches[0]['id']]);
}
}
}
tournamentRecordMatchResult($db, $match, $derived);
jsonResponse(['ok' => true, 'swiss_result' => $swissResult]);
jsonResponse(['ok' => true, 'result' => $derived['result']]);
}
/**
* Everything the caller currently owes a game in, across every tournament they
* are registered for.
*/
function handleMyPending($db, string $userId): void {
// Get all tournaments the player is registered for that are in_progress
$regs = $db->get('tournament_registrations', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.registered',
'select' => 'tournament_id'
'select' => 'tournament_id',
]);
if (!is_array($regs) || isset($regs['error']) || empty($regs)) {
jsonResponse(['pending' => []]);
}
$pending = [];
foreach ($regs as $reg) {
$tid = $reg['tournament_id'];
// Get tournament info
$tournaments = $db->get('el3ab_tournaments', [
'id' => 'eq.' . $tid,
'status' => 'eq.in_progress',
'select' => 'id,name,time_control,game_key',
'limit' => 1
]);
if (!is_array($tournaments) || empty($tournaments) || isset($tournaments['error'])) continue;
$tournament = $tournaments[0];
// Get in_progress rounds
$rounds = $db->get('el3ab_tournament_rounds', [
'tournament_id' => 'eq.' . $tid,
'status' => 'eq.in_progress',
'select' => 'id,round_number,pairings',
'order' => 'round_number.desc',
'limit' => 1
]);
if (!is_array($rounds) || empty($rounds) || isset($rounds['error'])) continue;
$round = $rounds[0];
$rawPairings = $round['pairings'] ?? '[]';
$pairings = is_array($rawPairings) ? $rawPairings : json_decode($rawPairings, true);
foreach ($pairings as $idx => $pairing) {
$playerA = $pairing['player_a'] ?? $pairing['white_id'] ?? null;
$playerB = $pairing['player_b'] ?? $pairing['black_id'] ?? null;
if ($userId !== $playerA && $userId !== $playerB) continue;
// Check if already has a match for this round
$existingMatch = $db->get('matches', [
'tournament_id' => 'eq.' . $tid,
'tournament_round' => 'eq.' . $round['round_number'],
'status' => 'eq.completed',
'or' => "(white_player_id.eq.{$userId},black_player_id.eq.{$userId})",
'select' => 'id',
'limit' => 1
]);
if (is_array($existingMatch) && !empty($existingMatch) && !isset($existingMatch['error'])) continue;
$opponentId = ($userId === $playerA) ? $playerB : $playerA;
// BYE check
if (!$opponentId || $opponentId === 'BYE') {
$pending[] = [
'tournament_id' => $tid,
'tournament_name' => $tournament['name'],
'round_id' => $round['id'],
'round_number' => $round['round_number'],
'pairing_index' => $idx,
'opponent_id' => null,
'is_bye' => true,
'time_control' => $tournament['time_control'],
'game_key' => $tournament['game_key']
];
continue;
$tournament = tournamentLoad($db, $reg['tournament_id']);
if (!$tournament || $tournament['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) continue;
$found = tournamentPendingPairingFor($db, $tournament, $userId);
if (!$found) continue;
$pairing = $found['pairing'];
$round = $found['round'];
$isBye = !empty($pairing['is_bye']);
$opponentId = $isBye ? null
: (($pairing['white_id'] === $userId) ? $pairing['black_id'] : $pairing['white_id']);
$opponentName = null;
$opponentAvatar = null;
if ($opponentId) {
$profiles = $db->get('profiles', ['id' => 'eq.' . $opponentId, 'select' => 'display_name,username,avatar_url', 'limit' => 1]);
if (is_array($profiles) && !empty($profiles) && !isset($profiles['error'])) {
$opponentName = $profiles[0]['display_name'] ?? $profiles[0]['username'] ?? null;
$opponentAvatar = $profiles[0]['avatar_url'] ?? null;
}
// Get opponent name
$opponents = $db->get('profiles', ['id' => 'eq.' . $opponentId, 'select' => 'display_name,avatar_url', 'limit' => 1]);
$opponent = is_array($opponents) && !empty($opponents) ? $opponents[0] : null;
$pending[] = [
'tournament_id' => $tid,
'tournament_name' => $tournament['name'],
'round_id' => $round['id'],
'round_number' => $round['round_number'],
'pairing_index' => $idx,
'opponent_id' => $opponentId,
'opponent_name' => $opponent['display_name'] ?? 'Unknown',
'opponent_avatar' => $opponent['avatar_url'] ?? null,
'is_bye' => false,
'time_control' => $tournament['time_control'],
'game_key' => $tournament['game_key']
];
}
}
jsonResponse(['pending' => $pending]);
}
function mapToSwissResult(string $result, string $whiteId, string $blackId, string $reporterId): string {
switch ($result) {
case 'white_wins':
case 'win':
return '1-0';
case 'black_wins':
case 'loss':
return '0-1';
case 'draw':
return '1/2-1/2';
default:
return $result;
$pending[] = [
'tournament_id' => $tournament['id'],
'tournament_name' => $tournament['name'],
'round_id' => $round['id'],
'round_number' => (int)$round['round_number'],
'board' => $pairing['board'] ?? null,
'pairing_id' => $pairing['pairing_id'] ?? null,
'color' => $isBye ? null : (($pairing['white_id'] === $userId) ? 'w' : 'b'),
'opponent_id' => $opponentId,
'opponent_name' => $opponentName,
'opponent_avatar' => $opponentAvatar,
'is_bye' => $isBye,
'time_control' => $tournament['time_control'],
'game_key' => $tournament['game_key'],
];
}
}
function determineWinner(string $result, array $match): ?string {
switch ($result) {
case 'white_wins':
return $match['white_player_id'];
case 'black_wins':
return $match['black_player_id'];
default:
return null;
}
}
function reportToSwissApi(string $swissId, string $roundId, int $pairingIndex, string $result): void {
$url = SWISS_API . '/tournaments/' . $swissId . '/rounds/' . $roundId . '/results';
$body = json_encode(['pairing_index' => $pairingIndex, 'result' => $result]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_exec($ch);
curl_close($ch);
jsonResponse(['pending' => $pending]);
}
......@@ -7,6 +7,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
$method = $_SERVER['REQUEST_METHOD'];
......@@ -76,38 +78,107 @@ if ($method === 'POST') {
$input = getInput();
$action = $input['action'] ?? '';
$db = supabase($token);
// Service role throughout: $userId comes from the verified token, so nothing
// here is caller-supplied. Registration used to go through the user's own
// JWT, where an RLS policy could drop the insert and still report success.
$db = supabaseService();
if ($action === 'register') {
$tournamentId = $input['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
$t = tournamentLoad($db, $tournamentId);
if (!$t) jsonError('Tournament not found', 404);
if (!in_array($t['status'], [TOURNAMENT_STATUS_REGISTRATION, TOURNAMENT_STATUS_DRAFT], true)) {
jsonError('Registration is closed for this tournament', 409);
}
if (!empty($t['registration_closes_at']) && strtotime($t['registration_closes_at']) < time()) {
jsonError('Registration has closed', 409);
}
$existing = $db->get('tournament_registrations', [
'tournament_id' => 'eq.' . $tournamentId,
'player_id' => 'eq.' . $userId,
'select' => 'id'
'select' => 'id,status',
'limit' => 1,
]);
if (!empty($existing) && !isset($existing['error'])) jsonError('Already registered');
$existing = (is_array($existing) && !empty($existing) && !isset($existing['error'])) ? $existing[0] : null;
if ($existing && ($existing['status'] ?? '') === 'registered') {
jsonResponse(['success' => true, 'already_registered' => true]);
}
$result = $db->insert('tournament_registrations', [
'tournament_id' => $tournamentId,
'player_id' => $userId,
'status' => 'registered'
// Count against the cap only among players who are actually in.
$regs = $db->get('tournament_registrations', [
'tournament_id' => 'eq.' . $tournamentId,
'status' => 'eq.registered',
'select' => 'id',
]);
$count = (is_array($regs) && !isset($regs['error'])) ? count($regs) : 0;
if (!empty($t['max_players']) && $count >= (int)$t['max_players']) {
jsonError('Tournament is full', 409);
}
if ($existing) {
// Re-joining after withdrawing: revive the row rather than inserting
// a second one, which the old code would have done.
$res = $db->update('tournament_registrations', [
'status' => 'registered',
'withdrawn_at' => null,
], ['id' => 'eq.' . $existing['id']]);
} else {
$res = $db->insert('tournament_registrations', [
'tournament_id' => $tournamentId,
'player_id' => $userId,
'status' => 'registered',
]);
}
if (isset($res['error'])) jsonError($res['error']);
if (isset($result['error'])) jsonError($result['error']);
jsonResponse(['success' => true]);
jsonResponse(['success' => true, 'player_count' => $count + 1]);
}
if ($action === 'cancel') {
$tournamentId = $input['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
$db->delete('tournament_registrations', [
$t = tournamentLoad($db, $tournamentId);
if (!$t) jsonError('Tournament not found', 404);
// Once play has begun a player withdraws; the row is kept so their
// completed games keep counting towards opponents' tiebreaks.
$status = ($t['status'] === TOURNAMENT_STATUS_IN_PROGRESS) ? 'withdrawn' : 'cancelled';
$db->update('tournament_registrations', [
'status' => $status,
'withdrawn_at' => gmdate('c'),
], [
'tournament_id' => 'eq.' . $tournamentId,
'player_id' => 'eq.' . $userId
'player_id' => 'eq.' . $userId,
]);
jsonResponse(['success' => true]);
jsonResponse(['success' => true, 'status' => $status]);
}
if ($action === 'check-start') {
// Lets a client nudge a due tournament into starting without waiting for
// the scheduler tick. Idempotent, and it only ever starts a tournament
// whose own start time has already passed.
$tournamentId = $input['tournament_id'] ?? '';
if (!$tournamentId) jsonError('tournament_id required');
$t = tournamentLoad($db, $tournamentId);
if (!$t) jsonError('Tournament not found', 404);
if ($t['status'] === TOURNAMENT_STATUS_IN_PROGRESS) {
jsonResponse(['started' => true, 'current_round' => (int)$t['current_round']]);
}
if (empty($t['auto_start']) || empty($t['starts_at']) || strtotime($t['starts_at']) > time()) {
jsonResponse(['started' => false, 'reason' => 'not_due']);
}
$res = tournamentStart($db, $tournamentId);
jsonResponse(['started' => (bool)$res['ok'], 'reason' => $res['error']]);
}
}
......
......@@ -14,8 +14,20 @@ if (file_exists($envFile)) {
define('SUPABASE_URL', getenv('SUPABASE_URL') ?: 'https://safe-supabase-kong.caprover.al-arcade.com');
define('SUPABASE_ANON_KEY', getenv('SUPABASE_ANON_KEY') ?: '');
define('SUPABASE_SERVICE_KEY', getenv('SUPABASE_SERVICE_KEY') ?: '');
// Signing secret for Supabase-issued JWTs. When set, api requests verify tokens
// in-process instead of calling GoTrue on every single request — which matters a
// great deal when every live game polls twice a second.
define('SUPABASE_JWT_SECRET', getenv('SUPABASE_JWT_SECRET') ?: '');
define('SUPABASE_REST', SUPABASE_URL . '/rest/v1');
define('SUPABASE_AUTH', SUPABASE_URL . '/auth/v1');
define('SUPABASE_STORAGE', SUPABASE_URL . '/storage/v1');
define('STOCKFISH_API', getenv('STOCKFISH_API') ?: 'https://stockfishapi.caprover.al-arcade.com');
define('SWISS_API', getenv('SWISS_API') ?: 'https://swissapi.caprover.al-arcade.com/api/v1');
// The external Swiss service rejects every unauthenticated request. EL3AB now
// runs its own pairing engine, so this is optional: set it only to mirror a
// tournament out to that service as well.
define('SWISS_API_KEY', getenv('SWISS_API_KEY') ?: '');
// Shared secret for api/cron.php (auto-start, no-show forfeits, stale cleanup).
define('CRON_SECRET', getenv('CRON_SECRET') ?: '');
......@@ -106,7 +106,6 @@ class SupabaseClient {
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($response, true);
if ($httpCode >= 400) {
......
......@@ -27,21 +27,7 @@ function requireAuth(): string {
echo json_encode(['error' => 'Invalid or expired token']);
exit;
}
// Ban enforcement — check if player is banned
$userId = $user['id'] ?? null;
if ($userId) {
require_once __DIR__ . '/supabase.php';
$sdb = supabaseService();
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'is_banned,ban_expires_at', 'limit' => 1]);
if (!empty($profiles) && !isset($profiles['error']) && ($profiles[0]['is_banned'] ?? false)) {
$banExpires = $profiles[0]['ban_expires_at'] ?? null;
if (!$banExpires || strtotime($banExpires) > time()) {
http_response_code(403);
echo json_encode(['error' => 'Account banned', 'ban_expires_at' => $banExpires]);
exit;
}
}
}
assertNotBanned($user['id'] ?? null);
return $token;
}
......@@ -58,26 +44,32 @@ function requireAuthUser(): array {
echo json_encode(['error' => 'Invalid or expired token']);
exit;
}
$userId = $user['id'] ?? null;
if ($userId) {
require_once __DIR__ . '/supabase.php';
$sdb = supabaseService();
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'is_banned,ban_expires_at', 'limit' => 1]);
if (!empty($profiles) && !isset($profiles['error']) && ($profiles[0]['is_banned'] ?? false)) {
$banExpires = $profiles[0]['ban_expires_at'] ?? null;
if (!$banExpires || strtotime($banExpires) > time()) {
http_response_code(403);
echo json_encode(['error' => 'Account banned', 'ban_expires_at' => $banExpires]);
exit;
}
}
}
assertNotBanned($user['id'] ?? null);
return $user;
}
/**
* Resolve a bearer token to a user.
*
* Every API call runs through here, and each live game polls twice a second, so
* this used to make an upstream GoTrue round-trip per poll per player. A 64-board
* tournament was generating tens of auth requests a second before a single move
* was processed.
*
* Two mitigations, in order of preference:
* 1. If the JWT signing secret is configured, verify HS256 locally — no network
* at all. This is what Supabase itself does to validate its own tokens.
* 2. Otherwise fall back to the GoTrue call, but memoise the answer briefly so
* a burst of polls costs one request rather than dozens.
*/
function verifyToken(string $token): ?array {
$url = SUPABASE_AUTH . '/user';
$ch = curl_init($url);
$local = verifyTokenLocally($token);
if ($local !== null) return $local;
$cached = authCacheGet('tok:' . hash('sha256', $token));
if ($cached !== null) return $cached ?: null;
$ch = curl_init(SUPABASE_AUTH . '/user');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_ANON_KEY,
......@@ -86,10 +78,101 @@ function verifyToken(string $token): ?array {
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) return null;
return json_decode($response, true);
if ($httpCode !== 200) {
// Cache the rejection too, briefly, so a client stuck retrying with a
// dead token cannot hammer the auth service.
authCacheSet('tok:' . hash('sha256', $token), false, 30);
return null;
}
$user = json_decode($response, true);
if (is_array($user)) authCacheSet('tok:' . hash('sha256', $token), $user, AUTH_CACHE_TTL);
return $user;
}
/**
* Verify the JWT signature and expiry without leaving the process.
* Returns null when no secret is configured, so the caller falls back to GoTrue.
*/
function verifyTokenLocally(string $token): ?array {
$secret = SUPABASE_JWT_SECRET;
if ($secret === '') return null;
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
[$h64, $p64, $s64] = $parts;
$header = json_decode(base64UrlDecode($h64), true);
if (!is_array($header) || ($header['alg'] ?? '') !== 'HS256') return null;
$expected = hash_hmac('sha256', $h64 . '.' . $p64, $secret, true);
if (!hash_equals($expected, base64UrlDecode($s64))) return null;
$payload = json_decode(base64UrlDecode($p64), true);
if (!is_array($payload) || empty($payload['sub'])) return null;
if (isset($payload['exp']) && (int)$payload['exp'] < time()) return null;
return ['id' => $payload['sub'], 'role' => $payload['role'] ?? 'authenticated', 'email' => $payload['email'] ?? null];
}
function base64UrlDecode(string $v): string {
return (string)base64_decode(strtr($v, '-_', '+/') . str_repeat('=', (4 - strlen($v) % 4) % 4));
}
/**
* Tiny short-lived cache. Uses APCu when the extension is present and falls back
* to temp files, so it works on a stock php:apache image with no extra services.
*/
const AUTH_CACHE_TTL = 60;
function authCacheGet(string $key) {
if (function_exists('apcu_fetch')) {
$ok = false;
$v = apcu_fetch($key, $ok);
return $ok ? $v : null;
}
$f = sys_get_temp_dir() . '/el3ab_auth_' . hash('sha256', $key);
if (!is_file($f)) return null;
$raw = @file_get_contents($f);
if ($raw === false) return null;
$row = json_decode($raw, true);
if (!is_array($row) || ($row['exp'] ?? 0) < time()) { @unlink($f); return null; }
return $row['v'];
}
function authCacheSet(string $key, $value, int $ttl): void {
if (function_exists('apcu_store')) { apcu_store($key, $value, $ttl); return; }
$f = sys_get_temp_dir() . '/el3ab_auth_' . hash('sha256', $key);
@file_put_contents($f, json_encode(['exp' => time() + $ttl, 'v' => $value]), LOCK_EX);
}
/**
* Ban check, memoised for the same reason. A ban therefore takes effect within
* a minute rather than instantly, which is an acceptable trade for removing a
* database query from every single request.
*/
function assertNotBanned(?string $userId): void {
if (!$userId) return;
$cacheKey = 'ban:' . $userId;
$cached = authCacheGet($cacheKey);
if ($cached === null) {
require_once __DIR__ . '/supabase.php';
$sdb = supabaseService();
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'is_banned,ban_expires_at', 'limit' => 1]);
$cached = (is_array($profiles) && !empty($profiles) && !isset($profiles['error'])) ? $profiles[0] : [];
authCacheSet($cacheKey, $cached, AUTH_CACHE_TTL);
}
if (!empty($cached['is_banned'])) {
$banExpires = $cached['ban_expires_at'] ?? null;
if (!$banExpires || strtotime($banExpires) > time()) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Account banned', 'ban_expires_at' => $banExpires]);
exit;
}
}
}
function getUserId(string $token): ?string {
......
<?php
/**
* Server-side chess helpers.
*
* Two jobs:
* 1. Parse and sanity-check FEN so the server can reason about a match without
* trusting whatever the client claims happened.
* 2. Turn "how the game ended" into a value the `public.match_result` enum
* actually accepts, deriving the winner from server-known facts rather than
* from the reporting client's word for it.
*
* The result enum is deliberately kept to the coarse set the clients already
* understand — white_wins / black_wins / draw / aborted. The precise reason
* (checkmate, timeout, resignation, ...) is recorded in matches.metadata.end_reason,
* so we lose no detail while staying inside a vocabulary every reader agrees on.
*/
const CHESS_START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
/** Values `public.match_result` will accept. Verified against the live schema. */
const MATCH_RESULT_ENUM = [
'white_wins', 'black_wins', 'draw',
'white_timeout', 'black_timeout',
'white_resign', 'black_resign',
'white_abandon', 'black_abandon',
'stalemate', 'insufficient_material', 'threefold_repetition',
'fifty_moves', 'mutual_draw', 'aborted',
];
/** The subset we actually write, so every client can decode it without a lookup table. */
const MATCH_RESULT_CANONICAL = ['white_wins', 'black_wins', 'draw', 'aborted'];
/** Reasons a game can end. Anything else is normalised to 'unknown'. */
const CHESS_END_REASONS = [
'checkmate', 'resign', 'timeout', 'abandon', 'stalemate', 'agreement',
'insufficient_material', 'threefold_repetition', 'fifty_moves', 'aborted', 'unknown',
];
/**
* Split a FEN into its six fields. Returns null when the string is not a
* structurally valid FEN — callers must treat null as "reject this input".
*/
function chessParseFen(?string $fen): ?array {
if (!is_string($fen) || $fen === '') return null;
$parts = preg_split('/\s+/', trim($fen));
if (count($parts) < 4) return null;
[$placement, $side, $castling, $enPassant] = $parts;
$halfmove = isset($parts[4]) ? (int)$parts[4] : 0;
$fullmove = isset($parts[5]) ? (int)$parts[5] : 1;
if ($side !== 'w' && $side !== 'b') return null;
if (!preg_match('/^[prnbqkPRNBQK1-8\/]+$/', $placement)) return null;
$ranks = explode('/', $placement);
if (count($ranks) !== 8) return null;
$counts = [];
foreach ($ranks as $rank) {
$files = 0;
for ($i = 0, $n = strlen($rank); $i < $n; $i++) {
$c = $rank[$i];
if ($c >= '1' && $c <= '8') {
$files += (int)$c;
} else {
$files++;
$counts[$c] = ($counts[$c] ?? 0) + 1;
}
}
if ($files !== 8) return null;
}
// Exactly one king each, or the position is not a chess position at all.
if (($counts['K'] ?? 0) !== 1 || ($counts['k'] ?? 0) !== 1) return null;
return [
'placement' => $placement,
'side' => $side,
'castling' => $castling,
'en_passant'=> $enPassant,
'halfmove' => $halfmove,
'fullmove' => $fullmove,
'has_counters' => isset($parts[5]), // some clients omit the last two fields
'counts' => $counts,
'material' => array_sum($counts),
];
}
/** 'w' | 'b' | null — whose turn it is in this FEN. */
function chessSideToMove(?string $fen): ?string {
$p = chessParseFen($fen);
return $p ? $p['side'] : null;
}
/**
* Guard against a client posting a position that could not have followed the
* previous one. This is a cheap tamper check, not a legality proof: a full
* move generator is deliberately out of scope, because a subtly wrong one
* would reject legal moves and break live games — a far worse failure than the
* one it prevents.
*
* Returns [ok, reason].
*/
function chessValidateTransition(?string $prevFen, string $nextFen, string $moverColor): array {
$next = chessParseFen($nextFen);
if (!$next) return [false, 'malformed_fen'];
// After a move by $moverColor it must be the other side's turn.
$expectedSide = $moverColor === 'w' ? 'b' : 'w';
if ($next['side'] !== $expectedSide) return [false, 'side_to_move_mismatch'];
if ($prevFen === null || $prevFen === '') return [true, 'ok'];
$prev = chessParseFen($prevFen);
if (!$prev) return [true, 'ok']; // Previous state was already bad; don't compound it.
// The mover must actually have been on move in the previous position.
if ($prev['side'] !== $moverColor) return [false, 'not_your_turn'];
// A single move can capture at most one piece, and can never add material.
$delta = $prev['material'] - $next['material'];
if ($delta < 0 || $delta > 1) return [false, 'material_delta'];
// Promotion aside, no piece type may increase in count.
foreach ($next['counts'] as $piece => $count) {
$before = $prev['counts'][$piece] ?? 0;
if ($count > $before) {
$isPromotionPiece = in_array($piece, ['Q', 'R', 'B', 'N', 'q', 'r', 'b', 'n'], true);
if (!$isPromotionPiece || $count > $before + 1) return [false, 'illegal_piece_gain'];
}
}
// Move counters must advance exactly as the rules require. This is what
// catches a client replaying an earlier position: the numbers cannot go
// backwards, and they cannot stand still across Black's move.
if ($prev['has_counters'] && $next['has_counters']) {
$expectedFullmove = $prev['fullmove'] + ($moverColor === 'b' ? 1 : 0);
if ($next['fullmove'] !== $expectedFullmove) return [false, 'fullmove_mismatch'];
// The halfmove clock resets to 0 on a pawn move or a capture, and
// otherwise increments by one.
$captured = ($prev['material'] - $next['material']) === 1;
if ($next['halfmove'] !== 0 && $next['halfmove'] !== $prev['halfmove'] + 1) {
return [false, 'halfmove_mismatch'];
}
if ($captured && $next['halfmove'] !== 0) return [false, 'halfmove_not_reset'];
}
return [true, 'ok'];
}
/**
* Derive the authoritative result from facts the server knows, never from the
* reporting client's claim about who won.
*
* @param string $reason One of CHESS_END_REASONS.
* @param string|null $finalFen Position at the end of the game.
* @param string|null $actorColor 'w'|'b' — who resigned / flagged / left, when relevant.
* @return array{result:string, reason:string, winner:?string} winner: 'white'|'black'|null
*/
function chessDeriveResult(string $reason, ?string $finalFen, ?string $actorColor): array {
$reason = in_array($reason, CHESS_END_REASONS, true) ? $reason : 'unknown';
$sideToMove = chessSideToMove($finalFen);
$win = function (string $colour) use ($reason) {
return [
'result' => $colour === 'w' ? 'white_wins' : 'black_wins',
'reason' => $reason,
'winner' => $colour === 'w' ? 'white' : 'black',
];
};
$drawn = ['result' => 'draw', 'reason' => $reason, 'winner' => null];
switch ($reason) {
case 'checkmate':
// The mated side is the side to move. Fully server-derived — the
// client cannot claim a win it did not deliver.
if ($sideToMove === null) return $drawn;
return $win($sideToMove === 'w' ? 'b' : 'w');
case 'resign':
case 'abandon':
// The actor is the one who quit, so the actor loses.
if ($actorColor !== 'w' && $actorColor !== 'b') return $drawn;
return $win($actorColor === 'w' ? 'b' : 'w');
case 'timeout':
// The flagged side is the actor when we know it, otherwise the side
// whose clock was running (the side to move).
$loser = ($actorColor === 'w' || $actorColor === 'b') ? $actorColor : $sideToMove;
if ($loser === null) return $drawn;
return $win($loser === 'w' ? 'b' : 'w');
case 'stalemate':
case 'agreement':
case 'insufficient_material':
case 'threefold_repetition':
case 'fifty_moves':
return $drawn;
case 'aborted':
return ['result' => 'aborted', 'reason' => 'aborted', 'winner' => null];
default:
return $drawn;
}
}
/** Winner uuid for a derived result, or null for a draw. */
function chessWinnerId(?string $winner, ?string $whiteId, ?string $blackId): ?string {
if ($winner === 'white') return $whiteId;
if ($winner === 'black') return $blackId;
return null;
}
/** Points for the Swiss table: [whitePoints, blackPoints]. */
function chessResultPoints(string $result): array {
if ($result === 'white_wins') return [1.0, 0.0];
if ($result === 'black_wins') return [0.0, 1.0];
if ($result === 'aborted') return [0.0, 0.0];
return [0.5, 0.5];
}
/** Milliseconds on the clock for a `public.time_control` value. */
function chessTimeControlMs(string $timeControl): array {
if ($timeControl === 'custom') return ['initial' => 600000, 'increment' => 0];
$parts = explode('_', $timeControl);
$minutes = isset($parts[1]) ? (int)$parts[1] : 10;
$increment = isset($parts[2]) ? (int)$parts[2] : 0;
return ['initial' => $minutes * 60 * 1000, 'increment' => $increment * 1000];
}
/** Values `public.time_control` will accept. Verified against the live schema. */
const TIME_CONTROL_ENUM = [
'bullet_1_0', 'bullet_1_1', 'bullet_2_1',
'blitz_3_0', 'blitz_3_2', 'blitz_5_0', 'blitz_5_3',
'rapid_10_0', 'rapid_10_5', 'rapid_15_10', 'rapid_30_0',
'classical_60_0', 'classical_90_30', 'custom',
];
function chessNormaliseTimeControl(?string $tc, string $fallback = 'rapid_10_0'): string {
return (is_string($tc) && in_array($tc, TIME_CONTROL_ENUM, true)) ? $tc : $fallback;
}
......@@ -2,14 +2,22 @@
require_once __DIR__ . '/../config/database.php';
function supabase(?string $token = null): SupabaseClient {
return new SupabaseClient($token);
// Guarded so a test harness can supply an alternative backend (a local Postgres,
// for instance) by declaring these before this file is required. Production
// behaviour is unchanged: nothing else declares them.
if (!function_exists('supabase')) {
function supabase(?string $token = null): SupabaseClient {
return new SupabaseClient($token);
}
}
function supabaseService(): SupabaseClient {
return new SupabaseClient(null, true);
if (!function_exists('supabaseService')) {
function supabaseService(): SupabaseClient {
return new SupabaseClient(null, true);
}
}
if (!function_exists('supabaseRpc')) {
function supabaseRpc(string $functionName, array $params = []): ?array {
$url = SUPABASE_REST . '/rpc/' . $functionName;
$ch = curl_init($url);
......@@ -23,10 +31,11 @@ function supabaseRpc(string $functionName, array $params = []): ?array {
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
return json_decode($result, true);
}
}
function supabaseAuth(string $method, string $endpoint, ?array $body = null, ?string $token = null): array {
$url = SUPABASE_AUTH . '/' . $endpoint;
$ch = curl_init($url);
......@@ -49,7 +58,6 @@ function supabaseAuth(string $method, string $endpoint, ?array $body = null, ?st
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($response, true);
if ($httpCode >= 400) {
......
<?php
/**
* EL3AB native Swiss tournament engine.
*
* Before this file existed, nothing in the platform could start a tournament,
* generate pairings, or advance a round. Tournament data was fetched from an
* external Swiss service that every request reached without an Authorization
* header, so every call returned 401 and every getter silently returned an
* empty array. That is why standings, pairings and brackets have always been
* blank.
*
* This engine owns the whole lifecycle inside EL3AB's own tables:
*
* el3ab_tournaments the tournament (status, current_round, format)
* tournament_registrations who is playing
* el3ab_tournament_rounds one row per round; `pairings` and `results` jsonb
* matches the actual games, linked by tournament_id/round/pairing
*
* Pairing follows the Dutch system in the shape that matters for online play:
* players are sorted by score then rating, split into score groups, and paired
* top-half against bottom-half within each group, subject to "never repeat an
* opponent" and colour balance. It is not a byte-for-byte FIDE C.04.3
* implementation — the transfer/float rules for odd score groups are simplified
* — but it is deterministic, never repeats a pairing, keeps colours balanced,
* and always produces a complete round, which is what an online event needs.
*/
require_once __DIR__ . '/supabase.php';
require_once __DIR__ . '/chess.php';
const TOURNAMENT_STATUS_DRAFT = 'draft';
const TOURNAMENT_STATUS_REGISTRATION = 'registration';
const TOURNAMENT_STATUS_IN_PROGRESS = 'in_progress';
const TOURNAMENT_STATUS_COMPLETED = 'completed';
const TOURNAMENT_STATUS_CANCELLED = 'cancelled';
/** A round is `pending` until paired, `in_progress` while played, then `completed`. */
const ROUND_STATUS_IN_PROGRESS = 'in_progress';
const ROUND_STATUS_COMPLETED = 'completed';
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
function tournamentLoad($sdb, string $tournamentId): ?array {
$rows = $sdb->get('el3ab_tournaments', ['id' => 'eq.' . $tournamentId, 'select' => '*', 'limit' => 1]);
return (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null;
}
function tournamentRounds($sdb, string $tournamentId): array {
$rows = $sdb->get('el3ab_tournament_rounds', [
'tournament_id' => 'eq.' . $tournamentId,
'select' => 'id,round_number,status,pairings,results,started_at,completed_at',
'order' => 'round_number.asc',
]);
if (!is_array($rows) || isset($rows['error'])) return [];
foreach ($rows as &$r) {
$r['pairings'] = tournamentDecodeJson($r['pairings'] ?? null);
$r['results'] = tournamentDecodeJson($r['results'] ?? null);
}
return $rows;
}
function tournamentRound($sdb, string $tournamentId, int $roundNumber): ?array {
foreach (tournamentRounds($sdb, $tournamentId) as $r) {
if ((int)$r['round_number'] === $roundNumber) return $r;
}
return null;
}
/**
* jsonb comes back as an array when written correctly and as a JSON *string*
* when an earlier code path json_encode()d it before writing. Some existing
* rows are double-encoded, so unwrap until we reach a list.
*/
function tournamentDecodeJson($raw): array {
for ($i = 0; $i < 3; $i++) {
if (is_array($raw)) return $raw;
if (!is_string($raw) || $raw === '') return [];
$raw = json_decode($raw, true);
}
return is_array($raw) ? $raw : [];
}
/**
* Everyone registered, with their rating, in a stable seeding order.
* Registration is the authoritative roster — `tournament_players` belongs to
* the separate over-the-board Swiss schema and is not used here.
*/
function tournamentPlayers($sdb, string $tournamentId, string $timeControl = 'rapid_10_0'): array {
$regs = $sdb->get('tournament_registrations', [
'tournament_id' => 'eq.' . $tournamentId,
'status' => 'eq.registered',
'select' => 'player_id,seed,is_bot,registered_at',
'order' => 'registered_at.asc',
]);
if (!is_array($regs) || isset($regs['error']) || empty($regs)) return [];
$ids = array_values(array_unique(array_filter(array_column($regs, 'player_id'))));
if (empty($ids)) return [];
$ratingCol = tournamentRatingColumn($timeControl);
$profiles = $sdb->get('profiles', [
'id' => 'in.(' . implode(',', $ids) . ')',
'select' => "id,display_name,username,avatar_url,{$ratingCol}",
]);
$byId = [];
if (is_array($profiles) && !isset($profiles['error'])) {
foreach ($profiles as $p) $byId[$p['id']] = $p;
}
$players = [];
foreach ($regs as $r) {
$pid = $r['player_id'];
if (!$pid) continue;
$p = $byId[$pid] ?? [];
$players[] = [
'id' => $pid,
'name' => $p['display_name'] ?? $p['username'] ?? 'Player',
'avatar' => $p['avatar_url'] ?? null,
'rating' => (int)($p[$ratingCol] ?? 1200),
'is_bot' => (bool)($r['is_bot'] ?? false),
'seed' => $r['seed'] ?? null,
];
}
// Seed order: explicit seed first, then rating, then name — deterministic.
usort($players, function ($a, $b) {
if ($a['seed'] !== null && $b['seed'] !== null && $a['seed'] !== $b['seed']) {
return $a['seed'] <=> $b['seed'];
}
if ($a['rating'] !== $b['rating']) return $b['rating'] <=> $a['rating'];
return strcmp($a['id'], $b['id']);
});
return $players;
}
function tournamentRatingColumn(string $timeControl): string {
if (str_starts_with($timeControl, 'bullet')) return 'elo_bullet';
if (str_starts_with($timeControl, 'blitz')) return 'elo_blitz';
if (str_starts_with($timeControl, 'classical')) return 'elo_classical';
return 'elo_rapid';
}
// ---------------------------------------------------------------------------
// Standings
// ---------------------------------------------------------------------------
/**
* Build the cross-table from every completed round.
*
* Returns, per player: points, opponents faced, colour history, byes, and the
* tiebreak values (Buchholz cut-1, Buchholz, Sonneborn-Berger) the tournament
* config asks for.
*/
function tournamentStandings($sdb, array $tournament, ?array $rounds = null, ?array $players = null): array {
$tid = $tournament['id'];
$timeControl = $tournament['time_control'] ?? 'rapid_10_0';
$players = $players ?? tournamentPlayers($sdb, $tid, $timeControl);
$rounds = $rounds ?? tournamentRounds($sdb, $tid);
$state = [];
foreach ($players as $p) {
$state[$p['id']] = [
'player_id' => $p['id'],
'name' => $p['name'],
'avatar' => $p['avatar'],
'rating' => $p['rating'],
'is_bot' => $p['is_bot'],
'points' => 0.0,
'wins' => 0, 'draws' => 0, 'losses' => 0, 'byes' => 0,
'games_played' => 0,
'opponents' => [], // player_id => points scored against them
'colors' => [], // 'w' | 'b' per round actually played
'played' => [], // set of opponent ids, for the no-rematch rule
'had_bye' => false,
'results_by_round' => [],
];
}
foreach ($rounds as $round) {
foreach ($round['pairings'] as $pairing) {
$white = $pairing['white_id'] ?? null;
$black = $pairing['black_id'] ?? null;
$result = $pairing['result'] ?? null; // white_wins | black_wins | draw | null
// Bye: one seat empty. Scored at the tournament's bye value.
if ($pairing['is_bye'] ?? false) {
$who = $white ?: $black;
if ($who && isset($state[$who])) {
$byeValue = (float)($tournament['bye_value'] ?? 1.0);
$state[$who]['points'] += $byeValue;
$state[$who]['byes']++;
$state[$who]['had_bye'] = true;
$state[$who]['results_by_round'][(int)$round['round_number']] = 'bye';
}
continue;
}
if (!$white || !$black || !isset($state[$white], $state[$black])) continue;
// Record the encounter even before a result, so the next round never
// pairs the same two players again.
$state[$white]['played'][$black] = true;
$state[$black]['played'][$white] = true;
if ($result === null) continue; // still being played
$state[$white]['colors'][] = 'w';
$state[$black]['colors'][] = 'b';
$state[$white]['games_played']++;
$state[$black]['games_played']++;
[$wp, $bp] = chessResultPoints($result);
$state[$white]['points'] += $wp;
$state[$black]['points'] += $bp;
$state[$white]['opponents'][$black] = ($state[$white]['opponents'][$black] ?? 0) + $wp;
$state[$black]['opponents'][$white] = ($state[$black]['opponents'][$white] ?? 0) + $bp;
if ($wp === 1.0) { $state[$white]['wins']++; $state[$black]['losses']++; }
elseif ($bp === 1.0) { $state[$black]['wins']++; $state[$white]['losses']++; }
else { $state[$white]['draws']++; $state[$black]['draws']++; }
$rn = (int)$round['round_number'];
$state[$white]['results_by_round'][$rn] = $wp === 1.0 ? 'win' : ($wp === 0.5 ? 'draw' : 'loss');
$state[$black]['results_by_round'][$rn] = $bp === 1.0 ? 'win' : ($bp === 0.5 ? 'draw' : 'loss');
}
}
// Tiebreaks need every player's final score, so they run in a second pass.
foreach ($state as $id => &$s) {
$oppScores = [];
$sb = 0.0;
foreach ($s['opponents'] as $oppId => $scoredAgainst) {
$oppPoints = $state[$oppId]['points'] ?? 0.0;
$oppScores[] = $oppPoints;
$sb += $oppPoints * $scoredAgainst; // Sonneborn-Berger
}
sort($oppScores);
$buchholz = array_sum($oppScores);
$buchholzCut1 = count($oppScores) > 1 ? $buchholz - $oppScores[0] : $buchholz;
$s['tiebreaks'] = [
'buchholz_cut_1' => round($buchholzCut1, 2),
'buchholz' => round($buchholz, 2),
'sonneborn_berger' => round($sb, 2),
'wins' => $s['wins'],
];
$s['points'] = round($s['points'], 2);
unset($s['opponents']);
}
unset($s);
$order = $tournament['tiebreak_rules'] ?? ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'];
$order = tournamentDecodeJson($order) ?: ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'];
$list = array_values($state);
usort($list, function ($a, $b) use ($order) {
if ($a['points'] !== $b['points']) return $b['points'] <=> $a['points'];
foreach ($order as $rule) {
$av = $a['tiebreaks'][$rule] ?? 0;
$bv = $b['tiebreaks'][$rule] ?? 0;
if ($av !== $bv) return $bv <=> $av;
}
if ($a['rating'] !== $b['rating']) return $b['rating'] <=> $a['rating'];
return strcmp($a['player_id'], $b['player_id']);
});
$rank = 0;
foreach ($list as $i => &$row) {
$row['rank'] = ++$rank;
}
unset($row);
return $list;
}
// ---------------------------------------------------------------------------
// Pairing
// ---------------------------------------------------------------------------
/** Colour difference: whites minus blacks played so far. */
function tournamentColorDiff(array $colors): int {
$d = 0;
foreach ($colors as $c) { $d += ($c === 'w') ? 1 : -1; }
return $d;
}
/**
* Colour a player is due, from their history.
* Returns 'w', 'b', or null when they have no history at all.
*/
function tournamentDueColor(array $colors): ?string {
$d = tournamentColorDiff($colors);
if ($d > 0) return 'b';
if ($d < 0) return 'w';
$last = end($colors);
if ($last === 'w') return 'b';
if ($last === 'b') return 'w';
return null;
}
/**
* Decide who plays White, following the FIDE colour-allocation order:
* 1. give both players their due colour when they differ;
* 2. otherwise the player with the larger colour imbalance gets their due;
* 3. otherwise look back for the most recent round in which they had
* different colours and alternate from it;
* 4. otherwise the higher-ranked player gets their due colour.
*
* `$boardIndex` only breaks the round-1 case, where nobody has any history:
* alternating down the boards keeps the field balanced from the start.
*
* @return array{0: array, 1: array} [white, black]
*/
function tournamentAssignColors(array $a, array $b, int $boardIndex): array {
$dueA = tournamentDueColor($a['colors']);
$dueB = tournamentDueColor($b['colors']);
// 1. Compatible preferences — everybody gets what they are owed.
if ($dueA === 'w' && $dueB === 'b') return [$a, $b];
if ($dueA === 'b' && $dueB === 'w') return [$b, $a];
// One side has no history at all: satisfy the side that does.
if ($dueA === null && $dueB !== null) return $dueB === 'w' ? [$b, $a] : [$a, $b];
if ($dueB === null && $dueA !== null) return $dueA === 'w' ? [$a, $b] : [$b, $a];
// 4b. Round one — no history on either side. Alternate down the boards so
// the top seeds do not all receive the same colour.
if ($dueA === null && $dueB === null) {
return ($boardIndex % 2 === 0) ? [$a, $b] : [$b, $a];
}
// Both are due the same colour.
$diffA = tournamentColorDiff($a['colors']);
$diffB = tournamentColorDiff($b['colors']);
// 2. The more imbalanced player gets their due colour.
if (abs($diffA) !== abs($diffB)) {
$winner = abs($diffA) > abs($diffB) ? $a : $b;
$other = $winner === $a ? $b : $a;
$due = $winner === $a ? $dueA : $dueB;
return $due === 'w' ? [$winner, $other] : [$other, $winner];
}
// 3. Alternate from the most recent round where their colours differed.
$ca = array_reverse($a['colors']);
$cb = array_reverse($b['colors']);
for ($i = 0; $i < min(count($ca), count($cb)); $i++) {
if ($ca[$i] === $cb[$i]) continue;
// Whoever had Black in that round takes White now.
return ($ca[$i] === 'b') ? [$a, $b] : [$b, $a];
}
// 4. Fall back to rank, with board alternation so it does not always favour
// the same seeds round after round.
$higher = ($a['rating'] >= $b['rating']) ? $a : $b;
$lower = ($higher === $a) ? $b : $a;
return ($boardIndex % 2 === 0) ? [$higher, $lower] : [$lower, $higher];
}
/**
* Generate the pairings for the next round.
*
* @return array{pairings: array, error: ?string}
*/
function tournamentGeneratePairings($sdb, array $tournament, int $roundNumber): array {
$tid = $tournament['id'];
$timeControl = $tournament['time_control'] ?? 'rapid_10_0';
$players = tournamentPlayers($sdb, $tid, $timeControl);
if (count($players) < 2) {
return ['pairings' => [], 'error' => 'Need at least 2 registered players'];
}
$rounds = tournamentRounds($sdb, $tid);
$standings = tournamentStandings($sdb, $tournament, $rounds, $players);
// Round 1 has no scores, so the standings sort degenerates to seed order,
// which is exactly what round 1 pairing wants.
$pool = [];
foreach ($standings as $s) {
$pool[] = [
'id' => $s['player_id'],
'points' => $s['points'],
'rating' => $s['rating'],
'colors' => $s['colors'],
'played' => $s['played'] ?? [],
'had_bye' => $s['had_bye'],
];
}
// A bye goes to the lowest-ranked player who has not had one yet.
$bye = null;
if (count($pool) % 2 === 1) {
for ($i = count($pool) - 1; $i >= 0; $i--) {
if (!$pool[$i]['had_bye']) { $bye = $pool[$i]; array_splice($pool, $i, 1); break; }
}
if ($bye === null) { // everyone already had one; give it to the last
$bye = array_pop($pool);
}
}
// $pool is already in pairing order: score desc, then rating desc.
$pairs = tournamentMatchPool($pool);
if ($pairs === null) {
return ['pairings' => [], 'error' => 'Could not find a valid pairing for this round'];
}
$pairings = [];
$board = 1;
foreach ($pairs as [$a, $b]) {
[$white, $black] = tournamentAssignColors($a, $b, $board - 1);
$pairings[] = [
'board' => $board,
'white_id' => $white['id'],
'black_id' => $black['id'],
'is_bye' => false,
'result' => null,
'match_id' => null,
'pairing_id' => tournamentPairingId($tid, $roundNumber, $board),
];
$board++;
}
if ($bye !== null) {
$pairings[] = [
'board' => $board,
'white_id' => $bye['id'],
'black_id' => null,
'is_bye' => true,
'result' => 'bye',
'match_id' => null,
'pairing_id' => tournamentPairingId($tid, $roundNumber, $board),
];
}
return ['pairings' => $pairings, 'error' => null];
}
/**
* Pair the whole field.
*
* A greedy top-half/bottom-half split cannot honour "never play the same
* opponent twice" — it paints itself into a corner in later rounds and the only
* escape is a rematch. This is a backtracking search instead: take the highest
* unpaired player, try opponents in preference order, and undo the choice if the
* remainder turns out to be unpairable.
*
* $pool must already be in pairing order (score desc, then rating desc).
*
* @return array|null List of [playerA, playerB], or null if no pairing exists.
*/
function tournamentMatchPool(array $pool): ?array {
$n = count($pool);
if ($n === 0) return [];
if ($n % 2 !== 0) return null; // caller must have removed the bye already
$used = array_fill(0, $n, false);
$result = [];
// A hard cap keeps a pathological field from spinning; the greedy first
// choice is almost always accepted, so this is never approached in practice.
$budget = 200000;
$solve = function (int $from) use (&$solve, &$used, &$result, $pool, $n, &$budget): bool {
if ($budget-- <= 0) return false;
// Highest-ranked unpaired player.
$i = $from;
while ($i < $n && $used[$i]) $i++;
if ($i >= $n) return true; // everyone is paired
$used[$i] = true;
foreach (tournamentCandidateOrder($pool, $used, $i, $n) as $j) {
$used[$j] = true;
$result[] = [$pool[$i], $pool[$j]];
if ($solve($i + 1)) return true;
array_pop($result);
$used[$j] = false;
}
$used[$i] = false;
return false;
};
if ($solve(0)) return $result;
// No rematch-free pairing exists. Rather than fail the round, allow
// rematches — a repeat game is far better than a tournament that stalls.
$used = array_fill(0, $n, false);
$result = [];
for ($i = 0; $i < $n; $i++) {
if ($used[$i]) continue;
$used[$i] = true;
for ($j = $i + 1; $j < $n; $j++) {
if ($used[$j]) continue;
$used[$j] = true;
$result[] = [$pool[$i], $pool[$j]];
break;
}
}
return $result;
}
/**
* Opponents for $pool[$i], best first.
*
* Preference is dominated by score proximity — Swiss wants players on the same
* score to meet — and then by the ideal "top half meets bottom half" distance
* inside that score group. Anyone already played is excluded outright.
*/
function tournamentCandidateOrder(array $pool, array $used, int $i, int $n): array {
$me = $pool[$i];
// Where the same-score group ends, so we can aim at its far half.
$groupEnd = $i;
while ($groupEnd + 1 < $n && $pool[$groupEnd + 1]['points'] === $me['points']) $groupEnd++;
$groupStart = $i;
while ($groupStart > 0 && $pool[$groupStart - 1]['points'] === $me['points']) $groupStart--;
$groupSize = $groupEnd - $groupStart + 1;
$idealOffset = intdiv($groupSize, 2); // top half meets bottom half
$ideal = $groupStart + (($i - $groupStart) + $idealOffset);
$candidates = [];
for ($j = $i + 1; $j < $n; $j++) {
if ($used[$j]) continue;
if (isset($me['played'][$pool[$j]['id']])) continue; // never a rematch
$scoreGap = abs($me['points'] - $pool[$j]['points']);
$cost = $scoreGap * 1000 + abs($j - $ideal);
// Nudge away from pairs where both players want the same colour, so the
// colour allocator is rarely forced to break someone's due colour.
$dueA = tournamentDueColor($me['colors']);
$dueB = tournamentDueColor($pool[$j]['colors']);
if ($dueA !== null && $dueA === $dueB) $cost += 3;
$candidates[] = ['j' => $j, 'cost' => $cost];
}
usort($candidates, fn($x, $y) => $x['cost'] <=> $y['cost'] ?: $x['j'] <=> $y['j']);
return array_column($candidates, 'j');
}
/**
* A stable identifier for one pairing.
*
* This is what makes "create the match for my pairing" idempotent: both players
* derive the same id, so the second one to arrive finds the first one's match
* instead of creating a duplicate. Duplicate matches for a single pairing are
* what left each player alone on their own board inside tournaments.
*/
function tournamentPairingId(string $tournamentId, int $roundNumber, int $board): string {
return tournamentUuidFrom($tournamentId . ':' . $roundNumber . ':' . $board);
}
/**
* The match id for a pairing, derived from the same inputs on both clients.
*
* This is the mechanism that makes match creation race-free without a schema
* change: both players compute the same id and INSERT it, so the primary key on
* `matches` guarantees exactly one of them wins and the other reads back the
* existing row. Previously both inserted, each got their own match, and each sat
* alone on a board waiting for an opponent who was in the other game.
*/
function tournamentMatchIdFor(string $tournamentId, int $roundNumber, int $board): string {
return tournamentUuidFrom($tournamentId . ':' . $roundNumber . ':' . $board . ':match');
}
/** Deterministic uuid-shaped string from arbitrary input. */
function tournamentUuidFrom(string $seed): string {
$h = md5($seed);
return substr($h, 0, 8) . '-' . substr($h, 8, 4) . '-' . substr($h, 12, 4)
. '-' . substr($h, 16, 4) . '-' . substr($h, 20, 12);
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/**
* Move a tournament from registration into round 1.
* Idempotent: calling it twice will not create a second round 1.
*
* @return array{ok: bool, error: ?string, round: ?array}
*/
function tournamentStart($sdb, string $tournamentId): array {
$t = tournamentLoad($sdb, $tournamentId);
if (!$t) return ['ok' => false, 'error' => 'Tournament not found', 'round' => null];
if ($t['status'] === TOURNAMENT_STATUS_IN_PROGRESS) {
$existing = tournamentRound($sdb, $tournamentId, max(1, (int)$t['current_round']));
return ['ok' => true, 'error' => null, 'round' => $existing];
}
if (!in_array($t['status'], [TOURNAMENT_STATUS_DRAFT, TOURNAMENT_STATUS_REGISTRATION], true)) {
return ['ok' => false, 'error' => 'Tournament is ' . $t['status'], 'round' => null];
}
$players = tournamentPlayers($sdb, $tournamentId, $t['time_control'] ?? 'rapid_10_0');
$minPlayers = max(2, (int)($t['min_players'] ?? 2));
if (count($players) < $minPlayers) {
return ['ok' => false, 'error' => "Not enough players (" . count($players) . "/{$minPlayers})", 'round' => null];
}
// Claim the start conditionally so two concurrent triggers cannot both pair
// round 1. Only the request that flips the status proceeds.
$claim = $sdb->update('el3ab_tournaments', [
'status' => TOURNAMENT_STATUS_IN_PROGRESS,
'current_round' => 0,
'updated_at' => gmdate('c'),
], [
'id' => 'eq.' . $tournamentId,
'status' => 'in.(' . TOURNAMENT_STATUS_DRAFT . ',' . TOURNAMENT_STATUS_REGISTRATION . ')',
]);
if (isset($claim['error']) || empty($claim)) {
$existing = tournamentRound($sdb, $tournamentId, 1);
return ['ok' => (bool)$existing, 'error' => $existing ? null : 'Could not start', 'round' => $existing];
}
$t['status'] = TOURNAMENT_STATUS_IN_PROGRESS;
return tournamentOpenRound($sdb, $t, 1);
}
/**
* Pair and open a specific round.
*
* @return array{ok: bool, error: ?string, round: ?array}
*/
function tournamentOpenRound($sdb, array $tournament, int $roundNumber): array {
$tid = $tournament['id'];
$existing = tournamentRound($sdb, $tid, $roundNumber);
if ($existing) {
return ['ok' => true, 'error' => null, 'round' => $existing];
}
$gen = tournamentGeneratePairings($sdb, $tournament, $roundNumber);
if ($gen['error']) return ['ok' => false, 'error' => $gen['error'], 'round' => null];
if (empty($gen['pairings'])) return ['ok' => false, 'error' => 'No pairings could be generated', 'round' => null];
$inserted = $sdb->insert('el3ab_tournament_rounds', [
'tournament_id' => $tid,
'round_number' => $roundNumber,
'status' => ROUND_STATUS_IN_PROGRESS,
'started_at' => gmdate('c'),
'pairings' => $gen['pairings'], // jsonb: pass the array, never a JSON string
'results' => [],
]);
if (isset($inserted['error'])) {
// Most likely another request created it a moment ago.
$existing = tournamentRound($sdb, $tid, $roundNumber);
if ($existing) return ['ok' => true, 'error' => null, 'round' => $existing];
return ['ok' => false, 'error' => 'Failed to create round: ' . $inserted['error'], 'round' => null];
}
$sdb->update('el3ab_tournaments', [
'current_round' => $roundNumber,
'updated_at' => gmdate('c'),
], ['id' => 'eq.' . $tid]);
$round = tournamentRound($sdb, $tid, $roundNumber);
return ['ok' => true, 'error' => null, 'round' => $round];
}
/**
* Record a finished match against its pairing, then close and advance the round
* if that was the last game outstanding.
*
* Called in-process from game.php's finaliseMatch(), so a result can never be
* lost to a failed HTTP call back into our own web server.
*/
function tournamentRecordMatchResult($sdb, array $match, array $derived): void {
$tid = $match['tournament_id'] ?? null;
if (!$tid) return;
$roundNumber = (int)($match['tournament_round'] ?? 0);
if ($roundNumber < 1) return;
$round = tournamentRound($sdb, $tid, $roundNumber);
if (!$round) return;
$pairings = $round['pairings'];
$changed = false;
foreach ($pairings as &$p) {
if (!empty($p['is_bye'])) continue;
$samePair = ($p['white_id'] === $match['white_player_id'] && $p['black_id'] === $match['black_player_id'])
|| ($p['white_id'] === $match['black_player_id'] && $p['black_id'] === $match['white_player_id']);
if (!$samePair) continue;
if (($p['result'] ?? null) !== null) return; // already recorded; stay idempotent
// Store the result from the *pairing's* point of view, which may be the
// reverse of the match's if the colours were swapped when the match was made.
$result = $derived['result'];
$colorsSwapped = ($p['white_id'] !== $match['white_player_id']);
if ($colorsSwapped) {
if ($result === 'white_wins') $result = 'black_wins';
elseif ($result === 'black_wins') $result = 'white_wins';
}
$p['result'] = $result;
$p['match_id'] = $match['id'];
$p['reason'] = $derived['reason'];
$p['completed_at'] = gmdate('c');
$changed = true;
break;
}
unset($p);
if (!$changed) return;
$sdb->update('el3ab_tournament_rounds', [
'pairings' => $pairings,
], ['id' => 'eq.' . $round['id']]);
tournamentMaybeAdvance($sdb, $tid);
}
/**
* If every pairing in the current round has a result, close it and open the
* next one — or finish the tournament when the last round is done.
*/
function tournamentMaybeAdvance($sdb, string $tournamentId): array {
$t = tournamentLoad($sdb, $tournamentId);
if (!$t || $t['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) {
return ['advanced' => false, 'reason' => 'not_in_progress'];
}
$current = (int)($t['current_round'] ?? 0);
if ($current < 1) return ['advanced' => false, 'reason' => 'not_started'];
$round = tournamentRound($sdb, $tournamentId, $current);
if (!$round) return ['advanced' => false, 'reason' => 'no_round'];
foreach ($round['pairings'] as $p) {
if (!empty($p['is_bye'])) continue;
if (($p['result'] ?? null) === null) {
return ['advanced' => false, 'reason' => 'games_outstanding'];
}
}
// Close the round exactly once.
$closed = $sdb->update('el3ab_tournament_rounds', [
'status' => ROUND_STATUS_COMPLETED,
'completed_at' => gmdate('c'),
], ['id' => 'eq.' . $round['id'], 'status' => 'eq.' . ROUND_STATUS_IN_PROGRESS]);
if (isset($closed['error']) || empty($closed)) {
return ['advanced' => false, 'reason' => 'already_closed'];
}
$totalRounds = (int)($t['swiss_rounds'] ?? $t['rounds_total'] ?? 0);
if ($totalRounds < 1) $totalRounds = tournamentSuggestedRounds($sdb, $tournamentId, $t);
if ($current >= $totalRounds) {
tournamentFinish($sdb, $t);
return ['advanced' => false, 'reason' => 'tournament_complete', 'completed' => true];
}
$next = tournamentOpenRound($sdb, $t, $current + 1);
return ['advanced' => (bool)$next['ok'], 'reason' => $next['error'] ?? 'ok', 'round' => $next['round']];
}
/** Standard Swiss length: enough rounds to separate the field. */
function tournamentSuggestedRounds($sdb, string $tournamentId, array $t): int {
$n = count(tournamentPlayers($sdb, $tournamentId, $t['time_control'] ?? 'rapid_10_0'));
if ($n < 2) return 1;
return max(3, min(11, (int)ceil(log($n, 2)) + 1));
}
/** Mark the tournament complete and write the final placings. */
function tournamentFinish($sdb, array $tournament): void {
$standings = tournamentStandings($sdb, $tournament);
foreach ($standings as $row) {
$sdb->update('tournament_registrations', [
'final_standing' => $row['rank'],
], [
'tournament_id' => 'eq.' . $tournament['id'],
'player_id' => 'eq.' . $row['player_id'],
]);
}
$sdb->update('el3ab_tournaments', [
'status' => TOURNAMENT_STATUS_COMPLETED,
'updated_at' => gmdate('c'),
], ['id' => 'eq.' . $tournament['id'], 'status' => 'eq.' . TOURNAMENT_STATUS_IN_PROGRESS]);
}
/**
* Award a walkover when a player has not shown up for their game.
* Keeps a round from stalling forever because one player never appeared.
*/
function tournamentForfeitPairing($sdb, string $tournamentId, int $roundNumber, string $pairingId, string $absentPlayerId): bool {
$round = tournamentRound($sdb, $tournamentId, $roundNumber);
if (!$round) return false;
$pairings = $round['pairings'];
$changed = false;
foreach ($pairings as &$p) {
if (($p['pairing_id'] ?? null) !== $pairingId) continue;
if (($p['result'] ?? null) !== null) return false;
$p['result'] = ($p['white_id'] === $absentPlayerId) ? 'black_wins' : 'white_wins';
$p['reason'] = 'forfeit';
$p['forfeited_by'] = $absentPlayerId;
$p['completed_at'] = gmdate('c');
$changed = true;
break;
}
unset($p);
if (!$changed) return false;
$sdb->update('el3ab_tournament_rounds', ['pairings' => $pairings], ['id' => 'eq.' . $round['id']]);
tournamentMaybeAdvance($sdb, $tournamentId);
return true;
}
/**
* The pairing a player owes a game in, for the current round.
* Returns null when they have already finished, have a bye, or are not playing.
*/
function tournamentPendingPairingFor($sdb, array $tournament, string $playerId): ?array {
$current = (int)($tournament['current_round'] ?? 0);
if ($current < 1) return null;
$round = tournamentRound($sdb, $tournament['id'], $current);
if (!$round || $round['status'] !== ROUND_STATUS_IN_PROGRESS) return null;
foreach ($round['pairings'] as $p) {
$isMine = ($p['white_id'] ?? null) === $playerId || ($p['black_id'] ?? null) === $playerId;
if (!$isMine) continue;
if (($p['result'] ?? null) !== null) return null;
return ['round' => $round, 'pairing' => $p];
}
return null;
}
......@@ -4,16 +4,44 @@
import * as session from './match-session.js';
import * as ui from './match-ui.js';
import * as mp from './multiplayer.js';
import * as store from './store.js';
/**
* When the opponent was last seen, from the heartbeat the server records in
* game_state. Returns 0 when there is nothing to go on.
*/
function opponentHeartbeatAt(data) {
if (!data || !data.game_state) return 0;
try {
const gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state;
if (!gs || typeof gs !== 'object') return 0;
// The server stamps one flat `hb_<playerId>` key per player every 10s.
// Take the most recent one that is not ours.
const mine = 'hb_' + myPlayerId();
let latest = 0;
for (const [key, ts] of Object.entries(gs)) {
if (!key.startsWith('hb_') || key === mine) continue;
const v = typeof ts === 'number' ? ts : Date.parse(ts);
if (Number.isFinite(v) && v > latest) latest = v;
}
return latest;
} catch (e) { return 0; }
}
function myPlayerId() {
try { return store.get('auth.userId'); } catch (e) { return null; }
}
export function start(matchId, gameType, options = {}) {
const { onMove, onGameEnd } = options;
let lastMoveCount = 0;
let lastOpponentSeenAt = 0;
let connectionLost = false;
const sess = session.create(matchId, gameType, {
onOpponentMove: (data) => {
session.markOpponentActive();
ui.hideOpponentThinking();
if (connectionLost) {
......@@ -21,25 +49,28 @@ export function start(matchId, gameType, options = {}) {
ui.showConnectionRestored();
}
// Always forward data so game can process resign/draw/state changes
// Only *evidence of the opponent* counts as opponent activity.
//
// This used to call session.markOpponentActive() unconditionally on every
// poll. Since we poll every 2s regardless of what the opponent is doing,
// the "last seen" timestamp never aged and disconnect and abandon
// detection could never fire — a player whose opponent walked away waited
// forever, which stalls a whole tournament round.
const moveCount = data.move_count || data.current_turn || 0;
if (moveCount > lastMoveCount) {
lastMoveCount = moveCount;
session.markOpponentActive();
}
if (opponentHeartbeatAt(data) > lastOpponentSeenAt) {
lastOpponentSeenAt = opponentHeartbeatAt(data);
session.markOpponentActive();
}
onMove?.(data);
// Detect game ended externally
if (data.status === 'completed') {
onGameEnd?.(data);
}
// Check for opponent ping (they're still connected)
if (data.game_state) {
try {
const gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state;
if (gs.ping) session.markOpponentActive();
} catch (e) {}
}
},
onOpponentDisconnect: () => {
......
......@@ -10,6 +10,7 @@ const DISCONNECT_THRESHOLD = 30000; // Consider opponent disconnected after 30s
const ABANDON_THRESHOLD = 60000; // Auto-win after 60s disconnect
const POLL_INTERVAL = 2000; // Poll for opponent moves every 2s
const RECONNECT_GRACE = 5000; // Wait 5s on page load before assuming fresh start
const RECOVERY_WINDOW_MS = 4 * 60 * 60 * 1000; // rejoin a match for up to 4h
let currentSession = null;
......@@ -34,6 +35,7 @@ export function create(matchId, gameType, options = {}) {
onConnectionLost: options.onConnectionLost || null,
onConnectionRestored: options.onConnectionRestored || null,
opponentDisconnected: false,
abandonReported: false, // latched separately from opponentDisconnected
connectionLost: false // Track whether we are currently in a disconnected state
};
......@@ -70,8 +72,11 @@ export function getRecoverableMatch() {
const saved = localStorage.getItem('el3ab_active_match');
if (!saved) return null;
const data = JSON.parse(saved);
// Only recover if less than 5 minutes old
if (Date.now() - data.timestamp > 300000) {
// Recovery window has to outlast a real game. Five minutes meant a
// classical or long rapid tournament game could not be rejoined after a
// refresh. The server is asked whether the match is still live anyway, so a
// generous window here costs nothing.
if (Date.now() - data.timestamp > RECOVERY_WINDOW_MS) {
localStorage.removeItem('el3ab_active_match');
return null;
}
......@@ -83,6 +88,7 @@ export function getRecoverableMatch() {
export function markOpponentActive() {
if (!currentSession) return;
currentSession.lastOpponentActivity = Date.now();
currentSession.abandonReported = false;
if (currentSession.opponentDisconnected) {
currentSession.opponentDisconnected = false;
currentSession.onOpponentReconnect?.();
......@@ -158,14 +164,18 @@ function startDisconnectWatch() {
const elapsed = Date.now() - currentSession.lastOpponentActivity;
if (elapsed > ABANDON_THRESHOLD && !currentSession.opponentDisconnected) {
// Opponent abandoned — auto-claim win
currentSession.onOpponentAbandon?.();
} else if (elapsed > DISCONNECT_THRESHOLD && !currentSession.opponentDisconnected) {
// Opponent disconnected but within grace period
// These were `else if` on a shared `!opponentDisconnected` guard, so the
// 30s branch set the flag and the 60s abandon branch could then never run.
// They are independent conditions with independent latches.
if (elapsed > DISCONNECT_THRESHOLD && !currentSession.opponentDisconnected) {
currentSession.opponentDisconnected = true;
currentSession.onOpponentDisconnect?.();
}
if (elapsed > ABANDON_THRESHOLD && !currentSession.abandonReported) {
currentSession.abandonReported = true;
currentSession.onOpponentAbandon?.();
}
}, 5000);
}
......@@ -185,8 +195,10 @@ function setupVisibilityHandler() {
net.post(endpoint, { action: 'get', match_id: currentSession.matchId })
.then(data => {
if (data && !data.error && currentSession) {
// Do NOT markOpponentActive() here: coming back to our own tab
// says nothing about whether the opponent is still there.
// onOpponentMove decides that from the data itself.
currentSession.onOpponentMove(data);
markOpponentActive();
}
})
.catch(() => {});
......
......@@ -36,15 +36,23 @@ export function connect() {
try {
const msg = JSON.parse(e.data);
if (msg.event === 'postgres_changes') {
const payload = msg.payload;
// Supabase Realtime nests the row under payload.data; older/self-hosted
// builds put it directly on payload. Accept both rather than silently
// handing every subscriber `undefined`.
const p = msg.payload || {};
const body = p.data || p;
const callbacks = subscriptions[msg.topic];
if (callbacks) {
callbacks.forEach(cb => cb({
type: payload.type, // INSERT, UPDATE, DELETE
new: payload.record,
old: payload.old_record
type: body.type || body.eventType, // INSERT, UPDATE, DELETE
new: body.record ?? body.new,
old: body.old_record ?? body.old
}));
}
} else if (msg.event === 'phx_reply' && msg.payload && msg.payload.status === 'error') {
// A refused channel join used to fail silently, leaving the client
// convinced it was subscribed and waiting forever for updates.
console.warn('[realtime] channel join refused:', msg.topic, msg.payload.response);
}
} catch (err) {
console.warn('[realtime] onmessage error:', err);
......
......@@ -18,18 +18,32 @@ import { STOCKFISH_URL } from '../../../core/config.js';
import * as matchLive from '../../../core/match-live.js';
let board, clock, gameState;
let liveSession = null; // handle for tearing the poll/heartbeat down on exit
export function mountGame(el, params) {
const { mode = 'bot', botId = 'amina', timeControl = 'rapid_10_0', matchId, color } = params;
// For live games without color, resolve from match data (prevents both players seeing white)
let playerColor = color || 'w';
const tc = parseTimeControl(timeControl);
scene.enterGameMode();
// Colour has exactly one source of truth: gameState.playerColor.
//
// There used to be a local `playerColor` alongside it, seeded with
// `color || 'w'`. When a caller supplied no colour BOTH players became White,
// and because the poll only ingests the opponent's move while it is not your
// turn, two Whites never saw each other's moves — each played a private board.
// That is the "playing against yourself" report.
//
// In a live game the colour is only trusted once the server has confirmed it.
// Until then the board is inert: a wrong guess is worse than a short wait.
const isLive = mode === 'live';
const startingColor = (color === 'w' || color === 'b') ? color : 'w';
gameState = {
mode, botId, matchId, playerColor, timeControl,
isPlayerTurn: playerColor === 'w',
mode, botId, matchId, timeControl,
playerColor: startingColor,
colorConfirmed: !isLive || color === 'w' || color === 'b',
isPlayerTurn: startingColor === 'w',
gameOver: false, moveCount: 0,
capturedByPlayer: [], capturedByOpponent: [],
moveHistory: [], botThinking: false,
......@@ -41,65 +55,36 @@ export function mountGame(el, params) {
engine.create();
clock = new ChessClock(tc.time, tc.increment);
// If recovering from a refresh, fetch current state from server and fix color
// Recovering after a refresh or a re-entry. The server is asked for the whole
// truth — colour, position and clocks — before the board becomes interactive.
// This path used to be reached with no colour at all, which meant both
// reconnecting players became White.
if (params.recovered && matchId) {
gameState._recovering = true;
gameState.colorConfirmed = false;
net.post('game.php', { action: 'get', match_id: matchId }).then(data => {
if (!gameState || gameState.matchId !== matchId) return;
if (!data || data.error) { gameState._recovering = false; return; }
// If game already ended (opponent resigned while we were away)
if (data.status === 'completed') {
const myId = store.get('auth.userId');
const isWhite = data.white_player_id === myId;
const isWin = (data.result === 'white_wins' && isWhite) || (data.result === 'black_wins' && !isWhite);
endGame(isWin ? 'win' : data.result === 'draw' ? 'draw' : 'loss', 'resign', { serverAlreadyCompleted: true });
return;
}
// Fix color: determine from match data which side we are
const myId = store.get('auth.userId');
if (data.white_player_id === myId) {
gameState.playerColor = 'w';
if (board) board.flipped = false;
} else {
gameState.playerColor = 'b';
if (board) board.flipped = true;
}
const myColor = (data.my_color === 'w' || data.my_color === 'b')
? data.my_color
: (data.white_player_id && myId && data.white_player_id === myId ? 'w' : 'b');
// Update board canSelect to use correct color
if (board) board.canSelect = (piece) => {
if (gameState.gameOver || gameState.botThinking) return false;
if (!gameState.isPlayerTurn) return false;
const pieceColor = piece === piece.toUpperCase() ? 'w' : 'b';
return pieceColor === gameState.playerColor;
};
// Load position
if (data.current_fen) {
engine.load(data.current_fen);
if (board) board.setPosition(data.current_fen);
gameState.moveCount = data.move_count || 0;
lastKnownMoveCount = data.move_count || 0;
// Determine whose turn
const turnFromFen = data.current_fen.split(' ')[1];
gameState.isPlayerTurn = turnFromFen === gameState.playerColor;
if (!gameState.isPlayerTurn) clock.start(turnFromFen);
else clock.start(gameState.playerColor);
// Show check highlight if king is in check
if (engine.isCheck()) {
board.setCheck(findKing(engine.turn()));
}
// The game may have finished while we were away.
if (data.status === 'completed') {
gameState._recovering = false;
gameState.playerColor = myColor;
gameState.colorConfirmed = true;
endGame(outcomeFromServerResult(data, myColor), endReasonFromServer(data),
{ serverAlreadyCompleted: true });
return;
}
// Restore clocks
if (data.white_time_remaining_ms) clock.white = data.white_time_remaining_ms;
if (data.black_time_remaining_ms) clock.black = data.black_time_remaining_ms;
if (board) board.draw();
applyPlayerColor(myColor);
applyServerPosition(data);
gameState._recovering = false;
}).catch(() => { gameState._recovering = false; });
}).catch(() => { if (gameState) gameState._recovering = false; });
}
el.innerHTML = `
......@@ -177,16 +162,21 @@ export function mountGame(el, params) {
const boardContainer = el.querySelector('#board-container');
board = new ChessBoard(boardContainer, {
flipped: playerColor === 'b',
flipped: gameState.playerColor === 'b',
interactive: true,
onMove: (from, to) => handlePlayerMove(el, from, to)
});
// Every one of these reads gameState.playerColor rather than capturing a
// colour in the closure. Previously the async correction updated gameState but
// not the captured variable, so the clocks displayed on the wrong sides and a
// flag was awarded to the wrong player for the rest of the game.
board.canSelect = (piece) => {
if (gameState.gameOver || gameState.botThinking) return false;
if (!gameState.colorConfirmed) return false; // no guessing before the server answers
if (!gameState.isPlayerTurn) return false;
const pieceColor = piece === piece.toUpperCase() ? 'w' : 'b';
return pieceColor === playerColor;
return pieceColor === gameState.playerColor;
};
board.showLegalMoves = (square) => {
......@@ -199,20 +189,17 @@ export function mountGame(el, params) {
clock.onTick = (w, b) => {
const playerEl = el.querySelector('#clock-player');
const opponentEl = el.querySelector('#clock-opponent');
if (playerColor === 'w') {
playerEl.textContent = clock.format(w);
opponentEl.textContent = clock.format(b);
playerEl.classList.toggle('low-time', clock.isLowTime('w'));
opponentEl.classList.toggle('low-time', clock.isLowTime('b'));
} else {
playerEl.textContent = clock.format(b);
opponentEl.textContent = clock.format(w);
playerEl.classList.toggle('low-time', clock.isLowTime('b'));
opponentEl.classList.toggle('low-time', clock.isLowTime('w'));
}
if (!playerEl || !opponentEl) return;
const [mine, theirs] = gameState.playerColor === 'w' ? [w, b] : [b, w];
const myKey = gameState.playerColor;
const theirKey = gameState.playerColor === 'w' ? 'b' : 'w';
playerEl.textContent = clock.format(mine);
opponentEl.textContent = clock.format(theirs);
playerEl.classList.toggle('low-time', clock.isLowTime(myKey));
opponentEl.classList.toggle('low-time', clock.isLowTime(theirKey));
};
clock.onFlag = (color) => endGame(color === playerColor ? 'loss' : 'win', 'timeout');
clock.onFlag = (color) => endGame(color === gameState.playerColor ? 'loss' : 'win', 'timeout');
// Controls
el.querySelector('#btn-resign').addEventListener('click', async () => {
......@@ -276,72 +263,42 @@ export function mountGame(el, params) {
}
// Start bot if playing as black
if (playerColor === 'b' && mode === 'bot') {
if (gameState.playerColor === 'b' && mode === 'bot') {
clock.start('w');
requestBotMove(el);
}
// Live mode: start match session (enables reconnection on refresh)
if (mode === 'live' && matchId) {
matchLive.start(matchId, 'chess', {
liveSession = matchLive.start(matchId, 'chess', {
onMove: (data) => {
// match-session polls every 2s — process all game events here
handleLivePollData(el, data);
},
onGameEnd: (data) => {
// Already handled by handleLivePollData's status===completed check
// This is a fallback for edge cases (e.g., abandon detection)
if (gameState.gameOver) return;
if (!gameState || gameState.gameOver) return;
if (data && data.result) {
const myId = store.get('auth.userId');
const isWhite = data.white_player_id === myId;
const isWin = (data.result === 'white_wins' && isWhite) || (data.result === 'black_wins' && !isWhite);
const isDraw = data.result === 'draw' || data.result === 'aborted';
endGame(isWin ? 'win' : isDraw ? 'draw' : 'loss', data.result.includes('abandon') ? 'abandon' : 'resign', { serverAlreadyCompleted: true });
} else {
endGame('loss', 'abandon', { serverAlreadyCompleted: true });
endGame(outcomeFromServerResult(data, gameState.playerColor),
endReasonFromServer(data),
{ serverAlreadyCompleted: true });
}
// No result on the row means the match is not actually finished —
// do not invent a loss for this player. The old code did, which turned
// a hiccup in the poll into a recorded defeat.
}
});
if (playerColor === 'b') {
if (gameState.playerColor === 'b') {
gameState.isPlayerTurn = false;
clock.start('w');
}
// Fetch match data to verify color and render opponent profile
let opponentId = params.opponentId;
// Confirm colour against the match row. game.php now returns `my_color`
// computed server-side, so no client has to work out which side it is from
// an id comparison that silently defaults to Black when the id is missing.
if (matchId) {
net.post('game.php', { action: 'get', match_id: matchId }).then(matchData => {
if (!matchData || matchData.error) return;
const myId = store.get('auth.userId');
// Bug 2 fix: verify and correct player color from authoritative server data
const serverColor = matchData.white_player_id === myId ? 'w' : 'b';
if (gameState.playerColor !== serverColor) {
gameState.playerColor = serverColor;
gameState.isPlayerTurn = engine.turn() === serverColor;
if (board) {
board.flipped = serverColor === 'b';
board.canSelect = (piece) => {
if (gameState.gameOver || gameState.botThinking) return false;
if (!gameState.isPlayerTurn) return false;
const pieceColor = piece === piece.toUpperCase() ? 'w' : 'b';
return pieceColor === gameState.playerColor;
};
board.draw();
}
if (serverColor === 'b' && gameState.moveCount === 0) {
clock.start('w');
}
}
// Render opponent profile
const oppId = matchData.white_player_id === myId ? matchData.black_player_id : matchData.white_player_id;
if (oppId) fetchAndRenderOpponent(el, oppId);
}).catch(() => {});
}
if (opponentId) {
fetchAndRenderOpponent(el, opponentId);
confirmColorFromServer(el, matchId, params.opponentId);
} else if (params.opponentId) {
fetchAndRenderOpponent(el, params.opponentId);
}
}
......@@ -633,6 +590,117 @@ function mpLogFront(event, data) {
} catch(e) {}
}
/**
* 'win' | 'loss' | 'draw' from a settled server result.
*
* The server writes metadata.winner ('white' | 'black' | null) alongside the
* result enum, so the outcome no longer depends on string-matching a result
* vocabulary that the two sides disagreed about.
*/
function outcomeFromServerResult(data, myColor) {
const meta = data && data.metadata
? (typeof data.metadata === 'string' ? safeParse(data.metadata) : data.metadata)
: null;
const winner = meta && meta.winner;
if (winner === 'white') return myColor === 'w' ? 'win' : 'loss';
if (winner === 'black') return myColor === 'b' ? 'win' : 'loss';
if (winner === null && meta) return 'draw';
// Older rows have no metadata.winner — fall back to the result enum.
const r = data && data.result;
if (r === 'white_wins') return myColor === 'w' ? 'win' : 'loss';
if (r === 'black_wins') return myColor === 'b' ? 'win' : 'loss';
return 'draw';
}
function endReasonFromServer(data) {
const meta = data && data.metadata
? (typeof data.metadata === 'string' ? safeParse(data.metadata) : data.metadata)
: null;
return (meta && meta.end_reason) || 'unknown';
}
function safeParse(s) {
try { return JSON.parse(s); } catch (e) { return null; }
}
/**
* Ask the server which side we are and apply the answer.
*
* Retries, because being wrong about colour is the single worst failure this
* screen has: both players end up as White and each plays a private board.
* The board stays inert until this succeeds.
*/
async function confirmColorFromServer(el, matchId, opponentIdHint, attempt = 0) {
try {
const data = await net.post('game.php', { action: 'get', match_id: matchId });
if (!data || data.error) throw new Error(data && data.error ? data.error : 'no match data');
if (!gameState || gameState.matchId !== matchId) return; // scene changed under us
const myId = store.get('auth.userId');
// Prefer the server's own answer; fall back to comparing ids only if an
// older server build is still deployed.
const serverColor = (data.my_color === 'w' || data.my_color === 'b')
? data.my_color
: (data.white_player_id && myId && data.white_player_id === myId ? 'w' : 'b');
applyPlayerColor(serverColor);
const oppId = data.opponent_id
|| (data.white_player_id === myId ? data.black_player_id : data.white_player_id)
|| opponentIdHint;
if (oppId) fetchAndRenderOpponent(el, oppId);
// Adopt the server's position if it is ahead of ours (rejoining a game).
if (data.current_fen && (data.move_count || 0) > gameState.moveCount) {
applyServerPosition(data);
}
} catch (e) {
if (attempt < 6 && gameState && gameState.matchId === matchId) {
const delay = Math.min(4000, 400 * Math.pow(2, attempt));
setTimeout(() => confirmColorFromServer(el, matchId, opponentIdHint, attempt + 1), delay);
} else {
mpLogFront('color_confirm_failed', { matchId, error: e.message });
}
}
}
/** Apply a confirmed colour everywhere it matters, in one place. */
function applyPlayerColor(colour) {
if (!gameState) return;
const changed = gameState.playerColor !== colour;
gameState.playerColor = colour;
gameState.colorConfirmed = true;
gameState.isPlayerTurn = engine.turn() === colour;
if (board) {
board.flipped = colour === 'b';
board.draw();
}
if (changed) mpLogFront('color_corrected', { to: colour });
}
/** Load a server position into the local engine, board and clocks. */
function applyServerPosition(data) {
if (!data.current_fen) return;
engine.load(data.current_fen);
if (board) {
board.setPosition(data.current_fen);
board.draw();
}
gameState.moveCount = data.move_count || 0;
lastKnownMoveCount = gameState.moveCount;
gameState.isPlayerTurn = engine.turn() === gameState.playerColor;
if (clock) {
if (data.white_time_remaining_ms != null) clock.white = data.white_time_remaining_ms;
if (data.black_time_remaining_ms != null) clock.black = data.black_time_remaining_ms;
clock.start(engine.turn());
}
if (engine.isCheck() && board) board.setCheck(findKing(engine.turn()));
}
async function sendLiveMove(el) {
if (!gameState.matchId) return;
lastKnownMoveCount = gameState.moveCount;
......@@ -783,15 +851,12 @@ function handleLivePollData(el, data) {
}
}
// Game ended (resign, abandon, draw accepted by opponent)
if (data.status === 'completed' && !gameState.gameOver) {
const result = data.result;
if (result) {
const isWin = (result === 'white_wins' && gameState.playerColor === 'w') ||
(result === 'black_wins' && gameState.playerColor === 'b');
const isDraw = result === 'draw' || result === 'aborted';
endGame(isWin ? 'win' : isDraw ? 'draw' : 'loss', result === 'aborted' ? 'abandon' : 'resign', { serverAlreadyCompleted: true });
}
// Game ended elsewhere: the opponent resigned, a draw was accepted, someone's
// clock ran out, or the scheduler forfeited a no-show.
if (data.status === 'completed' && !gameState.gameOver && data.result) {
endGame(outcomeFromServerResult(data, gameState.playerColor),
endReasonFromServer(data),
{ serverAlreadyCompleted: true });
}
}
......@@ -999,7 +1064,7 @@ function endGame(result, reason, { serverAlreadyCompleted = false } = {}) {
// skip redundant complete call — just show the result
if (serverAlreadyCompleted) {
const fallbackRating = result === 'win' ? 12 : result === 'draw' ? 1 : -8;
if (gameState.tournamentId) reportTournamentResult(result);
if (gameState.tournamentId) nudgeTournament(gameState.matchId, gameState.tournamentId);
setTimeout(() => navigateToResult(fallbackRating, null, null), 1000);
return;
}
......@@ -1010,42 +1075,68 @@ function endGame(result, reason, { serverAlreadyCompleted = false } = {}) {
navigateToResult(fallbackRating, null, null);
}, 5000);
// The server derives the winner itself, from the final position, from who
// resigned, or from whose clock expired. We report only *how* the game ended.
// This endpoint used to reject 'win' and 'loss' outright with "Invalid result
// value", so no decisive game was ever recorded: no rating, no coins, and the
// match stayed in_progress forever.
const tournamentId = gameState.tournamentId;
const matchId = gameState.matchId;
net.post('game.php', {
action: 'complete',
match_id: gameState.matchId || 'local',
result,
match_id: matchId || 'local',
reason,
result, // advisory only — the server does not trust it
fen: engine.fen(),
pgn: engine.pgn(),
opponent_rating: opponentRating,
time_control: gameState.timeControl || 'rapid_10_0'
}).then(data => {
clearTimeout(failsafeTimer);
const ratingChange = data?.rating_change || (result === 'win' ? 12 : result === 'draw' ? 1 : -8);
if (gameState.tournamentId) reportTournamentResult(result);
const ratingChange = (data && data.rating_change != null)
? data.rating_change
: (result === 'win' ? 12 : result === 'draw' ? 1 : -8);
if (tournamentId) nudgeTournament(matchId, tournamentId);
setTimeout(() => navigateToResult(ratingChange, data?.rating_before, data?.rating_after), 1000);
}).catch(() => {
}).catch((e) => {
clearTimeout(failsafeTimer);
if (gameState.tournamentId) reportTournamentResult(result);
mpLogFront('complete_failed', { error: e && e.message });
if (tournamentId) nudgeTournament(matchId, tournamentId);
const fallbackRating = result === 'win' ? 12 : result === 'draw' ? 1 : -8;
setTimeout(() => navigateToResult(fallbackRating, null, null), 1000);
});
}
function reportTournamentResult(result) {
const mappedResult = gameState.playerColor === 'w'
? (result === 'win' ? 'white_wins' : result === 'loss' ? 'black_wins' : 'draw')
: (result === 'win' ? 'black_wins' : result === 'loss' ? 'white_wins' : 'draw');
/**
* Ask the tournament to take the settled result into account.
*
* The server already does this in-process when the match is finalised; this is
* only a nudge for the case where our own complete call failed but the
* opponent's succeeded, so the round still advances promptly.
*/
function nudgeTournament(matchId, tournamentId) {
if (!matchId || !tournamentId) return;
net.post('tournament-match.php', {
action: 'report-result',
match_id: gameState.matchId,
tournament_id: gameState.tournamentId,
result: mappedResult
}).catch(e => console.warn('[tournament] report error:', e));
match_id: matchId,
tournament_id: tournamentId
}).catch(() => {});
}
export function unmountGame() {
// The live session owns a 2s poll, a heartbeat, a disconnect watchdog and the
// `el3ab_active_match` recovery key. None of it was torn down here, so those
// timers kept running after the player left, and the stale recovery key pulled
// them back into a finished game on the next app start — with no colour, which
// is how two players both ended up as White.
if (liveSession) {
try { liveSession.cleanup(); } catch (e) {}
liveSession = null;
}
mp.cleanup();
if (clock) { clock.stop(); clock = null; }
if (board) { board.destroy?.(); board = null; }
gameState = null;
lastKnownMoveCount = 0;
}
......@@ -204,18 +204,16 @@ function startGame(el, params) {
const gameKey = params.gameKey || 'chess';
// Bug 2 fix: fallback color assignment for chess if still undefined
let color = params.color;
if (gameKey === 'chess' && !color) {
const userId = store.get('player.id');
color = params.isHost ? 'w' : 'b';
}
// Colour is deliberately NOT guessed here. Deriving it from isHost was wrong
// whenever both clients believed they were the host, and both then became
// White. When we have not been told, chess-game asks the server.
const color = (params.color === 'w' || params.color === 'b') ? params.color : undefined;
if (gameKey === 'chess') {
scene.replace('chess-game', {
mode: 'live',
matchId: params.matchId,
color: color,
color,
timeControl: params.timeControl,
isFriendly: true
});
......
......@@ -20,7 +20,6 @@ const categories = [
{ key: 'blitz_3_2', label: '3 | 2', sub: '3+2' },
{ key: 'blitz_5_0', labelKey: 'time.5min', sub: '5+0' },
{ key: 'blitz_5_3', label: '5 | 3', sub: '5+3' },
{ key: 'blitz_5_5', label: '5 | 5', sub: '5+5' },
]
},
{
......@@ -29,20 +28,21 @@ const categories = [
{ key: 'rapid_10_0', labelKey: 'time.10min', sub: '10+0' },
{ key: 'rapid_10_5', label: '10 | 5', sub: '10+5' },
{ key: 'rapid_15_10', label: '15 | 10', sub: '15+10' },
{ key: 'rapid_20_0', labelKey: 'time.20min', sub: '20+0' },
{ key: 'rapid_30_0', labelKey: 'time.30min', sub: '30+0' },
]
},
{
name: 'Classical', nameKey: 'time.classical', iconSlot: 'crown', iconFallback: '♔', color: 'var(--purple)',
controls: [
{ key: 'classical_45_45', label: '45 | 45', sub: '45+45' },
{ key: 'classical_60_0', labelKey: 'time.60min', sub: '60+0' },
{ key: 'classical_90_30', label: '90 | 30', sub: '90+30' },
]
}
];
// Only the values in the public.time_control enum are offered. blitz_5_5,
// rapid_20_0 and classical_45_45 used to appear here; the database rejects them,
// so choosing one created no match and dropped the player back to the menu.
export function mountTimeSelect(el, params) {
el.innerHTML = `
<div class="tc-page" style="padding:16px;display:flex;flex-direction:column;gap:16px;height:100%;overflow-y:auto;">
......
......@@ -76,14 +76,14 @@ async function startArena(el, tournamentId, tournamentName) {
const status = await net.get('swiss.php', { action: 'arena-status', tournament_id: tournamentId });
if (status.status === 'in_game' && status.match_id) {
cleanup();
const tc = status.time_control || 'blitz_3_0';
scene.push('chess-game', {
mode: 'live',
matchId: status.match_id,
color: status.color,
timeControl: tc,
timeControl: status.time_control || 'blitz_3_0',
opponentId: status.opponent_id,
tournamentId,
tournamentRound: status.round_number,
recovered: true
});
} else if (status.status === 'paired') {
......@@ -106,18 +106,37 @@ async function startArena(el, tournamentId, tournamentName) {
});
}
function launchArenaMatch(data, tournamentId, tournamentName) {
/**
* Open the arena game for this player.
*
* arena-join only reports *that* we are paired; the match itself is minted by
* tournament-match.php so there is exactly one place that creates a tournament
* match and both players end up on the same board with agreed colours.
*/
async function launchArenaMatch(data, tournamentId, tournamentName) {
cleanup();
audio.play('match_found');
const tc = data.time_control || 'blitz_3_0';
scene.push('chess-game', {
mode: 'live',
matchId: data.match_id,
color: data.color,
timeControl: tc,
opponentId: data.opponent_id,
tournamentId
});
try {
const match = await net.post('tournament-match.php', {
action: 'create-or-join',
tournament_id: tournamentId
});
if (match.error || match.waiting) return;
if (match.bye) { audio.play('reward'); return; }
audio.play('match_found');
scene.push('chess-game', {
mode: 'live',
matchId: match.match_id,
color: match.color,
timeControl: match.time_control || data.time_control || 'blitz_3_0',
opponentId: match.opponent_id,
tournamentId,
tournamentRound: match.round_number,
recovered: !!match.recovered
});
} catch (e) {
console.warn('[arena] could not open match:', e);
}
}
async function loadArenaStandings(el, tournamentId) {
......
......@@ -216,7 +216,11 @@ async function loadRounds(content, tournamentId) {
el.addEventListener('click', async () => {
el.textContent = t('common.loading');
try {
const pd = await net.get('swiss.php', { action: 'pairings', round_id: r.id });
// Pairings are addressed by tournament + round number. `round_id` alone
// was never a parameter the endpoint accepted, so this always came back empty.
const pd = await net.get('swiss.php', {
action: 'pairings', tournament_id: tournamentId, round: r.round_number
});
const pairings = pd.pairings || [];
if (pairings.length === 0) { el.textContent = t('tournament.no_pairings'); return; }
......@@ -240,7 +244,7 @@ async function loadRounds(content, tournamentId) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
audio.play('click');
launchTournamentMatch(tournamentId, btn.dataset.roundId, parseInt(btn.dataset.idx));
launchTournamentMatch(tournamentId);
});
});
} catch (e) {
......@@ -288,7 +292,7 @@ async function loadMyGames(content, tournamentId) {
return `<div class="pairing-row" style="border:1px solid var(--gold);">
<span style="font-size:12px;color:var(--text-muted);">${t('tournament.round_short', { n: p.round_number })}</span>
<span style="font-size:12px;color:var(--text-primary);flex:1;margin:0 8px;">vs ${p.opponent_name || t('common.opponent')}</span>
<button class="play-pending-btn" data-tid="${p.tournament_id}" data-rid="${p.round_id}" data-idx="${p.pairing_index}" style="padding:6px 16px;background:var(--gold);border:none;border-radius:8px;color:#000;font-weight:700;font-size:12px;cursor:pointer;">${t('tournament.play_btn')}</button>
<button class="play-pending-btn" data-tid="${p.tournament_id}" style="padding:6px 16px;background:var(--gold);border:none;border-radius:8px;color:#000;font-weight:700;font-size:12px;cursor:pointer;">${t('tournament.play_btn')}</button>
</div>`;
}).join('');
}
......@@ -318,7 +322,7 @@ async function loadMyGames(content, tournamentId) {
content.querySelectorAll('.play-pending-btn').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
launchTournamentMatch(btn.dataset.tid, btn.dataset.rid, parseInt(btn.dataset.idx));
launchTournamentMatch(btn.dataset.tid);
});
});
} catch (e) {
......@@ -326,19 +330,20 @@ async function loadMyGames(content, tournamentId) {
}
}
async function launchTournamentMatch(tournamentId, roundId, pairingIndex) {
async function launchTournamentMatch(tournamentId) {
try {
// The server resolves which pairing is ours from the current round — the
// client no longer passes a round id or a pairing index, which used to let
// two clients disagree about which board they were on.
const data = await net.post('tournament-match.php', {
action: 'create-or-join',
tournament_id: tournamentId,
round_id: roundId,
pairing_index: pairingIndex
tournament_id: tournamentId
});
if (data.error) throw new Error(data.error);
if (data.bye) { audio.play('reward'); return; }
if (data.waiting) return;
const tc = data.time_control || 'rapid_10_0';
const gameKey = tournamentData?.game_key || 'chess';
const gameScene = gameKey === 'chess' ? 'chess-game' : gameKey + '-game';
......@@ -346,11 +351,11 @@ async function launchTournamentMatch(tournamentId, roundId, pairingIndex) {
mode: 'live',
matchId: data.match_id,
color: data.color,
timeControl: tc,
timeControl: data.time_control || 'rapid_10_0',
opponentId: data.opponent_id,
tournamentId,
tournamentRound: pairingIndex,
recovered: data.already_exists && data.status === 'in_progress'
tournamentRound: data.round_number,
recovered: !!data.recovered
});
} catch (e) {
console.error('[tournament] launch error:', e);
......
......@@ -216,7 +216,15 @@ async function checkGroupInvites(el, groupId, myId) {
if (btn.dataset.game === 'ludo') {
scene.push('ludo-game', { matchId: res.match_id, playerIndex: res.player_index });
} else {
scene.push('chess-game', { matchId: res.match_id, color: res.color });
// mode:'live' is required — without it mountGame defaults to
// 'bot' and the group invite silently started a game against the
// computer instead of against the person who invited you.
scene.push('chess-game', {
mode: 'live',
matchId: res.match_id,
color: res.color,
timeControl: res.time_control
});
}
} else {
btn.textContent = t('group.accepted');
......
......@@ -152,7 +152,7 @@ async function loadTournaments(el) {
${hasPending ? `
<div style="background:rgba(228,172,56,0.1);border:1px solid rgba(228,172,56,0.3);border-radius:10px;padding:10px;display:flex;align-items:center;justify-content:space-between;">
<div style="font-size:12px;color:var(--gold);font-weight:600;">${emoji('swords', '⚔️', 12)} ${t('tournament.match_ready')}${t('tournament.round_short')}${pendingMatch?.round_number || ''} vs ${pendingMatch?.opponent_name || t('tournament.opponent')}</div>
<button class="play-now-btn" data-tid="${tour.id}" data-rid="${pendingMatch?.round_id}" data-idx="${pendingMatch?.pairing_index}" style="background:var(--gold);border:none;border-radius:8px;padding:6px 14px;color:#000;font-weight:700;font-size:11px;cursor:pointer;">${t('common.play')}</button>
<button class="play-now-btn" data-tid="${tour.id}" style="background:var(--gold);border:none;border-radius:8px;padding:6px 14px;color:#000;font-weight:700;font-size:11px;cursor:pointer;">${t('common.play')}</button>
</div>
` : isRegistered ? `
<div style="font-size:11px;color:var(--success);font-weight:600;">${emoji('checkmark', '✓', 11)} ${t('tournament.joined')}</div>
......@@ -197,14 +197,15 @@ async function loadTournaments(el) {
btn.disabled = true;
btn.textContent = '...';
try {
// The server works out which pairing is ours from the tournament's
// current round, so the client cannot land on the wrong board.
const data = await net.post('tournament-match.php', {
action: 'create-or-join',
tournament_id: btn.dataset.tid,
round_id: btn.dataset.rid,
pairing_index: parseInt(btn.dataset.idx)
tournament_id: btn.dataset.tid
});
if (data.error) throw new Error(data.error);
if (data.bye) { audio.play('reward'); btn.textContent = 'BYE ✓'; return; }
if (data.waiting) { btn.textContent = t('common.waiting') || '...'; btn.disabled = false; return; }
scene.push('chess-game', {
mode: 'live',
matchId: data.match_id,
......@@ -212,8 +213,8 @@ async function loadTournaments(el) {
timeControl: data.time_control || 'rapid_10_0',
opponentId: data.opponent_id,
tournamentId: btn.dataset.tid,
tournamentRound: parseInt(btn.dataset.idx),
recovered: data.already_exists && data.status === 'in_progress'
tournamentRound: data.round_number,
recovered: !!data.recovered
});
} catch (err) {
btn.textContent = t('common.failed');
......
# EL3AB test suite
```bash
./tests/run.sh
```
Requires `php` (8.1+), `node` (18+) and the Postgres client tools (`initdb`,
`pg_ctl`, `psql`). Everything runs against a **throwaway local Postgres** that
the script creates and destroys. **No production data is read or written.**
---
## What each test covers
### `test_swiss.php` — pairing engine and result logic
Simulates complete tournaments at 2, 3, 4, 5, 6, 7, 8, 9, 16, 17, 32, 64 and 100
players and asserts the invariants after every round:
- nobody is ever paired with the same opponent twice
- every player is seated exactly once per round
- no player receives more than one bye
- colour allocation stays within 2 of even for everyone
- points are conserved (exactly 1.0 distributed per game)
- the tournament always reaches `completed`
It also checks that every result the server can derive is a value the
`public.match_result` enum actually accepts — the defect that silently rejected
every decisive game.
### `test_replay.php` — the move validator must not reject legal chess
Replays three real master games (Morphy's Opera Game, Kasparov–Topalov 1999, and
a game ending in promotion) through `chessValidateTransition`, using the same
`chess.min.js` the client ships. 167 transitions covering castling, promotion,
en passant, heavy capture sequences and two checkmates.
Asserts three things:
1. **no legal move is ever rejected** — a false positive here would break a live
game, which is far worse than the tampering it prevents;
2. a move submitted by the player whose turn it is *not* is always refused;
3. replaying an earlier position is always refused.
It also checks that the winner of a checkmate is derived from the final position
alone, with no input from the client.
### `test_integration.php` — the engine against real Postgres
The parts only a real database can prove: `jsonb` round-trips as arrays rather
than escaped strings, the real enums accept what we write, starting a tournament
twice does not create a second round 1, and — the important one — **two players
racing to open the same board produce exactly one match**, because both derive
the same primary key and Postgres rejects the loser.
### `test_auth.php` — local JWT verification
The fast path that removes an upstream auth call from every request. Checks a
valid signature passes and that wrong-secret, expired, subject-less, malformed,
tampered-payload and `alg=none` tokens are all rejected.
### `test_e2e.mjs` — the whole API over HTTP
Drives two independent clients through the real endpoints, exactly as a browser
would. Fifteen sections, including:
- a queued player's hand-off survives them re-queueing (the bug that stranded
one player alone in a game)
- the two players always receive **opposite** colours, and the server tells each
client its own colour rather than letting it guess
- a non-participant cannot read a live game's position
- a move from the wrong player is refused; a retried move is reported as a
duplicate rather than an error
- a full 33-move game to checkmate, move by move
- **a losing player who reports themselves the winner does not get the win**
- resignation, draw offers (which now require the opponent to have offered)
- both paired tournament players land on the same board with agreed colours
- a round advances by itself once its last game finishes
- the scheduler starts a due tournament, and forfeits no-shows so a round cannot
stall forever
- the server clock flags an absent player, and does *not* flag one who is merely
thinking
- heartbeats from the two players do not overwrite each other or clobber a
standing draw offer
---
## How it works
`router.php` runs the **real** API files under PHP's built-in server, replacing
only the Supabase data helpers with `pgshim.php`, a `SupabaseClient` subclass
that speaks PostgREST filter syntax to a local Postgres. Authentication is *not*
stubbed: `includes/auth.php` runs unmodified and verifies tokens over HTTP
against `authstub.php`, so the auth path under test is the production one.
`schema.sql` mirrors the production types and tables. The enum values in it were
copied verbatim from the live PostgREST OpenAPI spec — if production ever gains
a new `match_result` or `time_control` value, update it there too.
<?php
// Minimal stand-in for Supabase GoTrue /auth/v1/user so the REAL includes/auth.php
// runs unmodified: it verifies the token over HTTP exactly as it does in production.
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(.+)/i', $h, $m)) { http_response_code(401); echo '{"msg":"no token"}'; exit; }
$parts = explode('.', $m[1]);
$payload = json_decode(base64_decode(strtr($parts[1] ?? '', '-_', '+/')), true);
if (empty($payload['sub'])) { http_response_code(401); echo '{"msg":"bad token"}'; exit; }
header('Content-Type: application/json');
echo json_encode(['id' => $payload['sub'], 'role' => 'authenticated']);
<?php
// Load the engine without its Supabase dependency (already stubbed by the caller).
$root = dirname(__DIR__);
require_once $root . '/includes/chess.php';
$src = file_get_contents($root . '/includes/tournament-engine.php');
$src = preg_replace("#require_once __DIR__ . '/supabase.php';#", '', $src);
$src = preg_replace("#require_once __DIR__ . '/chess.php';#", '', $src);
$src = preg_replace('/^<\?php/', '', $src, 1);
eval($src);
// Replay real games with the SAME chess library the client ships, and dump the
// FEN sequence so the PHP validator can be checked against genuine play.
import fs from 'node:fs';
const src = fs.readFileSync(new URL('../public/js/lib/chess.min.js', import.meta.url),'utf8');
const mod = {exports:{}};
new Function('module','exports','globalThis', src)(mod, mod.exports, globalThis);
const Chess = globalThis.Chess || mod.exports.Chess || mod.exports;
const games = {
// Morphy — Opera Game, 1858 (ends in mate)
opera: "e4 e5 Nf3 d6 d4 Bg4 dxe5 Bxf3 Qxf3 dxe5 Bc4 Nf6 Qb3 Qe7 Nc3 c6 Bg5 b5 Nxb5 cxb5 Bxb5+ Nbd7 O-O-O Rd8 Rxd7 Rxd7 Rd1 Qe6 Bxd7+ Nxd7 Qb8+ Nxb8 Rd8#",
// Kasparov–Topalov, Wijk aan Zee 1999 (long, many captures)
topalov: "e4 d6 d4 Nf6 Nc3 g6 Be3 Bg7 Qd2 c6 f3 b5 Nge2 Nbd7 Bh6 Bxh6 Qxh6 Bb7 a3 e5 O-O-O Qe7 Kb1 a6 Nc1 O-O-O Nb3 exd4 Rxd4 c5 Rd1 Nb6 g3 Kb8 Na5 Ba8 Bh3 d5 Qf4+ Ka7 Rhe1 d4 Nd5 Nbxd5 exd5 Qd6 Rxd4 cxd4 Re7+ Kb6 Qxd4+ Kxa5 b4+ Ka4 Qc3 Qxd5 Ra7 Bb7 Rxb7 Qc4 Qxf6 Kxa3 Qxa6+ Kxb4 c3+ Kxc3 Qa1+ Kd2 Qb2+ Kd1 Bf1 Rd2 Rd7 Rxd7 Bxc4 bxc4 Qxh8 Rd3 Qa8 c3 Qa4+ Ke1 f4 f5 Kc1 Rd2 Qa7",
// A game with promotion
promo: "e4 e5 Nf3 Nc6 Bc4 Bc5 b4 Bxb4 c3 Ba5 d4 exd4 O-O d3 Qb3 Qf6 e5 Qg6 Re1 Nge7 Ba3 b5 Qxb5 Rb8 Qa4 Bb6 Nbd2 Bb7 Ne4 Qf5 Bxd3 Qh5 Nf6+ gxf6 exf6 Rg8 Rad1 Qxf3 Rxe7+ Nxe7 Qxd7+ Kxd7 Bf5+ Ke8 Bd7+ Kf8 Bxe7#",
};
const out = {};
for (const [name, pgnMoves] of Object.entries(games)) {
const c = new Chess();
const seq = [{fen: c.fen(), mover: null, san: null}];
for (const san of pgnMoves.split(/\s+/).filter(Boolean)) {
const before = c.turn();
const m = c.move(san);
if (!m) { console.error(`${name}: failed at ${san}`); break; }
seq.push({fen: c.fen(), mover: before, san});
}
out[name] = seq;
console.error(`${name}: ${seq.length-1} plies, final=${c.fen()}, gameOver=${c.isGameOver?.() ?? c.game_over?.()}`);
}
fs.writeFileSync('real_games.json', JSON.stringify(out));
<?php
/**
* A SupabaseClient-shaped adapter over a real Postgres connection.
*
* The engine speaks PostgREST filter syntax ("eq.<v>", "in.(a,b)", "neq.<v>",
* "is.true", "lt.<ts>"). This translates that to SQL and mimics PostgREST's
* return shapes precisely — including the two behaviours the engine's
* correctness depends on:
*
* - an UPDATE that matches zero rows returns [] (that is the conditional-write
* guard that stops two players both finalising a match)
* - any failure returns ['error' => ...] (that is the duplicate-key signal
* that makes tournament match creation race-free)
*/
require_once dirname(__DIR__) . '/config/database.php';
class PgShim extends SupabaseClient {
private PDO $pdo;
public array $log = [];
public function __construct(string $dsn = 'pgsql:host=127.0.0.1;port=54329;dbname=postgres', string $user = 'postgres') {
parent::__construct(null, false);
$this->pdo = new PDO($dsn, $user, '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
}
public function pdo(): PDO { return $this->pdo; }
/** PostgREST filter -> [sql fragment, bound values] */
private function where(array $params, array &$vals): string {
$clauses = [];
foreach ($params as $col => $expr) {
if (in_array($col, ['select', 'order', 'limit', 'offset', 'on_conflict'], true)) continue;
if (!is_string($expr)) continue;
if (preg_match('/^eq\.(.*)$/s', $expr, $m)) {
$clauses[] = "\"$col\" = ?"; $vals[] = $m[1];
} elseif (preg_match('/^neq\.(.*)$/s', $expr, $m)) {
// PostgREST's neq excludes NULLs too, matching `IS DISTINCT FROM`
// only for non-null columns; for status columns this is equivalent.
$clauses[] = "\"$col\" IS DISTINCT FROM ?"; $vals[] = $m[1];
} elseif (preg_match('/^not\.in\.\((.*)\)$/s', $expr, $m)) {
$items = $m[1] === '' ? [] : explode(',', $m[1]);
if ($items) {
$clauses[] = "\"$col\" NOT IN (" . implode(',', array_fill(0, count($items), '?')) . ')';
foreach ($items as $i) $vals[] = trim($i, '"');
}
} elseif (preg_match('/^in\.\((.*)\)$/s', $expr, $m)) {
$items = $m[1] === '' ? [] : explode(',', $m[1]);
if (!$items) { $clauses[] = 'false'; continue; }
$clauses[] = "\"$col\" IN (" . implode(',', array_fill(0, count($items), '?')) . ')';
foreach ($items as $i) $vals[] = trim($i, '"');
} elseif (preg_match('/^lt\.(.*)$/s', $expr, $m)) {
$clauses[] = "\"$col\" < ?"; $vals[] = $m[1];
} elseif (preg_match('/^gt\.(.*)$/s', $expr, $m)) {
$clauses[] = "\"$col\" > ?"; $vals[] = $m[1];
} elseif ($expr === 'is.true') {
$clauses[] = "\"$col\" IS TRUE";
} elseif ($expr === 'is.null') {
$clauses[] = "\"$col\" IS NULL";
} elseif ($col === 'or') {
// "(a.eq.X,b.eq.Y)"
$inner = trim($expr, '()');
$parts = [];
foreach (explode(',', $inner) as $p) {
if (preg_match('/^(\w+)\.eq\.(.*)$/s', $p, $mm)) {
$parts[] = "\"{$mm[1]}\" = ?"; $vals[] = $mm[2];
}
}
if ($parts) $clauses[] = '(' . implode(' OR ', $parts) . ')';
}
}
return $clauses ? ('WHERE ' . implode(' AND ', $clauses)) : '';
}
private function decodeRow(array $row): array {
foreach ($row as $k => $v) {
// Mirror PostgREST: jsonb comes back decoded, not as a string.
if (is_string($v) && in_array($k, ['pairings', 'results', 'moves', 'metadata', 'game_state', 'tiebreak_rules', 'payload'], true)) {
$d = json_decode($v, true);
$row[$k] = $d === null ? $v : $d;
}
}
return $row;
}
private function encodeValue($v) {
if (is_array($v) || is_object($v)) return json_encode($v);
if (is_bool($v)) return $v ? 'true' : 'false';
return $v;
}
public function get(string $table, array $params = []): array {
$vals = [];
$where = $this->where($params, $vals);
$select = $params['select'] ?? '*';
// Keep it simple: fetch whole rows, then project.
$sql = "SELECT * FROM \"$table\" $where";
if (!empty($params['order'])) {
[$c, $d] = array_pad(explode('.', $params['order']), 2, 'asc');
$sql .= ' ORDER BY "' . $c . '" ' . (strtolower($d) === 'desc' ? 'DESC' : 'ASC');
}
if (!empty($params['limit'])) $sql .= ' LIMIT ' . (int)$params['limit'];
try {
$st = $this->pdo->prepare($sql);
$st->execute($vals);
$rows = array_map([$this, 'decodeRow'], $st->fetchAll(PDO::FETCH_ASSOC));
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 400];
}
if ($select !== '*') {
$cols = array_map('trim', explode(',', $select));
$rows = array_map(fn($r) => array_intersect_key($r, array_flip($cols)), $rows);
}
return $rows;
}
public function getOne(string $table, array $params = []): ?array {
$params['limit'] = 1;
$r = $this->get($table, $params);
if (isset($r['error'])) return $r;
return $r[0] ?? null;
}
public function insert(string $table, array $data): array {
$cols = array_keys($data);
$sql = "INSERT INTO \"$table\" (" . implode(',', array_map(fn($c) => "\"$c\"", $cols)) . ')'
. ' VALUES (' . implode(',', array_fill(0, count($cols), '?')) . ') RETURNING *';
try {
$st = $this->pdo->prepare($sql);
$st->execute(array_map([$this, 'encodeValue'], array_values($data)));
return array_map([$this, 'decodeRow'], $st->fetchAll(PDO::FETCH_ASSOC));
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 409];
}
}
public function update(string $table, array $data, array $params = []): array {
$vals = [];
$sets = [];
foreach ($data as $c => $v) { $sets[] = "\"$c\" = ?"; $vals[] = $this->encodeValue($v); }
$whereVals = [];
$where = $this->where($params, $whereVals);
$sql = "UPDATE \"$table\" SET " . implode(',', $sets) . " $where RETURNING *";
try {
$st = $this->pdo->prepare($sql);
$st->execute(array_merge($vals, $whereVals));
return array_map([$this, 'decodeRow'], $st->fetchAll(PDO::FETCH_ASSOC));
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 400];
}
}
public function delete(string $table, array $params = []): array {
$vals = [];
$where = $this->where($params, $vals);
try {
$st = $this->pdo->prepare("DELETE FROM \"$table\" $where RETURNING *");
$st->execute($vals);
return $st->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
return ['error' => $e->getMessage(), 'code' => 400];
}
}
public function rpc(string $fn, array $data = []): array { return []; }
}
{"opera":[{"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","mover":null,"san":null},{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","mover":"w","san":"e4"},{"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2","mover":"b","san":"e5"},{"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2","mover":"w","san":"Nf3"},{"fen":"rnbqkbnr/ppp2ppp/3p4/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 0 3","mover":"b","san":"d6"},{"fen":"rnbqkbnr/ppp2ppp/3p4/4p3/3PP3/5N2/PPP2PPP/RNBQKB1R b KQkq d3 0 3","mover":"w","san":"d4"},{"fen":"rn1qkbnr/ppp2ppp/3p4/4p3/3PP1b1/5N2/PPP2PPP/RNBQKB1R w KQkq - 1 4","mover":"b","san":"Bg4"},{"fen":"rn1qkbnr/ppp2ppp/3p4/4P3/4P1b1/5N2/PPP2PPP/RNBQKB1R b KQkq - 0 4","mover":"w","san":"dxe5"},{"fen":"rn1qkbnr/ppp2ppp/3p4/4P3/4P3/5b2/PPP2PPP/RNBQKB1R w KQkq - 0 5","mover":"b","san":"Bxf3"},{"fen":"rn1qkbnr/ppp2ppp/3p4/4P3/4P3/5Q2/PPP2PPP/RNB1KB1R b KQkq - 0 5","mover":"w","san":"Qxf3"},{"fen":"rn1qkbnr/ppp2ppp/8/4p3/4P3/5Q2/PPP2PPP/RNB1KB1R w KQkq - 0 6","mover":"b","san":"dxe5"},{"fen":"rn1qkbnr/ppp2ppp/8/4p3/2B1P3/5Q2/PPP2PPP/RNB1K2R b KQkq - 1 6","mover":"w","san":"Bc4"},{"fen":"rn1qkb1r/ppp2ppp/5n2/4p3/2B1P3/5Q2/PPP2PPP/RNB1K2R w KQkq - 2 7","mover":"b","san":"Nf6"},{"fen":"rn1qkb1r/ppp2ppp/5n2/4p3/2B1P3/1Q6/PPP2PPP/RNB1K2R b KQkq - 3 7","mover":"w","san":"Qb3"},{"fen":"rn2kb1r/ppp1qppp/5n2/4p3/2B1P3/1Q6/PPP2PPP/RNB1K2R w KQkq - 4 8","mover":"b","san":"Qe7"},{"fen":"rn2kb1r/ppp1qppp/5n2/4p3/2B1P3/1QN5/PPP2PPP/R1B1K2R b KQkq - 5 8","mover":"w","san":"Nc3"},{"fen":"rn2kb1r/pp2qppp/2p2n2/4p3/2B1P3/1QN5/PPP2PPP/R1B1K2R w KQkq - 0 9","mover":"b","san":"c6"},{"fen":"rn2kb1r/pp2qppp/2p2n2/4p1B1/2B1P3/1QN5/PPP2PPP/R3K2R b KQkq - 1 9","mover":"w","san":"Bg5"},{"fen":"rn2kb1r/p3qppp/2p2n2/1p2p1B1/2B1P3/1QN5/PPP2PPP/R3K2R w KQkq b6 0 10","mover":"b","san":"b5"},{"fen":"rn2kb1r/p3qppp/2p2n2/1N2p1B1/2B1P3/1Q6/PPP2PPP/R3K2R b KQkq - 0 10","mover":"w","san":"Nxb5"},{"fen":"rn2kb1r/p3qppp/5n2/1p2p1B1/2B1P3/1Q6/PPP2PPP/R3K2R w KQkq - 0 11","mover":"b","san":"cxb5"},{"fen":"rn2kb1r/p3qppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/R3K2R b KQkq - 0 11","mover":"w","san":"Bxb5+"},{"fen":"r3kb1r/p2nqppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/R3K2R w KQkq - 1 12","mover":"b","san":"Nbd7"},{"fen":"r3kb1r/p2nqppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/2KR3R b kq - 2 12","mover":"w","san":"O-O-O"},{"fen":"3rkb1r/p2nqppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/2KR3R w k - 3 13","mover":"b","san":"Rd8"},{"fen":"3rkb1r/p2Rqppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/2K4R b k - 0 13","mover":"w","san":"Rxd7"},{"fen":"4kb1r/p2rqppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/2K4R w k - 0 14","mover":"b","san":"Rxd7"},{"fen":"4kb1r/p2rqppp/5n2/1B2p1B1/4P3/1Q6/PPP2PPP/2KR4 b k - 1 14","mover":"w","san":"Rd1"},{"fen":"4kb1r/p2r1ppp/4qn2/1B2p1B1/4P3/1Q6/PPP2PPP/2KR4 w k - 2 15","mover":"b","san":"Qe6"},{"fen":"4kb1r/p2B1ppp/4qn2/4p1B1/4P3/1Q6/PPP2PPP/2KR4 b k - 0 15","mover":"w","san":"Bxd7+"},{"fen":"4kb1r/p2n1ppp/4q3/4p1B1/4P3/1Q6/PPP2PPP/2KR4 w k - 0 16","mover":"b","san":"Nxd7"},{"fen":"1Q2kb1r/p2n1ppp/4q3/4p1B1/4P3/8/PPP2PPP/2KR4 b k - 1 16","mover":"w","san":"Qb8+"},{"fen":"1n2kb1r/p4ppp/4q3/4p1B1/4P3/8/PPP2PPP/2KR4 w k - 0 17","mover":"b","san":"Nxb8"},{"fen":"1n1Rkb1r/p4ppp/4q3/4p1B1/4P3/8/PPP2PPP/2K5 b k - 1 17","mover":"w","san":"Rd8#"}],"topalov":[{"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","mover":null,"san":null},{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","mover":"w","san":"e4"},{"fen":"rnbqkbnr/ppp1pppp/3p4/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2","mover":"b","san":"d6"},{"fen":"rnbqkbnr/ppp1pppp/3p4/8/3PP3/8/PPP2PPP/RNBQKBNR b KQkq d3 0 2","mover":"w","san":"d4"},{"fen":"rnbqkb1r/ppp1pppp/3p1n2/8/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 1 3","mover":"b","san":"Nf6"},{"fen":"rnbqkb1r/ppp1pppp/3p1n2/8/3PP3/2N5/PPP2PPP/R1BQKBNR b KQkq - 2 3","mover":"w","san":"Nc3"},{"fen":"rnbqkb1r/ppp1pp1p/3p1np1/8/3PP3/2N5/PPP2PPP/R1BQKBNR w KQkq - 0 4","mover":"b","san":"g6"},{"fen":"rnbqkb1r/ppp1pp1p/3p1np1/8/3PP3/2N1B3/PPP2PPP/R2QKBNR b KQkq - 1 4","mover":"w","san":"Be3"},{"fen":"rnbqk2r/ppp1ppbp/3p1np1/8/3PP3/2N1B3/PPP2PPP/R2QKBNR w KQkq - 2 5","mover":"b","san":"Bg7"},{"fen":"rnbqk2r/ppp1ppbp/3p1np1/8/3PP3/2N1B3/PPPQ1PPP/R3KBNR b KQkq - 3 5","mover":"w","san":"Qd2"},{"fen":"rnbqk2r/pp2ppbp/2pp1np1/8/3PP3/2N1B3/PPPQ1PPP/R3KBNR w KQkq - 0 6","mover":"b","san":"c6"},{"fen":"rnbqk2r/pp2ppbp/2pp1np1/8/3PP3/2N1BP2/PPPQ2PP/R3KBNR b KQkq - 0 6","mover":"w","san":"f3"},{"fen":"rnbqk2r/p3ppbp/2pp1np1/1p6/3PP3/2N1BP2/PPPQ2PP/R3KBNR w KQkq b6 0 7","mover":"b","san":"b5"},{"fen":"rnbqk2r/p3ppbp/2pp1np1/1p6/3PP3/2N1BP2/PPPQN1PP/R3KB1R b KQkq - 1 7","mover":"w","san":"Nge2"},{"fen":"r1bqk2r/p2nppbp/2pp1np1/1p6/3PP3/2N1BP2/PPPQN1PP/R3KB1R w KQkq - 2 8","mover":"b","san":"Nbd7"},{"fen":"r1bqk2r/p2nppbp/2pp1npB/1p6/3PP3/2N2P2/PPPQN1PP/R3KB1R b KQkq - 3 8","mover":"w","san":"Bh6"},{"fen":"r1bqk2r/p2npp1p/2pp1npb/1p6/3PP3/2N2P2/PPPQN1PP/R3KB1R w KQkq - 0 9","mover":"b","san":"Bxh6"},{"fen":"r1bqk2r/p2npp1p/2pp1npQ/1p6/3PP3/2N2P2/PPP1N1PP/R3KB1R b KQkq - 0 9","mover":"w","san":"Qxh6"},{"fen":"r2qk2r/pb1npp1p/2pp1npQ/1p6/3PP3/2N2P2/PPP1N1PP/R3KB1R w KQkq - 1 10","mover":"b","san":"Bb7"},{"fen":"r2qk2r/pb1npp1p/2pp1npQ/1p6/3PP3/P1N2P2/1PP1N1PP/R3KB1R b KQkq - 0 10","mover":"w","san":"a3"},{"fen":"r2qk2r/pb1n1p1p/2pp1npQ/1p2p3/3PP3/P1N2P2/1PP1N1PP/R3KB1R w KQkq e6 0 11","mover":"b","san":"e5"},{"fen":"r2qk2r/pb1n1p1p/2pp1npQ/1p2p3/3PP3/P1N2P2/1PP1N1PP/2KR1B1R b kq - 1 11","mover":"w","san":"O-O-O"},{"fen":"r3k2r/pb1nqp1p/2pp1npQ/1p2p3/3PP3/P1N2P2/1PP1N1PP/2KR1B1R w kq - 2 12","mover":"b","san":"Qe7"},{"fen":"r3k2r/pb1nqp1p/2pp1npQ/1p2p3/3PP3/P1N2P2/1PP1N1PP/1K1R1B1R b kq - 3 12","mover":"w","san":"Kb1"},{"fen":"r3k2r/1b1nqp1p/p1pp1npQ/1p2p3/3PP3/P1N2P2/1PP1N1PP/1K1R1B1R w kq - 0 13","mover":"b","san":"a6"},{"fen":"r3k2r/1b1nqp1p/p1pp1npQ/1p2p3/3PP3/P1N2P2/1PP3PP/1KNR1B1R b kq - 1 13","mover":"w","san":"Nc1"},{"fen":"2kr3r/1b1nqp1p/p1pp1npQ/1p2p3/3PP3/P1N2P2/1PP3PP/1KNR1B1R w - - 2 14","mover":"b","san":"O-O-O"},{"fen":"2kr3r/1b1nqp1p/p1pp1npQ/1p2p3/3PP3/PNN2P2/1PP3PP/1K1R1B1R b - - 3 14","mover":"w","san":"Nb3"},{"fen":"2kr3r/1b1nqp1p/p1pp1npQ/1p6/3pP3/PNN2P2/1PP3PP/1K1R1B1R w - - 0 15","mover":"b","san":"exd4"},{"fen":"2kr3r/1b1nqp1p/p1pp1npQ/1p6/3RP3/PNN2P2/1PP3PP/1K3B1R b - - 0 15","mover":"w","san":"Rxd4"},{"fen":"2kr3r/1b1nqp1p/p2p1npQ/1pp5/3RP3/PNN2P2/1PP3PP/1K3B1R w - - 0 16","mover":"b","san":"c5"},{"fen":"2kr3r/1b1nqp1p/p2p1npQ/1pp5/4P3/PNN2P2/1PP3PP/1K1R1B1R b - - 1 16","mover":"w","san":"Rd1"},{"fen":"2kr3r/1b2qp1p/pn1p1npQ/1pp5/4P3/PNN2P2/1PP3PP/1K1R1B1R w - - 2 17","mover":"b","san":"Nb6"},{"fen":"2kr3r/1b2qp1p/pn1p1npQ/1pp5/4P3/PNN2PP1/1PP4P/1K1R1B1R b - - 0 17","mover":"w","san":"g3"},{"fen":"1k1r3r/1b2qp1p/pn1p1npQ/1pp5/4P3/PNN2PP1/1PP4P/1K1R1B1R w - - 1 18","mover":"b","san":"Kb8"},{"fen":"1k1r3r/1b2qp1p/pn1p1npQ/Npp5/4P3/P1N2PP1/1PP4P/1K1R1B1R b - - 2 18","mover":"w","san":"Na5"},{"fen":"bk1r3r/4qp1p/pn1p1npQ/Npp5/4P3/P1N2PP1/1PP4P/1K1R1B1R w - - 3 19","mover":"b","san":"Ba8"},{"fen":"bk1r3r/4qp1p/pn1p1npQ/Npp5/4P3/P1N2PPB/1PP4P/1K1R3R b - - 4 19","mover":"w","san":"Bh3"},{"fen":"bk1r3r/4qp1p/pn3npQ/Nppp4/4P3/P1N2PPB/1PP4P/1K1R3R w - - 0 20","mover":"b","san":"d5"},{"fen":"bk1r3r/4qp1p/pn3np1/Nppp4/4PQ2/P1N2PPB/1PP4P/1K1R3R b - - 1 20","mover":"w","san":"Qf4+"},{"fen":"b2r3r/k3qp1p/pn3np1/Nppp4/4PQ2/P1N2PPB/1PP4P/1K1R3R w - - 2 21","mover":"b","san":"Ka7"},{"fen":"b2r3r/k3qp1p/pn3np1/Nppp4/4PQ2/P1N2PPB/1PP4P/1K1RR3 b - - 3 21","mover":"w","san":"Rhe1"},{"fen":"b2r3r/k3qp1p/pn3np1/Npp5/3pPQ2/P1N2PPB/1PP4P/1K1RR3 w - - 0 22","mover":"b","san":"d4"},{"fen":"b2r3r/k3qp1p/pn3np1/NppN4/3pPQ2/P4PPB/1PP4P/1K1RR3 b - - 1 22","mover":"w","san":"Nd5"},{"fen":"b2r3r/k3qp1p/p4np1/Nppn4/3pPQ2/P4PPB/1PP4P/1K1RR3 w - - 0 23","mover":"b","san":"Nbxd5"},{"fen":"b2r3r/k3qp1p/p4np1/NppP4/3p1Q2/P4PPB/1PP4P/1K1RR3 b - - 0 23","mover":"w","san":"exd5"},{"fen":"b2r3r/k4p1p/p2q1np1/NppP4/3p1Q2/P4PPB/1PP4P/1K1RR3 w - - 1 24","mover":"b","san":"Qd6"},{"fen":"b2r3r/k4p1p/p2q1np1/NppP4/3R1Q2/P4PPB/1PP4P/1K2R3 b - - 0 24","mover":"w","san":"Rxd4"},{"fen":"b2r3r/k4p1p/p2q1np1/Np1P4/3p1Q2/P4PPB/1PP4P/1K2R3 w - - 0 25","mover":"b","san":"cxd4"},{"fen":"b2r3r/k3Rp1p/p2q1np1/Np1P4/3p1Q2/P4PPB/1PP4P/1K6 b - - 1 25","mover":"w","san":"Re7+"},{"fen":"b2r3r/4Rp1p/pk1q1np1/Np1P4/3p1Q2/P4PPB/1PP4P/1K6 w - - 2 26","mover":"b","san":"Kb6"},{"fen":"b2r3r/4Rp1p/pk1q1np1/Np1P4/3Q4/P4PPB/1PP4P/1K6 b - - 0 26","mover":"w","san":"Qxd4+"},{"fen":"b2r3r/4Rp1p/p2q1np1/kp1P4/3Q4/P4PPB/1PP4P/1K6 w - - 0 27","mover":"b","san":"Kxa5"},{"fen":"b2r3r/4Rp1p/p2q1np1/kp1P4/1P1Q4/P4PPB/2P4P/1K6 b - b3 0 27","mover":"w","san":"b4+"},{"fen":"b2r3r/4Rp1p/p2q1np1/1p1P4/kP1Q4/P4PPB/2P4P/1K6 w - - 1 28","mover":"b","san":"Ka4"},{"fen":"b2r3r/4Rp1p/p2q1np1/1p1P4/kP6/P1Q2PPB/2P4P/1K6 b - - 2 28","mover":"w","san":"Qc3"},{"fen":"b2r3r/4Rp1p/p4np1/1p1q4/kP6/P1Q2PPB/2P4P/1K6 w - - 0 29","mover":"b","san":"Qxd5"},{"fen":"b2r3r/R4p1p/p4np1/1p1q4/kP6/P1Q2PPB/2P4P/1K6 b - - 1 29","mover":"w","san":"Ra7"},{"fen":"3r3r/Rb3p1p/p4np1/1p1q4/kP6/P1Q2PPB/2P4P/1K6 w - - 2 30","mover":"b","san":"Bb7"},{"fen":"3r3r/1R3p1p/p4np1/1p1q4/kP6/P1Q2PPB/2P4P/1K6 b - - 0 30","mover":"w","san":"Rxb7"},{"fen":"3r3r/1R3p1p/p4np1/1p6/kPq5/P1Q2PPB/2P4P/1K6 w - - 1 31","mover":"b","san":"Qc4"},{"fen":"3r3r/1R3p1p/p4Qp1/1p6/kPq5/P4PPB/2P4P/1K6 b - - 0 31","mover":"w","san":"Qxf6"},{"fen":"3r3r/1R3p1p/p4Qp1/1p6/1Pq5/k4PPB/2P4P/1K6 w - - 0 32","mover":"b","san":"Kxa3"},{"fen":"3r3r/1R3p1p/Q5p1/1p6/1Pq5/k4PPB/2P4P/1K6 b - - 0 32","mover":"w","san":"Qxa6+"},{"fen":"3r3r/1R3p1p/Q5p1/1p6/1kq5/5PPB/2P4P/1K6 w - - 0 33","mover":"b","san":"Kxb4"},{"fen":"3r3r/1R3p1p/Q5p1/1p6/1kq5/2P2PPB/7P/1K6 b - - 0 33","mover":"w","san":"c3+"},{"fen":"3r3r/1R3p1p/Q5p1/1p6/2q5/2k2PPB/7P/1K6 w - - 0 34","mover":"b","san":"Kxc3"},{"fen":"3r3r/1R3p1p/6p1/1p6/2q5/2k2PPB/7P/QK6 b - - 1 34","mover":"w","san":"Qa1+"},{"fen":"3r3r/1R3p1p/6p1/1p6/2q5/5PPB/3k3P/QK6 w - - 2 35","mover":"b","san":"Kd2"},{"fen":"3r3r/1R3p1p/6p1/1p6/2q5/5PPB/1Q1k3P/1K6 b - - 3 35","mover":"w","san":"Qb2+"},{"fen":"3r3r/1R3p1p/6p1/1p6/2q5/5PPB/1Q5P/1K1k4 w - - 4 36","mover":"b","san":"Kd1"},{"fen":"3r3r/1R3p1p/6p1/1p6/2q5/5PP1/1Q5P/1K1k1B2 b - - 5 36","mover":"w","san":"Bf1"},{"fen":"7r/1R3p1p/6p1/1p6/2q5/5PP1/1Q1r3P/1K1k1B2 w - - 6 37","mover":"b","san":"Rd2"},{"fen":"7r/3R1p1p/6p1/1p6/2q5/5PP1/1Q1r3P/1K1k1B2 b - - 7 37","mover":"w","san":"Rd7"},{"fen":"7r/3r1p1p/6p1/1p6/2q5/5PP1/1Q5P/1K1k1B2 w - - 0 38","mover":"b","san":"Rxd7"},{"fen":"7r/3r1p1p/6p1/1p6/2B5/5PP1/1Q5P/1K1k4 b - - 0 38","mover":"w","san":"Bxc4"},{"fen":"7r/3r1p1p/6p1/8/2p5/5PP1/1Q5P/1K1k4 w - - 0 39","mover":"b","san":"bxc4"},{"fen":"7Q/3r1p1p/6p1/8/2p5/5PP1/7P/1K1k4 b - - 0 39","mover":"w","san":"Qxh8"},{"fen":"7Q/5p1p/6p1/8/2p5/3r1PP1/7P/1K1k4 w - - 1 40","mover":"b","san":"Rd3"},{"fen":"Q7/5p1p/6p1/8/2p5/3r1PP1/7P/1K1k4 b - - 2 40","mover":"w","san":"Qa8"},{"fen":"Q7/5p1p/6p1/8/8/2pr1PP1/7P/1K1k4 w - - 0 41","mover":"b","san":"c3"},{"fen":"8/5p1p/6p1/8/Q7/2pr1PP1/7P/1K1k4 b - - 1 41","mover":"w","san":"Qa4+"},{"fen":"8/5p1p/6p1/8/Q7/2pr1PP1/7P/1K2k3 w - - 2 42","mover":"b","san":"Ke1"},{"fen":"8/5p1p/6p1/8/Q4P2/2pr2P1/7P/1K2k3 b - - 0 42","mover":"w","san":"f4"},{"fen":"8/7p/6p1/5p2/Q4P2/2pr2P1/7P/1K2k3 w - f6 0 43","mover":"b","san":"f5"},{"fen":"8/7p/6p1/5p2/Q4P2/2pr2P1/7P/2K1k3 b - - 1 43","mover":"w","san":"Kc1"},{"fen":"8/7p/6p1/5p2/Q4P2/2p3P1/3r3P/2K1k3 w - - 2 44","mover":"b","san":"Rd2"},{"fen":"8/Q6p/6p1/5p2/5P2/2p3P1/3r3P/2K1k3 b - - 3 44","mover":"w","san":"Qa7"}],"promo":[{"fen":"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1","mover":null,"san":null},{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","mover":"w","san":"e4"},{"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2","mover":"b","san":"e5"},{"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2","mover":"w","san":"Nf3"},{"fen":"r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3","mover":"b","san":"Nc6"},{"fen":"r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 3 3","mover":"w","san":"Bc4"},{"fen":"r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4","mover":"b","san":"Bc5"},{"fen":"r1bqk1nr/pppp1ppp/2n5/2b1p3/1PB1P3/5N2/P1PP1PPP/RNBQK2R b KQkq b3 0 4","mover":"w","san":"b4"},{"fen":"r1bqk1nr/pppp1ppp/2n5/4p3/1bB1P3/5N2/P1PP1PPP/RNBQK2R w KQkq - 0 5","mover":"b","san":"Bxb4"},{"fen":"r1bqk1nr/pppp1ppp/2n5/4p3/1bB1P3/2P2N2/P2P1PPP/RNBQK2R b KQkq - 0 5","mover":"w","san":"c3"},{"fen":"r1bqk1nr/pppp1ppp/2n5/b3p3/2B1P3/2P2N2/P2P1PPP/RNBQK2R w KQkq - 1 6","mover":"b","san":"Ba5"},{"fen":"r1bqk1nr/pppp1ppp/2n5/b3p3/2BPP3/2P2N2/P4PPP/RNBQK2R b KQkq d3 0 6","mover":"w","san":"d4"},{"fen":"r1bqk1nr/pppp1ppp/2n5/b7/2BpP3/2P2N2/P4PPP/RNBQK2R w KQkq - 0 7","mover":"b","san":"exd4"},{"fen":"r1bqk1nr/pppp1ppp/2n5/b7/2BpP3/2P2N2/P4PPP/RNBQ1RK1 b kq - 1 7","mover":"w","san":"O-O"},{"fen":"r1bqk1nr/pppp1ppp/2n5/b7/2B1P3/2Pp1N2/P4PPP/RNBQ1RK1 w kq - 0 8","mover":"b","san":"d3"},{"fen":"r1bqk1nr/pppp1ppp/2n5/b7/2B1P3/1QPp1N2/P4PPP/RNB2RK1 b kq - 1 8","mover":"w","san":"Qb3"},{"fen":"r1b1k1nr/pppp1ppp/2n2q2/b7/2B1P3/1QPp1N2/P4PPP/RNB2RK1 w kq - 2 9","mover":"b","san":"Qf6"},{"fen":"r1b1k1nr/pppp1ppp/2n2q2/b3P3/2B5/1QPp1N2/P4PPP/RNB2RK1 b kq - 0 9","mover":"w","san":"e5"},{"fen":"r1b1k1nr/pppp1ppp/2n3q1/b3P3/2B5/1QPp1N2/P4PPP/RNB2RK1 w kq - 1 10","mover":"b","san":"Qg6"},{"fen":"r1b1k1nr/pppp1ppp/2n3q1/b3P3/2B5/1QPp1N2/P4PPP/RNB1R1K1 b kq - 2 10","mover":"w","san":"Re1"},{"fen":"r1b1k2r/ppppnppp/2n3q1/b3P3/2B5/1QPp1N2/P4PPP/RNB1R1K1 w kq - 3 11","mover":"b","san":"Nge7"},{"fen":"r1b1k2r/ppppnppp/2n3q1/b3P3/2B5/BQPp1N2/P4PPP/RN2R1K1 b kq - 4 11","mover":"w","san":"Ba3"},{"fen":"r1b1k2r/p1ppnppp/2n3q1/bp2P3/2B5/BQPp1N2/P4PPP/RN2R1K1 w kq b6 0 12","mover":"b","san":"b5"},{"fen":"r1b1k2r/p1ppnppp/2n3q1/bQ2P3/2B5/B1Pp1N2/P4PPP/RN2R1K1 b kq - 0 12","mover":"w","san":"Qxb5"},{"fen":"1rb1k2r/p1ppnppp/2n3q1/bQ2P3/2B5/B1Pp1N2/P4PPP/RN2R1K1 w k - 1 13","mover":"b","san":"Rb8"},{"fen":"1rb1k2r/p1ppnppp/2n3q1/b3P3/Q1B5/B1Pp1N2/P4PPP/RN2R1K1 b k - 2 13","mover":"w","san":"Qa4"},{"fen":"1rb1k2r/p1ppnppp/1bn3q1/4P3/Q1B5/B1Pp1N2/P4PPP/RN2R1K1 w k - 3 14","mover":"b","san":"Bb6"},{"fen":"1rb1k2r/p1ppnppp/1bn3q1/4P3/Q1B5/B1Pp1N2/P2N1PPP/R3R1K1 b k - 4 14","mover":"w","san":"Nbd2"},{"fen":"1r2k2r/pbppnppp/1bn3q1/4P3/Q1B5/B1Pp1N2/P2N1PPP/R3R1K1 w k - 5 15","mover":"b","san":"Bb7"},{"fen":"1r2k2r/pbppnppp/1bn3q1/4P3/Q1B1N3/B1Pp1N2/P4PPP/R3R1K1 b k - 6 15","mover":"w","san":"Ne4"},{"fen":"1r2k2r/pbppnppp/1bn5/4Pq2/Q1B1N3/B1Pp1N2/P4PPP/R3R1K1 w k - 7 16","mover":"b","san":"Qf5"},{"fen":"1r2k2r/pbppnppp/1bn5/4Pq2/Q3N3/B1PB1N2/P4PPP/R3R1K1 b k - 0 16","mover":"w","san":"Bxd3"},{"fen":"1r2k2r/pbppnppp/1bn5/4P2q/Q3N3/B1PB1N2/P4PPP/R3R1K1 w k - 1 17","mover":"b","san":"Qh5"},{"fen":"1r2k2r/pbppnppp/1bn2N2/4P2q/Q7/B1PB1N2/P4PPP/R3R1K1 b k - 2 17","mover":"w","san":"Nf6+"},{"fen":"1r2k2r/pbppnp1p/1bn2p2/4P2q/Q7/B1PB1N2/P4PPP/R3R1K1 w k - 0 18","mover":"b","san":"gxf6"},{"fen":"1r2k2r/pbppnp1p/1bn2P2/7q/Q7/B1PB1N2/P4PPP/R3R1K1 b k - 0 18","mover":"w","san":"exf6"},{"fen":"1r2k1r1/pbppnp1p/1bn2P2/7q/Q7/B1PB1N2/P4PPP/R3R1K1 w - - 1 19","mover":"b","san":"Rg8"},{"fen":"1r2k1r1/pbppnp1p/1bn2P2/7q/Q7/B1PB1N2/P4PPP/3RR1K1 b - - 2 19","mover":"w","san":"Rad1"},{"fen":"1r2k1r1/pbppnp1p/1bn2P2/8/Q7/B1PB1q2/P4PPP/3RR1K1 w - - 0 20","mover":"b","san":"Qxf3"},{"fen":"1r2k1r1/pbppRp1p/1bn2P2/8/Q7/B1PB1q2/P4PPP/3R2K1 b - - 0 20","mover":"w","san":"Rxe7+"},{"fen":"1r2k1r1/pbppnp1p/1b3P2/8/Q7/B1PB1q2/P4PPP/3R2K1 w - - 0 21","mover":"b","san":"Nxe7"},{"fen":"1r2k1r1/pbpQnp1p/1b3P2/8/8/B1PB1q2/P4PPP/3R2K1 b - - 0 21","mover":"w","san":"Qxd7+"},{"fen":"1r4r1/pbpknp1p/1b3P2/8/8/B1PB1q2/P4PPP/3R2K1 w - - 0 22","mover":"b","san":"Kxd7"},{"fen":"1r4r1/pbpknp1p/1b3P2/5B2/8/B1P2q2/P4PPP/3R2K1 b - - 1 22","mover":"w","san":"Bf5+"},{"fen":"1r2k1r1/pbp1np1p/1b3P2/5B2/8/B1P2q2/P4PPP/3R2K1 w - - 2 23","mover":"b","san":"Ke8"},{"fen":"1r2k1r1/pbpBnp1p/1b3P2/8/8/B1P2q2/P4PPP/3R2K1 b - - 3 23","mover":"w","san":"Bd7+"},{"fen":"1r3kr1/pbpBnp1p/1b3P2/8/8/B1P2q2/P4PPP/3R2K1 w - - 4 24","mover":"b","san":"Kf8"},{"fen":"1r3kr1/pbpBBp1p/1b3P2/8/8/2P2q2/P4PPP/3R2K1 b - - 0 24","mover":"w","san":"Bxe7#"}]}
\ No newline at end of file
<?php
/**
* Test router for PHP's built-in server.
*
* Only the Supabase data helpers are replaced (with a local Postgres). The real
* includes/auth.php runs untouched — it verifies tokens over HTTP against a
* local GoTrue stub — so the auth path under test is the production one.
*/
putenv('SUPABASE_URL=http://127.0.0.1:8789');
putenv('SUPABASE_ANON_KEY=test-anon');
putenv('SUPABASE_SERVICE_KEY=test-service');
putenv('CRON_SECRET=testsecret');
putenv('TOURNAMENT_NO_SHOW_SECONDS=2');
require_once __DIR__ . '/pgshim.php';
$__pg = new PgShim();
function supabase(?string $token = null): SupabaseClient { global $__pg; return $__pg; }
function supabaseService(): SupabaseClient { global $__pg; return $__pg; }
function supabaseRpc(string $fn, array $params = []): ?array {
file_put_contents(__DIR__ . '/rpc.log', json_encode([$fn, $params]) . "\n", FILE_APPEND);
if ($fn === 'can_player_queue') return ['allowed' => true];
if ($fn === 'merge_game_state') {
// Mirror the real function: shallow-merge the patch into game_state.
global $__pg;
$rows = $__pg->get($params['p_table'], ['id' => 'eq.' . $params['p_match_id'], 'select' => 'game_state', 'limit' => 1]);
$gs = (is_array($rows) && !empty($rows) && is_array($rows[0]['game_state'] ?? null)) ? $rows[0]['game_state'] : [];
foreach (($params['p_patch'] ?? []) as $k => $v) {
if ($v === null) unset($gs[$k]); else $gs[$k] = $v;
}
$__pg->update($params['p_table'], ['game_state' => $gs], ['id' => 'eq.' . $params['p_match_id']]);
return ['ok' => true];
}
return [];
}
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = dirname(__DIR__) . $path;
if (!is_file($file)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['error' => 'no route: ' . $path]); exit; }
require $file;
#!/usr/bin/env bash
#
# EL3AB multiplayer + tournament test suite.
#
# ./tests/run.sh
#
# Everything runs against a throwaway local Postgres carrying a replica of the
# production types and tables. No production data is read or written.
#
# Requires: php (8.1+), node (18+), postgres client tools (initdb, pg_ctl, psql).
set -uo pipefail
cd "$(dirname "$0")"
PGPORT=${PGPORT:-54329}
PGDATA="$PWD/.pgdata"
APIPORT=8788
AUTHPORT=8789
PASS=0
FAIL=0
cleanup() {
[ -n "${API_PID:-}" ] && kill "$API_PID" 2>/dev/null
[ -n "${AUTH_PID:-}" ] && kill "$AUTH_PID" 2>/dev/null
pg_ctl -D "$PGDATA" stop -m immediate >/dev/null 2>&1
rm -rf "$PGDATA"
}
trap cleanup EXIT
step() { printf '\n\033[1m%s\033[0m\n' "$1"; }
run() {
if "${@:2}" > /tmp/el3ab_test_out 2>&1; then
printf ' \033[32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1))
sed 's/^/ /' /tmp/el3ab_test_out | tail -30
fi
}
step "Starting a disposable Postgres on port $PGPORT"
rm -rf "$PGDATA"
initdb -D "$PGDATA" -U postgres --auth=trust >/dev/null 2>&1
pg_ctl -D "$PGDATA" -o "-p $PGPORT -k /tmp -c listen_addresses='127.0.0.1'" -l "$PWD/.pg.log" start >/dev/null 2>&1
for _ in $(seq 1 20); do
psql -h 127.0.0.1 -p "$PGPORT" -U postgres -tAc 'select 1' >/dev/null 2>&1 && break
sleep 0.5
done
psql -h 127.0.0.1 -p "$PGPORT" -U postgres -q -f schema.sql >/dev/null 2>&1
echo " schema loaded"
step "Starting the API under test"
php -S 127.0.0.1:$AUTHPORT authstub.php > .auth.log 2>&1 &
AUTH_PID=$!
php -d display_errors=Off -S 127.0.0.1:$APIPORT router.php > .server.log 2>&1 &
API_PID=$!
sleep 2
echo " api on :$APIPORT, auth stub on :$AUTHPORT"
step "Tests"
run "Swiss pairing engine + chess result logic (2-100 players)" php test_swiss.php
run "Real master games replayed through the move validator" php test_replay.php
run "Tournament engine against real Postgres" php test_integration.php
run "Local JWT verification (signature, expiry, alg=none)" php test_auth.php
run "Full API end-to-end: matchmaking, a game, a tournament" node test_e2e.mjs
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ] || exit 1
-- Replica of the production types and tables the tournament engine touches.
-- Enum values copied verbatim from the live PostgREST OpenAPI spec.
CREATE TYPE match_status AS ENUM ('waiting','ready','in_progress','paused','completed','aborted','abandoned');
CREATE TYPE match_result AS ENUM ('white_wins','black_wins','draw','white_timeout','black_timeout','white_resign','black_resign','white_abandon','black_abandon','stalemate','insufficient_material','threefold_repetition','fifty_moves','mutual_draw','aborted');
CREATE TYPE time_control AS ENUM ('bullet_1_0','bullet_1_1','bullet_2_1','blitz_3_0','blitz_3_2','blitz_5_0','blitz_5_3','rapid_10_0','rapid_10_5','rapid_15_10','rapid_30_0','classical_60_0','classical_90_30','custom');
CREATE TYPE tournament_format AS ENUM ('swiss','round_robin','single_elimination','double_elimination','swiss_to_bracket','arena','team_battle');
CREATE TABLE profiles (
id uuid PRIMARY KEY,
display_name text, username text, avatar_url text,
elo_rapid int DEFAULT 1200, elo_blitz int DEFAULT 1200,
elo_bullet int DEFAULT 1200, elo_classical int DEFAULT 1200,
created_at timestamptz DEFAULT now()
);
CREATE TABLE el3ab_tournaments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
swiss_api_tournament_id uuid, org_id uuid,
game_key text DEFAULT 'chess', name text NOT NULL, name_ar text,
description text, format tournament_format DEFAULT 'swiss',
time_control time_control DEFAULT 'rapid_10_0',
rounds_total int, swiss_rounds int,
min_players int DEFAULT 4, max_players int DEFAULT 64,
bye_value numeric DEFAULT 1.0,
registration_closes_at timestamptz, starts_at timestamptz,
status text DEFAULT 'draft', current_round int DEFAULT 0,
is_rated bool DEFAULT true, auto_start bool DEFAULT true,
created_by uuid, tiebreak_rules jsonb DEFAULT '[]'::jsonb,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
);
CREATE TABLE el3ab_tournament_rounds (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tournament_id uuid REFERENCES el3ab_tournaments(id),
round_number int, status text,
started_at timestamptz, completed_at timestamptz,
pairings jsonb DEFAULT '[]'::jsonb, results jsonb DEFAULT '[]'::jsonb,
created_at timestamptz DEFAULT now(), swiss_round_id uuid
);
CREATE TABLE tournament_registrations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tournament_id uuid REFERENCES el3ab_tournaments(id),
player_id uuid, status text DEFAULT 'registered',
seed int, final_standing int, is_bot bool DEFAULT false,
registered_at timestamptz DEFAULT now(), withdrawn_at timestamptz
);
CREATE TABLE matches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
game_key text, white_player_id uuid, black_player_id uuid,
match_type text, tournament_id uuid, tournament_round int, pairing_id uuid,
status match_status DEFAULT 'waiting', result match_result,
time_control time_control, initial_time_ms int, increment_ms int DEFAULT 0,
white_time_remaining_ms int, black_time_remaining_ms int,
starting_fen text, current_fen text, pgn text,
moves jsonb DEFAULT '[]'::jsonb, move_count int DEFAULT 0,
game_state jsonb DEFAULT '{}'::jsonb,
white_rating_after int, black_rating_after int,
rating_change_white int, rating_change_black int,
bot_id text, is_rated bool DEFAULT false,
started_at timestamptz, completed_at timestamptz,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(),
metadata jsonb DEFAULT '{}'::jsonb
);
CREATE TABLE mp_log (
id bigserial PRIMARY KEY, ts timestamptz DEFAULT now(),
match_id uuid, game_key text, player_id uuid, event text, payload jsonb
);
<?php
/** Local JWT verification: the fast path that removes an upstream call per request. */
putenv('SUPABASE_JWT_SECRET=902343981eb82f43ff7a3757f3fcf25f14a2b9c729454eae5029ee3d1f189eb7');
putenv('SUPABASE_URL=http://127.0.0.1:1'); // must never be reached on this path
require_once dirname(__DIR__) . '/includes/auth.php';
$SECRET = '902343981eb82f43ff7a3757f3fcf25f14a2b9c729454eae5029ee3d1f189eb7';
$fail = 0;
function check($l, $c, $d = '') { global $fail; if ($c) { echo " ok $l\n"; } else { $fail++; echo " FAIL $l" . ($d ? " — $d" : '') . "\n"; } }
function b64u(string $s): string { return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); }
function mint(array $payload, string $secret, bool $valid = true): string {
$h = b64u(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$p = b64u(json_encode($payload));
$sig = hash_hmac('sha256', "$h.$p", $valid ? $secret : 'wrong-secret', true);
return "$h.$p." . b64u($sig);
}
$uid = '5ed4d72d-555f-4d19-8315-204dafd3a2e7';
$t = mint(['sub' => $uid, 'role' => 'authenticated', 'exp' => time() + 3600], $SECRET);
$u = verifyTokenLocally($t);
check('a correctly signed token verifies with no network call', $u && $u['id'] === $uid, json_encode($u));
$t = mint(['sub' => $uid, 'exp' => time() + 3600], $SECRET, false);
check('a token signed with the wrong secret is rejected', verifyTokenLocally($t) === null);
$t = mint(['sub' => $uid, 'exp' => time() - 10], $SECRET);
check('an expired token is rejected', verifyTokenLocally($t) === null);
$t = mint(['role' => 'authenticated', 'exp' => time() + 3600], $SECRET);
check('a token with no subject is rejected', verifyTokenLocally($t) === null);
// alg:none downgrade — a classic JWT attack.
$h = b64u(json_encode(['alg' => 'none', 'typ' => 'JWT']));
$p = b64u(json_encode(['sub' => $uid, 'exp' => time() + 3600]));
check('an alg=none token is rejected', verifyTokenLocally("$h.$p.") === null);
check('a malformed token is rejected', verifyTokenLocally('not-a-jwt') === null);
check('an empty token is rejected', verifyTokenLocally('') === null);
// Tampered payload keeps the original signature.
$good = mint(['sub' => $uid, 'exp' => time() + 3600], $SECRET);
[$h2, $p2, $s2] = explode('.', $good);
$evil = $h2 . '.' . b64u(json_encode(['sub' => '00000000-0000-0000-0000-000000000000', 'exp' => time() + 3600])) . '.' . $s2;
check('a tampered payload is rejected', verifyTokenLocally($evil) === null);
echo "\n";
if ($fail) { echo "$fail FAILURE(S)\n"; exit(1); }
echo "ALL AUTH CHECKS PASSED\n";
/**
* End-to-end test of the real API over HTTP.
*
* Drives two independent "clients" through matchmaking, a complete chess game,
* and a full tournament round — exercising api/game.php, api/matchmaking.php,
* api/tournament-match.php, api/tournament-admin.php and api/cron.php exactly as
* a browser would, against a local Postgres carrying the production schema.
*/
import fs from 'node:fs';
import { execSync } from 'node:child_process';
const API = 'http://127.0.0.1:8788/api';
const PSQL = `psql -h 127.0.0.1 -p 54329 -U postgres -tAc`;
const src = fs.readFileSync(new URL('../public/js/lib/chess.min.js', import.meta.url), 'utf8');
const mod = { exports: {} };
new Function('module', 'exports', 'globalThis', src)(mod, mod.exports, globalThis);
const Chess = globalThis.Chess || mod.exports.Chess || mod.exports;
const failures = [];
function check(label, cond, detail = '') {
if (cond) { console.log(` ok ${label}`); return true; }
failures.push(label);
console.log(` FAIL ${label}${detail ? ' — ' + detail : ''}`);
return false;
}
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const token = (sub) => `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64({ sub })}.sig`;
async function call(endpoint, body, user) {
const res = await fetch(`${API}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token(user)}` },
body: JSON.stringify(body),
});
const text = await res.text();
try { return { status: res.status, data: JSON.parse(text) }; }
catch { return { status: res.status, data: { _raw: text.slice(0, 300) } }; }
}
async function get(endpoint, params, user) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`${API}/${endpoint}?${qs}`, {
headers: { Authorization: `Bearer ${token(user)}` },
});
const text = await res.text();
try { return { status: res.status, data: JSON.parse(text) }; }
catch { return { status: res.status, data: { _raw: text.slice(0, 300) } }; }
}
const sql = (q) => execSync(`${PSQL} "${q.replace(/"/g, '\\"')}"`).toString().trim();
// ---------------------------------------------------------------------------
const P = [
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
'44444444-4444-4444-4444-444444444444',
];
console.log('=== setup ===');
sql(`DELETE FROM matches; DELETE FROM el3ab_tournament_rounds; DELETE FROM tournament_registrations; DELETE FROM el3ab_tournaments; DELETE FROM profiles;`);
sql(`CREATE TABLE IF NOT EXISTS matchmaking_queue (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), player_id uuid, game_key text, time_control time_control, rating int, status text, matched_with uuid, match_id uuid, queued_at timestamptz DEFAULT now())`);
sql(`CREATE TABLE IF NOT EXISTS player_blocks (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), player_id uuid, blocked_id uuid, type text)`);
sql(`CREATE TABLE IF NOT EXISTS admin_users (id uuid PRIMARY KEY)`);
sql(`CREATE TABLE IF NOT EXISTS achievements (id text PRIMARY KEY, "condition" jsonb, coins_reward int, xp_reward int)`);
sql(`CREATE TABLE IF NOT EXISTS player_achievements (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), player_id uuid, achievement_id text, progress int, completed bool, completed_at timestamptz)`);
sql(`DELETE FROM matchmaking_queue;`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS is_banned bool DEFAULT false`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS ban_expires_at timestamptz`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS is_admin bool DEFAULT false`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS games_played int DEFAULT 0`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS total_wins int DEFAULT 0`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS win_streak int DEFAULT 0`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS xp int DEFAULT 0`);
sql(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS level int DEFAULT 1`);
P.forEach((id, i) => sql(
`INSERT INTO profiles (id, display_name, username, elo_rapid, elo_blitz, is_admin) VALUES ('${id}','Player ${i + 1}','p${i + 1}',${1900 - i * 60},${1850 - i * 50}, ${i === 0})`
));
console.log(' seeded 4 players');
// ---------------------------------------------------------------------------
console.log('\n=== 1. matchmaking hand-off ===');
let r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
check('player A is queued', r.data.queued === true, JSON.stringify(r.data));
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[1]);
const bMatch = r.data;
check('player B is matched immediately', !!bMatch.match_id, JSON.stringify(bMatch));
check('player B receives a colour', bMatch.color === 'w' || bMatch.color === 'b', bMatch.color);
// The critical regression: A re-queueing (the 60s retry) must NOT destroy the hand-off.
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
check('A re-queueing returns the SAME match instead of orphaning it',
r.data.match_id === bMatch.match_id, `${r.data.match_id} vs ${bMatch.match_id}`);
const aColor = r.data.color;
check('A receives a colour', aColor === 'w' || aColor === 'b', String(aColor));
check('the two players have OPPOSITE colours', aColor && bMatch.color && aColor !== bMatch.color,
`A=${aColor} B=${bMatch.color}`);
const matchId = bMatch.match_id;
const white = aColor === 'w' ? P[0] : P[1];
const black = aColor === 'w' ? P[1] : P[0];
console.log('\n=== 2. server tells each client its own colour ===');
let g = await call('game.php', { action: 'get', match_id: matchId }, white);
check('white is told my_color=w', g.data.my_color === 'w', JSON.stringify(g.data.my_color));
g = await call('game.php', { action: 'get', match_id: matchId }, black);
check('black is told my_color=b', g.data.my_color === 'b', JSON.stringify(g.data.my_color));
check('opponent_id is supplied', g.data.opponent_id === white, g.data.opponent_id);
g = await call('game.php', { action: 'get', match_id: matchId }, P[2]);
check('a non-participant cannot read the live game', g.status === 403, `status ${g.status}`);
// ---------------------------------------------------------------------------
console.log('\n=== 3. turn ownership ===');
const c = new Chess();
// Black tries to move while it is White's turn.
c.move('e4');
r = await call('game.php', {
action: 'move', match_id: matchId, fen: c.fen(), move_count: 1,
move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4' }]),
}, black);
check('a move from the wrong player is rejected', r.status === 409, `status ${r.status} ${JSON.stringify(r.data)}`);
r = await call('game.php', {
action: 'move', match_id: matchId, fen: c.fen(), move_count: 1,
move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4' }]),
}, white);
check('white\'s own move is accepted', r.data.success === true, JSON.stringify(r.data));
r = await call('game.php', {
action: 'move', match_id: matchId, fen: c.fen(), move_count: 1,
move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4' }]),
}, white);
check('a replayed move_count is treated as a duplicate, not applied twice', r.data.duplicate === true, JSON.stringify(r.data));
// A tampered position (conjured queen) must be refused.
const cheat = 'rnbqkbnr/pppppppp/8/8/4P3/5Q2/PPPP1PPP/RNBQKBNR b KQkq - 0 1';
r = await call('game.php', { action: 'move', match_id: matchId, fen: cheat, move_count: 2 }, black);
check('a fabricated position is rejected', r.status === 409, `status ${r.status} ${JSON.stringify(r.data)}`);
// ---------------------------------------------------------------------------
console.log('\n=== 4. play the Opera Game to checkmate ===');
// Fresh match: section 3 already advanced the first board.
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
const gm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[1])).data;
const gMatchId = gm.match_id;
const gInfo = (await call('game.php', { action: 'get', match_id: gMatchId }, P[0])).data;
const gWhite = gInfo.my_color === 'w' ? P[0] : P[1];
const gBlack = gWhite === P[0] ? P[1] : P[0];
check('fresh match for the full game', !!gMatchId, JSON.stringify(gm));
const OPERA = 'e4 e5 Nf3 d6 d4 Bg4 dxe5 Bxf3 Qxf3 dxe5 Bc4 Nf6 Qb3 Qe7 Nc3 c6 Bg5 b5 Nxb5 cxb5 Bxb5+ Nbd7 O-O-O Rd8 Rxd7 Rxd7 Rd1 Qe6 Bxd7+ Nxd7 Qb8+ Nxb8 Rd8#'.split(' ');
const game = new Chess();
const history = [];
let mc = 0;
let rejected = 0;
for (const san of OPERA) {
const mover = game.turn();
const m = game.move(san);
history.push({ from: m.from, to: m.to, san: m.san });
mc++;
const who = mover === 'w' ? gWhite : gBlack;
const res = await call('game.php', {
action: 'move', match_id: gMatchId, fen: game.fen(), move_count: mc,
move: JSON.stringify(history),
}, who);
if (res.data.success !== true) { rejected++; console.log(` move ${mc} (${san}) -> ${JSON.stringify(res.data)}`); }
}
check(`all ${OPERA.length} legal moves accepted`, rejected === 0, `${rejected} rejected`);
check('the game is checkmate', game.isCheckmate ? game.isCheckmate() : game.in_checkmate());
console.log('\n=== 5. the server decides the winner, not the client ===');
// The LOSER reports first, falsely claiming a win.
const loser = game.turn() === 'w' ? gWhite : gBlack; // side to move is mated
const winner = loser === gWhite ? gBlack : gWhite;
r = await call('game.php', {
action: 'complete', match_id: gMatchId, reason: 'checkmate', result: 'win',
fen: game.fen(), pgn: game.pgn(),
}, loser);
check('the loser\'s false win claim does not win them the game',
r.data.winner === (winner === gWhite ? 'white' : 'black'),
JSON.stringify(r.data));
check('result is a canonical enum value',
['white_wins', 'black_wins', 'draw', 'aborted'].includes(r.data.result), r.data.result);
const row = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, result, metadata FROM matches WHERE id='${gMatchId}') t`));
check('match is marked completed in the database', row.status === 'completed', row.status);
check('result persisted', row.result === (winner === gWhite ? 'white_wins' : 'black_wins'), row.result);
check('metadata records the winner', row.metadata.winner === (winner === gWhite ? 'white' : 'black'), JSON.stringify(row.metadata));
check('metadata records the reason', row.metadata.end_reason === 'checkmate', JSON.stringify(row.metadata));
r = await call('game.php', { action: 'complete', match_id: gMatchId, reason: 'checkmate' }, winner);
check('the second report is idempotent', r.data.already_completed === true, JSON.stringify(r.data));
// ---------------------------------------------------------------------------
console.log('\n=== 6. resignation ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[2]);
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[3]);
const m2 = r.data.match_id;
check('second match created', !!m2, JSON.stringify(r.data));
const g2 = await call('game.php', { action: 'get', match_id: m2 }, P[2]);
const p2Color = g2.data.my_color;
r = await call('game.php', { action: 'resign', match_id: m2 }, P[2]);
check('resigning hands the win to the opponent',
r.data.result === (p2Color === 'w' ? 'black_wins' : 'white_wins'), JSON.stringify(r.data));
// ---------------------------------------------------------------------------
console.log('\n=== 7. draw offers need agreement ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_3_0' }, P[0]);
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_3_0' }, P[1]);
const m3 = r.data.match_id;
r = await call('game.php', { action: 'draw', match_id: m3 }, P[0]);
check('a draw cannot be taken without an offer standing', r.status === 409, `status ${r.status} ${JSON.stringify(r.data)}`);
sql(`UPDATE matches SET game_state = '{"draw_offer":"${P[1]}"}'::jsonb WHERE id='${m3}'`);
r = await call('game.php', { action: 'draw', match_id: m3 }, P[0]);
check('a draw offered by the opponent can be accepted', r.data.result === 'draw', JSON.stringify(r.data));
// ---------------------------------------------------------------------------
console.log('\n=== 8. tournament: create, start, pair ===');
r = await call('tournament-admin.php', {
action: 'create', name: 'E2E Cup', format: 'swiss', time_control: 'blitz_3_0',
rounds: 3, min_players: 4, max_players: 8, auto_start: true,
}, P[0]);
const tid = r.data.tournament?.id;
check('tournament created', !!tid, JSON.stringify(r.data));
for (const p of P) {
const rr = await call('tournaments.php', { action: 'register', tournament_id: tid }, p);
if (!rr.data.success) check(`register ${p.slice(0, 8)}`, false, JSON.stringify(rr.data));
}
r = await call('tournaments.php', { action: 'register', tournament_id: tid }, P[0]);
check('registering twice is idempotent', r.data.already_registered === true, JSON.stringify(r.data));
r = await call('tournament-admin.php', { action: 'start', tournament_id: tid }, P[0]);
check('tournament starts', r.data.ok === true, JSON.stringify(r.data));
check('round 1 has 2 boards for 4 players', r.data.round?.pairings?.length === 2, JSON.stringify(r.data.round?.pairings?.length));
r = await call('tournament-admin.php', { action: 'start', tournament_id: tid }, P[1]);
check('a non-admin cannot start a tournament', r.status === 403, `status ${r.status}`);
console.log('\n=== 9. both paired players open the SAME board ===');
const pairing = (await get('swiss.php', { action: 'pairings', tournament_id: tid }, P[0])).data.pairings[0];
const [pw, pb] = [pairing.white_id, pairing.black_id];
const [ra, rb] = await Promise.all([
call('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, pw),
call('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, pb),
]);
check('both clients receive a match', !!ra.data.match_id && !!rb.data.match_id, JSON.stringify([ra.data, rb.data]));
check('both are on the SAME match', ra.data.match_id === rb.data.match_id, `${ra.data.match_id} vs ${rb.data.match_id}`);
check('their colours are opposite', ra.data.color !== rb.data.color, `${ra.data.color} / ${rb.data.color}`);
check('white is white', ra.data.color === 'w', ra.data.color);
const nMatches = sql(`SELECT count(*) FROM matches WHERE tournament_id='${tid}'`);
check('exactly one match row was created for that pairing', nMatches === '1', `${nMatches} rows`);
console.log('\n=== 10. results advance the round ===');
const pairings = (await get('swiss.php', { action: 'pairings', tournament_id: tid }, P[0])).data.pairings;
for (const p of pairings) {
const j = await call('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, p.white_id);
await call('game.php', { action: 'resign', match_id: j.data.match_id }, p.black_id);
}
const t2 = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${tid}') t`));
check('the round advanced automatically once all games finished', t2.current_round === 2, JSON.stringify(t2));
const st = (await get('swiss.php', { action: 'standings', tournament_id: tid }, P[0])).data.standings;
check('standings are populated', st.length === 4, `${st.length} rows`);
check('the two winners lead on 1 point', st[0].points === 1 && st[1].points === 1, JSON.stringify(st.map(s => s.points)));
check('standings carry tiebreaks', typeof st[0].tiebreaks?.buchholz_cut_1 === 'number');
console.log('\n=== 11. no-show forfeits keep the round moving ===');
const before = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT current_round FROM el3ab_tournaments WHERE id='${tid}') t`));
sql(`UPDATE el3ab_tournament_rounds SET started_at = now() - interval '1 hour' WHERE tournament_id='${tid}' AND round_number=${before.current_round}`);
const cron = await fetch('http://127.0.0.1:8788/api/cron.php?secret=testsecret');
const cronData = await cron.json();
check('cron ran', cron.status === 200, JSON.stringify(cronData).slice(0, 200));
check('cron forfeited the unplayed games', (cronData.forfeited || []).length > 0, JSON.stringify(cronData.forfeited));
const after = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${tid}') t`));
check('the tournament moved on rather than stalling',
after.current_round > before.current_round || after.status === 'completed', JSON.stringify(after));
console.log('\n=== 12. cron auto-starts a due tournament ===');
r = await call('tournament-admin.php', {
action: 'create', name: 'Auto Start Cup', format: 'swiss', time_control: 'blitz_3_0',
rounds: 2, min_players: 4, max_players: 8, auto_start: true,
}, P[0]);
const tid2 = r.data.tournament.id;
for (const p of P) await call('tournaments.php', { action: 'register', tournament_id: tid2 }, p);
sql(`UPDATE el3ab_tournaments SET starts_at = now() - interval '1 minute' WHERE id='${tid2}'`);
const cron2 = await (await fetch('http://127.0.0.1:8788/api/cron.php?secret=testsecret')).json();
check('cron started the due tournament', (cron2.started || []).some(s => s.id === tid2), JSON.stringify(cron2.started));
const t3 = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${tid2}') t`));
check('it is now in progress on round 1', t3.status === 'in_progress' && t3.current_round === 1, JSON.stringify(t3));
const noSecret = await fetch('http://127.0.0.1:8788/api/cron.php');
check('cron refuses an unauthenticated call', noSecret.status === 403, `status ${noSecret.status}`);
console.log('\n=== 13. the server clock flags an absent player ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_3_0' }, P[2]);
const cm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_3_0' }, P[3])).data;
const cMatch = cm.match_id;
const cInfo = (await call('game.php', { action: 'get', match_id: cMatch }, P[2])).data;
const cWhite = cInfo.my_color === 'w' ? P[2] : P[3];
const cBlack = cWhite === P[2] ? P[3] : P[2];
// One move played, then White walks away with 5 seconds left and an hour passes.
const cg = new Chess();
cg.move('e4');
await call('game.php', {
action: 'move', match_id: cMatch, fen: cg.fen(), move_count: 1,
move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4' }]),
}, cWhite);
cg.move('e5');
await call('game.php', {
action: 'move', match_id: cMatch, fen: cg.fen(), move_count: 2,
move: JSON.stringify([{ from: 'e7', to: 'e5', san: 'e5' }]),
}, cBlack);
sql(`UPDATE matches SET white_time_remaining_ms = 3000, metadata = jsonb_set(metadata, '{last_move_at}', to_jsonb((now() - interval '1 hour')::text)) WHERE id='${cMatch}'`);
const flagged = (await call('game.php', { action: 'get', match_id: cMatch }, cBlack)).data;
check('the absent player is flagged on time', flagged.status === 'completed', JSON.stringify({ s: flagged.status, r: flagged.result }));
check('the player who ran out of time loses',
flagged.result === (cWhite === P[2] ? (cInfo.my_color === 'w' ? 'black_wins' : 'black_wins') : 'black_wins'),
flagged.result);
const cRow = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT result, metadata FROM matches WHERE id='${cMatch}') t`));
check('the end reason is recorded as a timeout', cRow.metadata.end_reason === 'timeout', JSON.stringify(cRow.metadata));
check('white flagged, so black wins', cRow.result === 'black_wins', cRow.result);
console.log('\n=== 14. a player still on the clock is NOT flagged ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
const om = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[1])).data;
const oMatch = om.match_id;
const oInfo = (await call('game.php', { action: 'get', match_id: oMatch }, P[0])).data;
const oWhite = oInfo.my_color === 'w' ? P[0] : P[1];
const og = new Chess();
og.move('d4');
await call('game.php', {
action: 'move', match_id: oMatch, fen: og.fen(), move_count: 1,
move: JSON.stringify([{ from: 'd2', to: 'd4', san: 'd4' }]),
}, oWhite);
sql(`UPDATE matches SET metadata = jsonb_set(metadata, '{last_move_at}', to_jsonb((now() - interval '30 seconds')::text)) WHERE id='${oMatch}'`);
const notFlagged = (await call('game.php', { action: 'get', match_id: oMatch }, oWhite)).data;
check('a thinking player is not flagged', notFlagged.status === 'in_progress', notFlagged.status);
check('their remaining time is reported, reduced', notFlagged.black_time_remaining_ms < 600000 && notFlagged.black_time_remaining_ms > 500000,
String(notFlagged.black_time_remaining_ms));
console.log('\n=== 15. heartbeats let each client see the other is alive ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[2]);
const hm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[3])).data;
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[2]);
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[3]);
const hs = JSON.parse(sql(`SELECT game_state FROM matches WHERE id='${hm.match_id}'`));
check('both players\' heartbeats are recorded', !!hs[`hb_${P[2]}`] && !!hs[`hb_${P[3]}`], JSON.stringify(hs));
check('one heartbeat does not overwrite the other', Object.keys(hs).filter(k => k.startsWith('hb_')).length === 2, JSON.stringify(Object.keys(hs)));
// A draw offer must survive a heartbeat landing on the same row.
sql(`UPDATE matches SET game_state = game_state || '{"draw_offer":"${P[2]}"}'::jsonb WHERE id='${hm.match_id}'`);
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[3]);
const hs2 = JSON.parse(sql(`SELECT game_state FROM matches WHERE id='${hm.match_id}'`));
check('a heartbeat does not clobber a standing draw offer', hs2.draw_offer === P[2], JSON.stringify(hs2));
// ---------------------------------------------------------------------------
console.log('');
if (failures.length) {
console.log(`${failures.length} FAILURE(S):`);
failures.forEach(f => console.log(' - ' + f));
process.exit(1);
}
console.log('ALL END-TO-END CHECKS PASSED');
<?php
/**
* Integration test: the tournament engine against the REAL Supabase database.
*
* The offline suite proves the pairing maths. This proves the parts only a real
* Postgres can: jsonb round-trips, the match_result / time_control enums, the
* deterministic-match-id race guard, and the conditional writes.
*
* Everything it creates is prefixed ZZZ-ENGINE-TEST and removed in teardown,
* which also runs if an assertion fails.
*/
// Runs against a LOCAL Postgres carrying a replica of the production types and
// tables — real enums, real jsonb, real primary keys — so no production data is
// touched.
require_once __DIR__ . '/pgshim.php';
if (!function_exists('supabaseService')) { function supabaseService() { return new PgShim(); } }
if (!function_exists('supabaseRpc')) { function supabaseRpc($f, $p = []) { return null; } }
if (!function_exists('supabase')) { function supabase($t = null) { return new PgShim(); } }
$ROOT = dirname(__DIR__);
require_once $ROOT . '/includes/chess.php';
require_once __DIR__ . '/engine_shim.php';
$PLAYERS = [
'5ed4d72d-555f-4d19-8315-204dafd3a2e7',
'05a1a1af-a3c3-42a7-923d-35288914b251',
'0a2911a5-6e99-46bb-94a3-46af69d124ab',
'991ec242-44f1-49a5-a7ba-2413839aeb79',
'a2aba430-4852-49e0-9ff9-be6a86526052',
'd984ada5-39c2-4377-a972-fc651563efee',
'b5da2bf1-0d28-4297-b96e-566b72ddedd9',
'66fb12a9-4def-48e5-afdd-fe27966e78b8',
];
$db = new PgShim();
// Seed the eight test players locally.
foreach ($PLAYERS as $i => $pid) {
$db->insert('profiles', ['id' => $pid, 'display_name' => 'Test Player ' . ($i+1),
'username' => 'tp' . ($i+1), 'elo_rapid' => 2000 - $i * 55, 'elo_blitz' => 1900 - $i * 40]);
}
$FAIL = [];
$tournamentId = null;
$createdMatchIds = [];
function check(string $label, bool $cond, string $detail = ''): void {
global $FAIL;
if ($cond) { echo " ok $label\n"; return; }
$FAIL[] = $label;
echo " FAIL $label" . ($detail ? " — $detail" : '') . "\n";
}
function teardown() {
global $db, $tournamentId, $createdMatchIds;
echo "\n-- teardown\n";
foreach ($createdMatchIds as $mid) {
$db->delete('mp_log', ['match_id' => 'eq.' . $mid]);
$db->delete('matches', ['id' => 'eq.' . $mid]);
}
if ($tournamentId) {
$db->delete('matches', ['tournament_id' => 'eq.' . $tournamentId]);
$db->delete('el3ab_tournament_rounds', ['tournament_id' => 'eq.' . $tournamentId]);
$db->delete('tournament_registrations', ['tournament_id' => 'eq.' . $tournamentId]);
$db->delete('el3ab_tournaments', ['id' => 'eq.' . $tournamentId]);
echo " removed tournament $tournamentId and its rows\n";
}
}
register_shutdown_function('teardown');
// ---------------------------------------------------------------------------
echo "=== 1. create tournament ===\n";
$created = $db->insert('el3ab_tournaments', [
'name' => 'ZZZ-ENGINE-TEST',
'game_key' => 'chess',
'format' => 'swiss',
'time_control' => 'blitz_3_2',
'swiss_rounds' => 3,
'rounds_total' => 3,
'status' => TOURNAMENT_STATUS_REGISTRATION,
'current_round' => 0,
'min_players' => 4,
'max_players' => 16,
'auto_start' => true,
'is_rated' => false,
'starts_at' => gmdate('c', time() - 60),
'tiebreak_rules' => ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'],
]);
check('tournament row created', !isset($created['error']) && !empty($created[0]['id']), json_encode($created));
if (isset($created['error'])) { exit(1); }
$tournamentId = $created[0]['id'];
echo " id = $tournamentId\n";
echo "=== 2. register players ===\n";
foreach ($PLAYERS as $pid) {
$r = $db->insert('tournament_registrations', [
'tournament_id' => $tournamentId, 'player_id' => $pid, 'status' => 'registered',
]);
if (isset($r['error'])) { check("register $pid", false, json_encode($r)); }
}
$loaded = tournamentPlayers($db, $tournamentId, 'blitz_3_2');
check('all players load back', count($loaded) === count($PLAYERS), 'got ' . count($loaded));
check('players carry ratings', !empty($loaded) && isset($loaded[0]['rating']));
echo "=== 3. start (pairs round 1) ===\n";
$start = tournamentStart($db, $tournamentId);
check('start succeeded', $start['ok'], $start['error'] ?? '');
check('round 1 exists', !empty($start['round']));
$r1 = $start['round'];
check('pairings survived the jsonb round-trip as an array', is_array($r1['pairings']), gettype($r1['pairings']));
check('correct number of boards', count($r1['pairings']) === count($PLAYERS) / 2, count($r1['pairings']) . ' boards');
foreach ($r1['pairings'] as $p) {
check("board {$p['board']} has both colours", !empty($p['white_id']) && !empty($p['black_id']));
check("board {$p['board']} has a pairing_id", !empty($p['pairing_id']));
}
echo "=== 4. starting twice must not create a second round 1 ===\n";
$again = tournamentStart($db, $tournamentId);
$rounds = tournamentRounds($db, $tournamentId);
check('still exactly one round', count($rounds) === 1, count($rounds) . ' rounds');
echo "=== 5. two players race to open the same board ===\n";
$p0 = $r1['pairings'][0];
$matchId = tournamentMatchIdFor($tournamentId, 1, (int)$p0['board']);
$clock = chessTimeControlMs('blitz_3_2');
$mk = fn() => $db->insert('matches', [
'id' => $matchId, 'game_key' => 'chess', 'match_type' => 'tournament',
'white_player_id' => $p0['white_id'], 'black_player_id' => $p0['black_id'],
'status' => 'in_progress', 'time_control' => 'blitz_3_2',
'initial_time_ms' => $clock['initial'], 'increment_ms' => $clock['increment'],
'white_time_remaining_ms' => $clock['initial'], 'black_time_remaining_ms' => $clock['initial'],
'tournament_id' => $tournamentId, 'tournament_round' => 1,
'pairing_id' => $p0['pairing_id'],
'starting_fen' => CHESS_START_FEN, 'current_fen' => CHESS_START_FEN,
'moves' => [], 'move_count' => 0, 'is_rated' => false,
'started_at' => gmdate('c'),
'metadata' => ['mode' => 'tournament', 'board' => $p0['board']],
]);
$first = $mk();
$createdMatchIds[] = $matchId;
$second = $mk();
check('first insert wins', !isset($first['error']), json_encode($first));
check('second insert is rejected by the primary key', isset($second['error']), json_encode($second));
$all = $db->get('matches', ['tournament_id' => 'eq.' . $tournamentId, 'select' => 'id']);
check('exactly one match exists for that pairing', count($all) === 1, count($all) . ' matches');
echo "=== 6. jsonb columns round-trip as objects, not strings ===\n";
$row = $db->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'moves,metadata,game_state', 'limit' => 1])[0];
check('moves is an array', is_array($row['moves']), gettype($row['moves']) . ' = ' . json_encode($row['moves']));
check('metadata is an array', is_array($row['metadata']), gettype($row['metadata']) . ' = ' . json_encode($row['metadata']));
echo "=== 7. every derived result is accepted by the match_result enum ===\n";
foreach (['checkmate', 'resign', 'timeout', 'stalemate', 'agreement', 'abandon', 'aborted'] as $reason) {
$d = chessDeriveResult($reason, CHESS_START_FEN, 'w');
$res = $db->update('matches', ['result' => $d['result']], ['id' => 'eq.' . $matchId]);
check("enum accepts '{$d['result']}' (from $reason)", !isset($res['error']), json_encode($res));
}
$db->update('matches', ['result' => null, 'status' => 'in_progress'], ['id' => 'eq.' . $matchId]);
echo "=== 8. play the whole tournament ===\n";
for ($round = 1; $round <= 3; $round++) {
$t = tournamentLoad($db, $tournamentId);
if ($t['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) break;
$rn = (int)$t['current_round'];
$r = tournamentRound($db, $tournamentId, $rn);
check("round $rn is open", $r && $r['status'] === ROUND_STATUS_IN_PROGRESS);
if (!$r) break;
$seats = [];
foreach ($r['pairings'] as $p) {
if (!empty($p['is_bye'])) { $seats[] = $p['white_id']; continue; }
$seats[] = $p['white_id']; $seats[] = $p['black_id'];
}
check("round $rn seats everyone exactly once",
count($seats) === count($PLAYERS) && count(array_unique($seats)) === count($PLAYERS),
count($seats) . ' seats / ' . count(array_unique($seats)) . ' unique');
foreach ($r['pairings'] as $i => $p) {
if (!empty($p['is_bye'])) continue;
$res = [0 => 'white_wins', 1 => 'black_wins', 2 => 'draw'][($i + $rn) % 3];
tournamentRecordMatchResult($db, [
'id' => tournamentMatchIdFor($tournamentId, $rn, (int)$p['board']),
'game_key' => 'chess',
'tournament_id' => $tournamentId,
'tournament_round' => $rn,
'white_player_id' => $p['white_id'],
'black_player_id' => $p['black_id'],
], ['result' => $res, 'reason' => 'checkmate', 'winner' => $res === 'white_wins' ? 'white' : ($res === 'black_wins' ? 'black' : null)]);
}
}
$t = tournamentLoad($db, $tournamentId);
check('tournament reached completed', $t['status'] === TOURNAMENT_STATUS_COMPLETED,
"status={$t['status']} round={$t['current_round']}");
check('all 3 rounds were created', count(tournamentRounds($db, $tournamentId)) === 3);
echo "=== 9. standings ===\n";
$standings = tournamentStandings($db, $t);
check('standings cover every player', count($standings) === count($PLAYERS), count($standings) . ' rows');
$total = array_sum(array_column($standings, 'points'));
check('points conserved (1.0 per game)', abs($total - 12.0) < 0.001, "total=$total, expected 12.0");
check('ranks are 1..N', array_column($standings, 'rank') === range(1, count($PLAYERS)));
check('tiebreaks present', isset($standings[0]['tiebreaks']['buchholz_cut_1']));
foreach (array_slice($standings, 0, 4) as $s) {
printf(" %d. %-38s %.1f bh1=%.1f sb=%.1f\n", $s['rank'], substr($s['name'], 0, 36), $s['points'],
$s['tiebreaks']['buchholz_cut_1'], $s['tiebreaks']['sonneborn_berger']);
}
echo "=== 10. final placings written back ===\n";
$regs = $db->get('tournament_registrations', ['tournament_id' => 'eq.' . $tournamentId, 'select' => 'player_id,final_standing']);
$placed = array_filter($regs, fn($r) => $r['final_standing'] !== null);
check('every player has a final standing', count($placed) === count($PLAYERS), count($placed) . ' placed');
echo "\n";
if ($FAIL) { echo count($FAIL) . " FAILURE(S)\n"; exit(1); }
echo "ALL INTEGRATION CHECKS PASSED\n";
<?php
require_once dirname(__DIR__) . '/includes/chess.php';
$games = json_decode(file_get_contents('real_games.json'), true);
$fail = 0; $total = 0;
foreach ($games as $name => $seq) {
echo "-- $name (" . (count($seq) - 1) . " plies)\n";
for ($i = 1; $i < count($seq); $i++) {
$prev = $seq[$i-1]['fen'];
$next = $seq[$i]['fen'];
$mover = $seq[$i]['mover'];
$san = $seq[$i]['san'];
$total++;
[$ok, $why] = chessValidateTransition($prev, $next, $mover);
if (!$ok) { $fail++; echo " REJECTED LEGAL MOVE ply $i ($mover $san): $why\n prev=$prev\n next=$next\n"; }
// The wrong player must never be allowed to push this position.
$other = $mover === 'w' ? 'b' : 'w';
[$ok2,] = chessValidateTransition($prev, $next, $other);
if ($ok2) { $fail++; echo " ACCEPTED MOVE FROM WRONG PLAYER at ply $i ($san)\n"; }
// Replaying an earlier position must be refused.
if ($i >= 3) {
[$ok3,] = chessValidateTransition($prev, $seq[$i-2]['fen'], $mover);
if ($ok3) { $fail++; echo " ACCEPTED A REWIND at ply $i\n"; }
}
}
}
// Checkmate detection is derived purely from the final position.
$operaFinal = $games['opera'][count($games['opera'])-1]['fen'];
$d = chessDeriveResult('checkmate', $operaFinal, null);
echo "opera final derived: {$d['result']} (expect white_wins — Black is mated)\n";
if ($d['result'] !== 'white_wins') { $fail++; echo " WRONG WINNER\n"; }
$promoFinal = $games['promo'][count($games['promo'])-1]['fen'];
$d = chessDeriveResult('checkmate', $promoFinal, null);
echo "promo final derived: {$d['result']} (expect white_wins)\n";
if ($d['result'] !== 'white_wins') { $fail++; echo " WRONG WINNER\n"; }
echo "\n$total transitions checked, $fail failures\n";
exit($fail ? 1 : 0);
<?php
/**
* Offline test of the Swiss pairing engine.
*
* The engine talks to Supabase through a $sdb object, so we hand it a fake one
* backed by in-memory arrays and simulate whole tournaments: every round gets
* paired, results are generated, and the invariants are asserted after each one.
*/
// Stub out the Supabase layer before the engine pulls it in.
if (!function_exists('supabaseService')) { function supabaseService() { return null; } }
if (!function_exists('supabaseRpc')) { function supabaseRpc($f, $p = []) { return null; } }
if (!function_exists('supabase')) { function supabase($t = null) { return null; } }
require_once __DIR__ . '/engine_shim.php';
// ---------------------------------------------------------------------------
// Fake Supabase
// ---------------------------------------------------------------------------
class FakeDb {
public array $t = [
'el3ab_tournaments' => [],
'el3ab_tournament_rounds' => [],
'tournament_registrations' => [],
'profiles' => [],
];
private int $seq = 0;
public function get(string $table, array $params = []): array {
$rows = $this->t[$table] ?? [];
$out = [];
foreach ($rows as $r) {
if ($this->matches($r, $params)) $out[] = $r;
}
if (isset($params['order'])) {
[$col, $dir] = array_pad(explode('.', $params['order']), 2, 'asc');
usort($out, fn($a, $b) => ($dir === 'desc' ? -1 : 1) * (($a[$col] ?? null) <=> ($b[$col] ?? null)));
}
if (isset($params['limit'])) $out = array_slice($out, 0, (int)$params['limit']);
return $out;
}
private function matches(array $row, array $params): bool {
foreach ($params as $col => $expr) {
if (in_array($col, ['select', 'order', 'limit'], true)) continue;
if (!is_string($expr)) continue;
if (str_starts_with($expr, 'eq.')) {
if ((string)($row[$col] ?? '') !== substr($expr, 3)) return false;
} elseif (str_starts_with($expr, 'neq.')) {
if ((string)($row[$col] ?? '') === substr($expr, 4)) return false;
} elseif (str_starts_with($expr, 'in.(')) {
$vals = explode(',', substr($expr, 4, -1));
if (!in_array((string)($row[$col] ?? ''), $vals, true)) return false;
}
}
return true;
}
public function insert(string $table, array $data): array {
$data['id'] = $data['id'] ?? ('id-' . (++$this->seq));
$this->t[$table][] = $data;
return [$data];
}
public function update(string $table, array $data, array $params = []): array {
$changed = [];
foreach ($this->t[$table] as $i => $r) {
if (!$this->matches($r, $params)) continue;
$this->t[$table][$i] = array_merge($r, $data);
$changed[] = $this->t[$table][$i];
}
return $changed;
}
public function delete(string $table, array $params = []): array { return []; }
public function rpc(string $fn, array $data = []): array { return []; }
}
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
$FAILURES = [];
function check(string $label, bool $cond, string $detail = ''): void {
global $FAILURES;
if (!$cond) { $FAILURES[] = "$label" . ($detail ? " — $detail" : ''); echo " FAIL $label" . ($detail ? " — $detail" : '') . "\n"; }
}
function buildTournament(FakeDb $db, int $n, int $rounds, string $id = 'T1'): array {
$db->t['el3ab_tournaments'][] = [
'id' => $id, 'name' => "Test $n", 'game_key' => 'chess', 'format' => 'swiss',
'time_control' => 'rapid_10_0', 'swiss_rounds' => $rounds, 'rounds_total' => $rounds,
'status' => 'registration', 'current_round' => 0, 'min_players' => 2,
'bye_value' => 1.0, 'tiebreak_rules' => ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'],
];
for ($i = 1; $i <= $n; $i++) {
$pid = sprintf('p%03d', $i);
$db->t['tournament_registrations'][] = [
'id' => "reg-$i", 'tournament_id' => $id, 'player_id' => $pid,
'status' => 'registered', 'seed' => null, 'is_bot' => false,
'registered_at' => sprintf('2026-09-01T00:00:%02dZ', $i % 60),
];
$db->t['profiles'][] = [
'id' => $pid, 'display_name' => "Player $i", 'username' => "p$i",
'avatar_url' => null, 'elo_rapid' => 2400 - $i * 17,
];
}
return $db->t['el3ab_tournaments'][0];
}
/** Deterministic pseudo-result so runs are reproducible. */
function fakeResult(string $w, string $b, int $round): string {
$h = crc32($w . $b . $round);
$m = $h % 10;
if ($m < 4) return 'white_wins';
if ($m < 8) return 'black_wins';
return 'draw';
}
function simulate(int $n, int $rounds, bool $verbose = false): array {
$db = new FakeDb();
buildTournament($db, $n, $rounds);
$start = tournamentStart($db, 'T1');
check("n=$n start ok", $start['ok'], $start['error'] ?? '');
if (!$start['ok']) return ['db' => $db, 'played' => []];
$seenPairs = [];
$byeCount = [];
for ($r = 1; $r <= $rounds; $r++) {
$t = tournamentLoad($db, 'T1');
if ($t['status'] !== 'in_progress') break;
$round = tournamentRound($db, 'T1', (int)$t['current_round']);
if (!$round) break;
$rn = (int)$round['round_number'];
$seats = [];
foreach ($round['pairings'] as $p) {
if (!empty($p['is_bye'])) {
$who = $p['white_id'] ?: $p['black_id'];
$byeCount[$who] = ($byeCount[$who] ?? 0) + 1;
$seats[] = $who;
continue;
}
$key = implode('|', [min($p['white_id'], $p['black_id']), max($p['white_id'], $p['black_id'])]);
check("n=$n r$rn no rematch", !isset($seenPairs[$key]), "$key repeated");
$seenPairs[$key] = true;
$seats[] = $p['white_id'];
$seats[] = $p['black_id'];
}
check("n=$n r$rn everyone seated once", count($seats) === $n && count(array_unique($seats)) === $n,
'seated=' . count($seats) . ' unique=' . count(array_unique($seats)) . " expected=$n");
// Play every game.
foreach ($round['pairings'] as $p) {
if (!empty($p['is_bye'])) continue;
$res = fakeResult($p['white_id'], $p['black_id'], $rn);
$derived = ['result' => $res, 'reason' => 'checkmate', 'winner' => $res === 'white_wins' ? 'white' : ($res === 'black_wins' ? 'black' : null)];
tournamentRecordMatchResult($db, [
'id' => "m-$rn-{$p['board']}",
'game_key' => 'chess',
'tournament_id' => 'T1',
'tournament_round' => $rn,
'white_player_id' => $p['white_id'],
'black_player_id' => $p['black_id'],
], $derived);
}
}
foreach ($byeCount as $who => $c) {
check("n=$n at most one bye per player", $c <= 1, "$who had $c byes");
}
$t = tournamentLoad($db, 'T1');
check("n=$n reached completion", $t['status'] === 'completed', "status={$t['status']} round={$t['current_round']}");
$standings = tournamentStandings($db, $t);
$totalPoints = array_sum(array_column($standings, 'points'));
$rs = tournamentRounds($db, 'T1');
$expected = 0.0;
foreach ($rs as $rr) {
foreach ($rr['pairings'] as $p) {
$expected += !empty($p['is_bye']) ? 1.0 : 1.0;
}
}
check("n=$n points conserved", abs($totalPoints - $expected) < 0.001, "got $totalPoints expected $expected");
// Colour balance: nobody should be more than 2 games off.
foreach ($standings as $s) {
$w = 0; $b = 0;
foreach ($s['colors'] as $c) { $c === 'w' ? $w++ : $b++; }
check("n=$n colour balance", abs($w - $b) <= 2, "{$s['player_id']} w=$w b=$b");
}
if ($verbose) {
echo " standings (top 5):\n";
foreach (array_slice($standings, 0, 5) as $s) {
printf(" %2d. %-10s %4.1f bh1=%.1f sb=%.1f\n", $s['rank'], $s['player_id'], $s['points'],
$s['tiebreaks']['buchholz_cut_1'], $s['tiebreaks']['sonneborn_berger']);
}
}
return ['db' => $db, 'standings' => $standings];
}
echo "=== Swiss pairing engine ===\n";
foreach ([[2,1],[3,3],[4,3],[5,5],[6,5],[7,5],[8,5],[9,5],[16,5],[17,5],[32,7],[64,7],[100,9]] as [$n, $r]) {
echo "-- $n players, $r rounds\n";
simulate($n, $r, $n === 8);
}
echo "\n=== Result derivation (includes/chess.php) ===\n";
$mateFen = 'rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3'; // white to move, mated
$d = chessDeriveResult('checkmate', $mateFen, null);
check('checkmate: side to move loses', $d['result'] === 'black_wins', json_encode($d));
$d = chessDeriveResult('resign', CHESS_START_FEN, 'w');
check('white resigns -> black wins', $d['result'] === 'black_wins', json_encode($d));
$d = chessDeriveResult('resign', CHESS_START_FEN, 'b');
check('black resigns -> white wins', $d['result'] === 'white_wins', json_encode($d));
$d = chessDeriveResult('timeout', CHESS_START_FEN, 'b');
check('black flags -> white wins', $d['result'] === 'white_wins', json_encode($d));
$d = chessDeriveResult('stalemate', CHESS_START_FEN, null);
check('stalemate -> draw', $d['result'] === 'draw', json_encode($d));
foreach (['checkmate','resign','timeout','abandon','stalemate','agreement','insufficient_material','threefold_repetition','fifty_moves','aborted','unknown'] as $reason) {
$d = chessDeriveResult($reason, CHESS_START_FEN, 'w');
check("result '$reason' is a real enum value", in_array($d['result'], MATCH_RESULT_ENUM, true), $d['result']);
check("result '$reason' is canonical", in_array($d['result'], MATCH_RESULT_CANONICAL, true), $d['result']);
}
echo "\n=== FEN validation ===\n";
check('start position parses', chessParseFen(CHESS_START_FEN) !== null);
check('missing king rejected', chessParseFen('rnbq1bnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1') === null);
check('short rank rejected', chessParseFen('rnbqkbn/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1') === null);
check('garbage rejected', chessParseFen('not a fen') === null);
check('empty rejected', chessParseFen('') === null);
$after_e4 = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1';
[$ok, $why] = chessValidateTransition(CHESS_START_FEN, $after_e4, 'w');
check('1.e4 accepted', $ok, $why);
[$ok, $why] = chessValidateTransition(CHESS_START_FEN, $after_e4, 'b');
check('black cannot move on white turn', !$ok, $why);
[$ok, $why] = chessValidateTransition($after_e4, CHESS_START_FEN, 'b');
check('cannot rewind to a white-to-move position as black', !$ok, $why);
$extraQueen = 'rnbqkbnr/pppppppp/8/8/8/5Q2/PPPPPPPP/RNBQKBNR b KQkq - 0 1';
[$ok, $why] = chessValidateTransition(CHESS_START_FEN, $extraQueen, 'w');
check('conjuring a queen rejected', !$ok, $why);
echo "\n=== Time control ===\n";
check('blitz_3_2 initial', chessTimeControlMs('blitz_3_2')['initial'] === 180000);
check('blitz_3_2 increment', chessTimeControlMs('blitz_3_2')['increment'] === 2000);
check('rapid_10_0 initial', chessTimeControlMs('rapid_10_0')['initial'] === 600000);
check('bogus tc normalised', chessNormaliseTimeControl('standard') === 'rapid_10_0');
check('valid tc preserved', chessNormaliseTimeControl('blitz_5_0') === 'blitz_5_0');
echo "\n";
if ($FAILURES) {
echo count($FAILURES) . " FAILURE(S)\n";
exit(1);
}
echo "ALL CHECKS PASSED\n";
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment