# 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.
