1. 01 Sep, 2026 11 commits
    • Mahmoud Aglan's avatar
      fix: UI sizing and spacing that did not fit real phones · 9030409a
      Mahmoud Aglan authored
      An audit of the live app at 360/390/430px found 355 measurable layout defects.
      tools/ui-audit.mjs drives the real app at those three widths and reports what is
      measurable rather than a matter of taste: horizontal overflow, tap targets below
      44px, text below a legible size, and content clipped by its own container.
      
        before  355  (tiny-text 216, tap-target 133, clipped 6)
        after     0
      
      The root cause of the size problems was not the stylesheet. /api/branding.php
      writes 181 stored values inline onto :root at runtime, overriding every token in
      tokens.css — including quick_btn_label_font 10 and hud_badge_font 9. Editing
      tokens.css alone changed nothing. The floors are now enforced in theme.js where
      the override happens, so no stored value (or future admin edit) can reintroduce
      illegible text or an unreachable control:
      
      - any *_font token is floored at 11px
      - tap-target tokens are floored at 44px
      - home_max_width, stored as 340px for a 360px phone, is treated as a floor and
        allowed to grow (min(100%, max(340px, 92vw))) instead of pinning every phone
        to the smallest one's layout
      
      Alongside that: a fluid type scale (--fs-xs … --fs-xl) and --tap-min in
      tokens.css, a tap-target floor on every control in core.css, and 53 sub-11px
      inline font sizes and 41 undersized inline button heights raised at the source.
      
      Layout fixes visible on screen:
      - the daily-gift button carried breathe-glow on the whole flex column, drawing a
        large dark rectangle across the quick-actions row. The glow belongs on the icon.
      - play home dumped its spare height as dead space above the tab bar; it now
        centres via auto margins, which does not clip when content overflows the way
        justify-content:center does.
      - the chess board left a stray "1." floating in a gap above the controls. The
        opening/material/move-list block is now hidden until there is something in it.
      
      Also adds tools/dev-server.php, which serves the app locally against the real
      Supabase so this work does not need a deploy per iteration.
      
      Multiplayer regression: tests/run.sh 5/5, and the production rehearsal passes.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      9030409a
    • Mahmoud Aglan's avatar
      feat: one reactions system for every game, on its own channel · 09afc0f8
      Mahmoud Aglan authored
      Reactions did not sync, and when something appeared it floated in the middle of
      the screen with no sign of who sent it. Four games had four implementations,
      three broken in a different way:
      
      - chess kept ONE shared `emote` slot in the match's game_state, so two players
        reacting in the same second overwrote each other, and it carried no sender;
      - ludo posted reactions through the MOVE endpoint, which replaces game_state
        wholesale — sending an emote wiped turn_count and broke the sync loop. Its
        receive side registered with mp.onEmoteReceived, which only stores a callback
        that nothing ever invokes, so ludo players never saw a reaction at all;
      - domino had a third variant, again a single shared slot;
      - backgammon wrote a `last_emote` column no client ever read, so the opponent
        saw nothing. (That table does not exist in the database either — see below.)
      
      Reactions are chat, not game state. They now live in `chat_messages` on a
      `match` channel via the new api/match-chat.php, so nothing about a reaction can
      reach a board, a clock or a turn — a social feature must not be able to corrupt
      a game in progress. The multiplayer sync path is untouched.
      
      core/reactions.js is the single client implementation. Its central idea is
      SEATS: a game registers which element belongs to which player, and every bubble
      is then drawn attached to that player, carrying their name and avatar. That is
      the actual fix for "you can't tell who is talking". Seat resolution falls back
      to "the seat that isn't mine" in a two-player game, so a game can register its
      opponent seat before it knows the opponent's id.
      
      Only preset emotes and preset phrases are accepted, validated server-side —
      free text between strangers in a live match is a moderation surface nobody is
      staffing. The cooldown is enforced on the server, not just in the UI.
      
      Also fixed, found on the way:
      - ChessBoard has draw(), not render(). board.js called render() whenever a
        themed piece image finished loading, so it threw every time and the board
        never repainted with custom piece art.
      - chess/scenes/game.js built an inline onerror="" whose body embedded the result
        of emoji() — which returns <img src="..."> when a themed asset exists. Its
        double quotes closed the attribute, breaking the markup and throwing a
        SyntaxError on every bot game; the stray '"> rendered as text inside the
        avatar. The fallback is wired in JS now.
      
      Adds tools/ui-audit.mjs (drives the real app at three phone widths and reports
      measurable layout defects) and tools/test-reactions.mjs (end-to-end reaction
      check against a live deployment, with teardown).
      
      Noted, not fixed here: backgammon_matches and backgammon_queue do not exist in
      the database, so backgammon multiplayer has never worked. Out of scope for this
      change and untouched.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      09afc0f8
    • Mahmoud Aglan's avatar
      docs: handover reflects the deployed and rehearsed state · 28e6b267
      Mahmoud Aglan authored
      Deployed to el3ab-player and verified end to end against production with
      tests/rehearsal.mjs, which now also covers spectating and the "watch this player"
      lookup. Production is left byte-for-byte as it was found: row counts across
      matches, tournaments, profiles, rating_history and the queue are unchanged.
      
      Remaining for a human: play one game on a real device (the interface changes are
      the part I could not verify), and optionally set CRON_SECRET and
      SUPABASE_JWT_SECRET, which need CapRover app configuration the push webhook does
      not grant.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      28e6b267
    • Mahmoud Aglan's avatar
      fix: restore spectating, and make "watch this player" watch the right player · 2710cab1
      Mahmoud Aglan authored
      Two problems, one of them mine.
      
      Mine: locking api/game.php's `get` to the two participants broke spectating
      outright. Watching a live board is a normal part of a chess event — the profile
      "watch" button and the tournament live board both rely on it — and on
      championship day it is what most people will be doing. Non-participants now get a
      spectator projection: position, clocks, move list, players and result, but no
      `my_color` and none of the private game_state (draw offers, heartbeats).
      
      Pre-existing: handleFindActiveMatch ignored the player_id it was given and always
      looked up the *caller's* match, so clicking "watch" on someone else silently
      opened your own game. It now honours the requested player. The board itself still
      goes through `get`, which decides what a spectator may see.
      
      Both lookups are also bounded to the last 12 hours. Production carries 27 matches
      stuck in a non-final state since May and July; without that bound, "watch" would
      open a game that ended three months ago.
      
      Test-harness hardening in the same commit: pgshim silently ignored any PostgREST
      filter it did not implement, so a query with `gte.` matched every row and the test
      passed while production filtered correctly. It now implements gte/lte and throws
      on anything unrecognised, rather than quietly matching everything.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      2710cab1
    • Mahmoud Aglan's avatar
      fix: creating a tournament without a start time failed against production · fcc74b9f
      Mahmoud Aglan authored
      el3ab_tournaments.starts_at is NOT NULL in the live schema. tournament-admin.php
      allowed it to be null, so every create call that did not supply one was rejected
      by Postgres. The local test schema had the column nullable, so the suite passed
      and the defect only appeared when the rehearsal ran against production.
      
      starts_at now defaults to now() — which also gives auto_start something to act on —
      and tests/schema.sql marks the column NOT NULL so the next one is caught locally.
      
      Adds tests/rehearsal.mjs, the script that found it: four throwaway accounts driven
      through matchmaking, a 33-move game to checkmate and a full Swiss round against the
      live deployment, with teardown in a finally block. Run it after any deploy.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      fcc74b9f
    • 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 27 commits