1. 19 Oct, 2026 21 commits
    • Mahmoud Aglan's avatar
      feat(challenges): sticker tiles, chunky gold bars, English titles · 1b5018a6
      Mahmoud Aglan authored
      Each challenge shows its game's sticker (ludo_played, chess_played...) and
      a ready one glows gold until collected. The all-done panel was never shown;
      it now appears once every challenge is claimed. The API's fallback icons
      and the stored templates use sticker names instead of emoji.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      1b5018a6
    • Mahmoud Aglan's avatar
      fix(ludo): two players who search at the same moment are matched · 3df90193
      Mahmoud Aglan authored
      Both missed each other in the queue call and both inserted; status never
      tried to match, so they waited for the 60 s timeout. A waiting player's
      status poll now claims fresh rows queued before its own, after locking its
      own row, so two pollers can never claim each other. ludo_queue stores the
      requested table size for that path.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      3df90193
    • Mahmoud Aglan's avatar
      fix(friends): the host can cancel a challenge it sent · 425202c0
      Mahmoud Aglan authored
      decline-invite only accepted the invited player, so a host backing out of the
      lobby got 403 and the friend could still accept into an empty game for two
      minutes. The host now cancels (result 'cancelled'); the target still declines.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      425202c0
    • Mahmoud Aglan's avatar
      67e39c8d
    • Mahmoud Aglan's avatar
      fix(tools): shoot.mjs deletes the matches and rows its account creates · 5ed5daf6
      Mahmoud Aglan authored
      Bot game scenes create domino/ludo/backgammon match rows and, now that payouts
      work, economy and rating rows; removeAccount left them behind. Twelve orphaned
      domino matches from earlier runs were deleted from production.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      5ed5daf6
    • Mahmoud Aglan's avatar
      feat(game): vibration switch in settings · 00e13552
      Mahmoud Aglan authored
      Haptics (feel.haptic) are off when the player turns vibration off; the
      setting persists with the rest of the player's preferences.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      00e13552
    • Mahmoud Aglan's avatar
      chore(design): detector exceptions for game motion; XP bar animates transform · 8822f88d
      Mahmoud Aglan authored
      bounce-easing is the owner's direction (El3ab is a game: pops and overshoot);
      side-tab flags the 3D bevel under buttons and cards. The XP bar used to
      transition width (layout thrash); it now scales.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      8822f88d
    • Mahmoud Aglan's avatar
    • Mahmoud Aglan's avatar
    • Mahmoud Aglan's avatar
      feat(game): announce new achievements the moment a game ends · 3e2a893c
      Mahmoud Aglan authored
      Achievements were only checked when the achievements screen was opened, so a
      new one was never celebrated. After every game the HUD now refreshes the player
      from the server (the purse counts up, a level-up fires if earned), runs the
      achievement check and drops a gold banner for each new one with its coin
      reward, the reward jingle and a haptic.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      3e2a893c
    • Mahmoud Aglan's avatar
      feat(game): a full-screen level-up celebration · 28883801
      Mahmoud Aglan authored
      When the server raises your level, the table dims and the crown slams in on
      spinning rays with the new level in a gold star, confetti, the level-up jingle
      and a heavy haptic. shoot.mjs --eval runs a snippet before the shot.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      28883801
    • Mahmoud Aglan's avatar
      fix(game): toasts sent over the bus were dropped; friend rows get game buttons · 8cb1f74b
      Mahmoud Aglan authored
      Nothing listened for bus.emit('toast'), so sync errors, login-required notes
      and group messages silently vanished. engine.js now shows them with a new
      ui.toast (parchment note with a sticker and a sound). Friend rows: Challenge is
      a green game button, chat a sticker button. shoot.mjs --with-friend makes a
      befriended second throwaway account.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      8cb1f74b
    • Mahmoud Aglan's avatar
      feat(game): a real loading screen; tournament tiebreak names · 6abd77c5
      Mahmoud Aglan authored
      - The splash is a game loading screen: the crown dealt onto spinning light
        rays, the wordmark, a chunky gold loading bar and a rotating tip.
      - index.php shows the crown and a loading bar before any script runs, so a slow
        phone never sees a blank page.
      - Tiebreak names (Buchholz, Sonneborn-Berger, ...) were raw keys on the
        tournament page.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      6abd77c5
    • Mahmoud Aglan's avatar
      fix(online): matchmaking for ludo, domino and backgammon actually matches · 1b6fe1dc
      Mahmoud Aglan authored
      Found by the gameplay pass, verified against production with throwaway
      accounts (31/31 checks):
      
      - ludo: the queue filtered and ordered on created_at, a column ludo_queue does
        not have, so no two players were ever matched; five stale rows from July to
        September were also deleted.
      - domino and ludo status/get decoded jsonb columns that already arrive as
        arrays: a PHP 8 TypeError, so the waiting player never learned of the match
        and every live poll failed.
      - all three: the zero-UUID claim placeholder was reported as a found match and
        its row deleted; failed inserts were reported as success; domino hands were
        renumbered by array_merge; complete passed p_winners as a string (the RPC
        refused it) and could pay twice. Complete now pays once, to seated players.
      - engine.js loads each game module independently, so one module failing to
        load can no longer stop the whole app from booting.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      1b6fe1dc
    • Mahmoud Aglan's avatar
      feat(game): rewards, shop, profile and settings feel like a game; coin payouts fixed · 6746a37a
      Mahmoud Aglan authored
      - Daily reward is a moment: a treasure chest on spinning light rays that
        wiggles when ready, a gold "open" button, the chest bursts with the reward
        jingle and coins fly to the purse; the week is a grid of reward cards with
        the chest on day seven.
      - Shop: loot cards framed and ribboned by rarity (grey, green, blue, purple,
        gold), drawn previews per item type (mini boards in their colours, piece
        sets, framed avatars, effects), coin and gem price tags; Arabic item names
        (api/shop.php now sends name_ar).
      - Achievements as sticker badges, grey with a lock until earned, gold when
        done; chunky gold progress bars. Leaderboard podium medals with ribbons.
        Settings: sticker tiles, a green game switch, the delete button is red, and
        a credits sheet for the CC BY artists. Profile: framed avatar, crown level,
        sticker stats and game marks; the Challenge button pushed a scene that does
        not exist and did nothing.
      - Signed out there is no HUD or tab bar; login sits in a framed tray with a
        big gold "play as guest" button.
      - HUD/tab crown uses the optimised crown.webp; wood and crown are preloaded.
      - Server: no game had paid coins or XP since 2026-06-18 because award_coins
        wrote a transaction type the table's check constraint rejects; fixed in the
        database (migrations/2026-10-19-award-coins-type.sql). Online backgammon had
        no tables at all; created (migrations/2026-10-19-backgammon-online.sql), and
        the backgammon friend invite now writes the same shape as matchmaking.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      6746a37a
    • Mahmoud Aglan's avatar
      feat(design): Royal Cafe, El3ab becomes a mobile game · bfb16a6e
      Mahmoud Aglan authored
      The owner's direction: El3ab is a game, not an app; the indigo pass read as a
      "big blue blob". The world moves onto a warm walnut cafe table under a lamp.
      
      - New game UI kit (tokens.css, core.css): leather trays for lists, ivory card
        stock in gold frames, chunky glossy 3D buttons (green play, blue, gold, red,
        purple, wood) that push down, ribbon screen titles, round back buttons, gold
        tab chips. Lalezar for display type, Baloo Bhaijaan 2 for reading.
      - Game HUD: avatar frame with level badge and XP bar (500 XP a level, as the
        server computes it), coin and gem purses with green plus buttons (reward,
        shop), wooden bell. Tab bar: sticker icons that lift when active, a big round
        play button with the crown standing out of the bar.
      - 60 glossy sticker icons baked from game-icons.net (CC BY 3.0, credited in
        public/assets/game/CREDITS.md) by tools/build-gicons.mjs; gicon()/crown()
        in icons.js; legacy emoji keys and icon slots now render stickers.
      - Illustrated SVG scene per game (core/game-art.js), used on the home hand and
        each game room.
      - Home is a game lobby: side buttons (reward chest that wiggles when ready,
        achievements, leaderboard, challenge) around the crown, the hand of game
        cards, a huge green Play online button, a live-tournament banner.
      - Rooms use shared game-mode buttons and a framed art hero (ui.js).
      - Sound: recorded card-table sounds (Kenney, CC0) through Web Audio with
        variants and pitch drift; synth fallback. Global press feel (feel.js),
        card-move scene transitions, counting purse, ringing bell.
      - Fix: the shop module was never loaded, so the shop could not open.
      - Restyled screens from the parallel pass (play flow, chess and puzzles, rank
        and tournaments, social, profile, shop, rewards, auth) and ~200 recovered or
        missing Arabic/English strings; t() now warns once per missing key.
      
      Ludo, backgammon and domino game screens and the shared in-game panels ship
      separately, after their gameplay pass.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      bfb16a6e
    • Mahmoud Aglan's avatar
      feat(design): play flow and game rooms in The Cafe Deck · c284b158
      Mahmoud Aglan authored
      - Chess bots are court cards: the bot's initial printed large in red or black
        ink by strength, name plate, a one-line personality, the rating band as the
        corner index; the strongest carries a crown.
      - Time controls are printed cards grouped by bullet, blitz, rapid, classical.
      - Matchmaking shows a face-down deck riffling while the clock counts.
      - Challenge-a-friend rows, a card-stock options sheet; the pre-game lobby shows
        you and your opponent as two player cards, the opponent face down until they
        accept. The lobby's clock chip now shows the real control; every classical
        control used to read 30 minutes.
      - Ludo, domino and backgammon rooms: chip groups printed on card stock when
        chosen, ludo seats in each player's ink, one red start button. Domino bot
        setup is one screen (level and target, then start) instead of two steps.
      - PRODUCT.md: El3ab is a game, not an app; binding for every screen.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      c284b158
    • Mahmoud Aglan's avatar
    • Mahmoud Aglan's avatar
    • Mahmoud Aglan's avatar
      feat(admin): branding edits The Cafe Deck tokens; theme saves need the admin · 034fc175
      Mahmoud Aglan authored
      - The colour section now edits the deck tokens (card back, card stock and
        inks, foil and signals, text) with tokens.css values as defaults; the top
        and tab bar section only lists values theme.js still applies.
      - Retired sections (home tiles, quick actions, game menu sheet, menu buttons,
        chips, bot cards, game gradients, pill radii) are gone; saving them did
        nothing after the redesign, and their old defaults could have overridden it.
      - Emoji replacement slots become icon overrides (icon_<name>); slots that are
        now drawn icons are dropped from the asset list. Board defaults match the
        printed boards.
      - POST /api/branding.php required no login at all: anyone could rewrite the
        theme for every player. It now requires the branding admin session.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      034fc175
    • Mahmoud Aglan's avatar
      feat(design): shared screen parts, emoji-free strings, printed chess board · 6b2507b8
      Mahmoud Aglan authored
      - core/ui.js: screenHead/wireBack, emptyState, loadingRows, errorState, avatar,
        esc, so every redesigned screen is built from the same furniture.
      - i18n: 44 strings carried emoji or check-mark glyphs; they now read as words.
      - Chess board dark squares move to a mid-tone printed indigo so both piece
        colours read on them.
      - tools/shoot.mjs can push any scene with params.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      6b2507b8
  2. 18 Oct, 2026 1 commit
    • Mahmoud Aglan's avatar
      feat(design): The Cafe Deck, foundation and home · 7119c142
      Mahmoud Aglan authored
      El3ab is redesigned as the deck on a cafe table: an indigo card-back ground
      printed with a fine lattice, white card-stock faces in black and red ink, gold
      foil kept for the crown, coins and first place. One face, Alexandria, for every
      role, with tabular figures so clocks and ratings never jitter.
      
      - tokens.css and core.css rewritten. The old token names still resolve, into
        the new palette, so every screen picks up the world before its own pass.
      - New icon family (public/js/core/icons.js): Lucide for utility icons plus
        El3ab's own crown, coin, gem, suits and four game marks. emoji() and the old
        PNG icon slots now render these, so the 300 emoji call sites stop printing
        emoji. Admins can still override any icon via an "icon_<name>" asset.
      - theme.js applies only the new keys; the retired palette's stored values can
        no longer repaint the app. getColor() falls back to the matching token.
      - HUD: avatar, name and level, coin and gem purses, bell. Tab bar marks the
        active world with a red diamond pip.
      - Home is now a fanned hand of the four games with your rating in each card's
        corner; the chosen card is pulled up and its lobby sits below it.
      - tools/shoot.mjs screenshots scenes as a throwaway signed-in player.
      - docs/branding-backup-2026-10-19.json: the stored theme before this change.
      Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
      7119c142
  3. 01 Sep, 2026 16 commits
    • Mahmoud Aglan's avatar
      fix(tournaments): a double no-show is no longer a free point for Black · aa75a0c8
      Mahmoud Aglan authored
      When neither player opened their board, the sweep called
      tournamentForfeitPairing with the white player's id as the absentee. That
      helper awards the point to whichever seat is NOT the one named, so every game
      nobody turned up for was recorded as black_wins — a full point, in a rated
      event, for a player who also did not appear. It fired on all four boards of a
      round during testing.
      
      An unnamed absentee now records the pairing as 'aborted', which
      chessResultPoints already scores 0-0, and the standings count it as a loss for
      both rather than falling through to the draw branch.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      aa75a0c8
    • Mahmoud Aglan's avatar
      fix(tournaments): let the manager drive the pairing engine · 62db8358
      Mahmoud Aglan authored
      api/cron.php now accepts the Supabase service key as an alternative
      credential, so the management app can run the engine without a second
      shared secret having to be provisioned on two CapRover apps first, and
      takes an optional tournament_id so an organiser pressing Start gets the
      answer now instead of at the next sweep.
      
      The sweep also stops skipping tournaments with no round. Flipping a
      tournament to in_progress without pairing round one left it registered,
      running and permanently unplayable; the sweep now opens the round.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      62db8358
    • Mahmoud Aglan's avatar
      fix: a tournament started with no round no longer deadlocks · 929263b6
      Mahmoud Aglan authored
      The manager's Start button flips el3ab_tournaments.status to in_progress
      directly. For a tournament this engine owns, nothing else was ever going to pair
      round 1 — so the tournament sat registered, started, and permanently unplayable,
      with current_round 0 and no round row.
      
      tournamentTick now opens the round when it finds that state, which also repairs
      any tournament already stuck in it. Covered by e2e section 21.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      929263b6
    • Mahmoud Aglan's avatar
      fix: ui-audit tears down its guest accounts even when the browser crashes · 76109e6f
      Mahmoud Aglan authored
      The audit signs in as a guest per device, which creates a real account. A single
      long-lived browser was crashing partway through the third device, and the crash
      took the teardown with it — leaving throwaway accounts in production.
      
      Now: one browser per device, and teardown in a finally block so it runs whichever
      way the run ends.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      76109e6f
    • Mahmoud Aglan's avatar
      fix: the chess board overflowed any padded container · 13cbc287
      Mahmoud Aglan authored
      ChessBoard sized itself from container.clientWidth, which INCLUDES padding, so
      inside a padded container the board came out exactly the padding too wide. On the
      puzzle screen that pushed the whole page 4px sideways and hung the board off the
      edge. Sizing now measures the container's content box.
      
      resize() had a second bug: it set canvas.width/height (the backing store) but
      never the CSS width/height, so after any resize the element laid out at its
      attribute width — devicePixelRatio times too large. It also now resets the
      transform before scaling rather than relying on the implicit reset.
      
      Both paths share one availableSize(), so they can no longer disagree.
      
      Local audit is clean across all scenes at 360/390/430px.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      13cbc287
    • 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
  4. 15 Aug, 2026 1 commit
  5. 10 Jul, 2026 1 commit