1. 01 Sep, 2026 6 commits
    • Mahmoud Aglan's avatar
    • Mahmoud Aglan's avatar
      fix: a bot outage no longer freezes the board on "thinking…" · 231ba42e
      Mahmoud Aglan authored
      requestBotMove retried forever on failure and did nothing at all when the engine
      answered without a move, so a Stockfish hiccup left the player watching the
      thinking indicator with no way to continue.
      
      Now: four attempts with exponential backoff, then a dialog offering to end the
      game. Also guards against gameState having been torn down mid-request.
      
      The engine itself is healthy — verified live: GET /api/chess/bots returns the
      seven bots the client expects, and POST /api/chess/move answers the start
      position with d2d4 at depth 3. api/bots.php's paths are correct.
      
      Arabic and English strings added for the new message.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      231ba42e
    • Mahmoud Aglan's avatar
      fix: rated games now actually change ratings · 34f9459c
      Mahmoud Aglan authored
      No completed match in production has ever had white_rating_after or
      rating_change_white written, and 272 of the 290 profiles were still sitting on
      the default 1200. The complete_match database function does not touch the rating
      columns, and game.php had calculateElo() and getRatingColumn() helpers that
      nothing ever called.
      
      Elo is now computed in applyRatings(), called from finaliseMatch() after its
      conditional write has succeeded — so a game can never be rated twice. It writes
      both ratings before and after and the deltas onto the match, updates the correct
      per-time-control column on each profile along with the game/win/loss/draw and
      streak counters, and records a rating_history row per player.
      
      This matters beyond the leaderboard: Swiss seeding orders players by rating, so
      with every rating pinned at 1200 the top-half/bottom-half split in round 1 was
      arbitrary.
      
      Bot games and aborted games are never rated, and a match with the same player on
      both sides is refused outright.
      
      Covered by e2e sections 18 and 19.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      34f9459c
    • Mahmoud Aglan's avatar
      fix: a stale queue claim must not drop a player into a dead match · 16a760f7
      Mahmoud Aglan authored
      Production carries seven matchmaking_queue rows marked `matched` from May and
      June, each pointing at a game that finished months ago. The previous commit
      started honouring `matched` rows so the hand-off survives a re-queue — which,
      without this, would have sent those players straight into a three-month-old
      finished match.
      
      liveClaimedMatch() now verifies the referenced match is real, still playable,
      and actually belongs to the caller before acting on it, and deletes the row on
      the spot when it is not. Self-healing, so the existing rows need no manual
      cleanup. handleStatus also deletes only the row it consumed rather than every
      row for that player, and returns opponent_id alongside the colour.
      
      Covered by e2e section 17: a stale claim is refused and cleared, queueing past
      one works normally, and a genuinely live claim is still delivered with the
      correct opposite colour.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      16a760f7
    • Mahmoud Aglan's avatar
      feat: tournaments progress without an external scheduler · 7bbe3ea6
      Mahmoud Aglan authored
      api/cron.php still exists as the belt-and-braces tick, but an event must not
      depend on someone remembering to wire one up — especially not the one running on
      Saturday. The engine now also advances off ordinary traffic, the same way the
      matchmaking queue already sweeps its own stale rows:
      
      - browsing the tournament list starts any tournament whose start time has passed
      - a player opening their game, or checking their pending games, forfeits an
        opponent who never turned up and closes the round if that was the last board
      
      Both are idempotent and throttled (once every 15-20s per tournament, via APCu or
      a temp file), so they cost one query every few seconds rather than one per
      request. A game that has actually begun is never forfeited by this path — the
      server clock settles those.
      
      Also fixes tournamentEnvInt: `getenv($k) ?: $default` silently discarded a
      configured value of "0", because "0" is falsy in PHP.
      
      tests/run.sh now clears its ports before starting and aborts if a server fails
      to bind. A stale server from an earlier run had been serving the previous
      revision on those ports, so the suite was quietly testing old code — the run
      looked like a product failure and was not one.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      7bbe3ea6
    • Mahmoud Aglan's avatar
      fix: repair chess multiplayer and build the missing tournament engine · 33fd4d40
      Mahmoud Aglan authored
      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>
      33fd4d40
  2. 15 Aug, 2026 1 commit
  3. 10 Jul, 2026 1 commit
  4. 03 Jul, 2026 32 commits