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])) {
......
This diff is collapsed.
......@@ -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);
......
This diff is collapsed.
<?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]);
}
This diff is collapsed.
......@@ -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,
]);
$existing = (is_array($existing) && !empty($existing) && !isset($existing['error'])) ? $existing[0] : null;
if ($existing && ($existing['status'] ?? '') === 'registered') {
jsonResponse(['success' => true, 'already_registered' => true]);
}
// 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',
]);
if (!empty($existing) && !isset($existing['error'])) jsonError('Already registered');
$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);
}
$result = $db->insert('tournament_registrations', [
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'
'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 {
// 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 {
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) {
......
This diff is collapsed.
......@@ -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);
......
This diff is collapsed.
......@@ -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();
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');
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
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');
......
This diff is collapsed.
This diff is collapsed.
<?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);
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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