Commit 0b076f00 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: player feedback round across all four games, plus sessions that survive bad lines

Domino
- Public tables for 2, 3 and 4 seats, including the four-player free-for-all
  (domino_table_join/leave, domino_merge_state); bot matches pay real coins.
- The Egyptian lock rule: a blocked game is scored, the opponent no longer
  draws the boneyard.

Backgammon (tawla)
- 31: the first runner must reach the home board before the rest may move.
- New board colours so the white checkers read clearly (--bg-* tokens,
  DESIGN.md, branding admin).

Matchmaking
- After 60 s with no opponent, a procedurally generated player-like bot takes
  the seat (ghost_profiles); unrated, paid once, 90 s anti-farm floor.

Chess and ludo
- Premoves merged; draw on flag when the side left cannot mate; bot payout
  guard; ludo seat guard and rules fixes.

Social and sessions
- Chat: the sender on the right, the other person on the left; a thread whose
  first load failed keeps retrying instead of going silent; creating a group
  survives a dropped answer without making a second group.
- Group chat messages readable by members only (RLS).
- A slow auth service no longer signs players out: the server answers 503
  instead of a cached 401, refresh only ends a session the server refused,
  and realtime channels receive the refreshed token.
- complete_match raises levels with XP.

Docs: the manager/game integration audit and the org invites + events plan.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent d951ad46
......@@ -73,8 +73,8 @@ colors:
board-highlight-dark: "#D3C060"
board-check: "#FF4D5E"
board-frame: "#1D1A25"
bg-board-light: "#22A57E"
bg-board-dark: "#1B9070"
bg-board-light: "#177F68"
bg-board-dark: "#157862"
bg-point-dark: "#FF4458"
bg-point-light: "#F4F2F8"
bg-checker-white: "#FFFFFF"
......@@ -347,7 +347,7 @@ Each game owns exactly one hue, used for its card art ground, its mode buttons a
### Board palettes
Boards are flat colour. Pieces and tiles are flat colour standing on a darker lip of their own shape.
- **Chess:** lavender slate squares (`board-light`, `board-dark`), soft yellow move highlights (`board-highlight-light`, `board-highlight-dark`), `board-check` red for check, ink frame (`board-frame`).
- **Backgammon:** a teal field (`bg-board-light`, `bg-board-dark`) in an ink frame and bar (`bg-board-border`, `bg-bar-*`, `bg-bearoff`). Points alternate red and pale (`bg-point-dark`, `bg-point-light`). Checkers are white and ink (`bg-checker-white`, `bg-checker-black`). `bg-highlight` win green marks legal targets and `bg-selected` gold marks the picked checker.
- **Backgammon:** a deep teal field (`bg-board-light` #177F68, `bg-board-dark` #157862) in an ink frame and bar (`bg-board-border`, `bg-bar-*`), a slate bear-off tray (`bg-bearoff` #6B6585). Points alternate red #F03E52 and slate #8A84A3 (`bg-point-dark`, `bg-point-light`): never near-white, because white checkers must read on every surface (each checker colour is at least 3.2:1 against the field, both points, the bar and the tray; white checkers stand on a slate lip). Checkers are white and ink (`bg-checker-white`, `bg-checker-black`). `bg-highlight` win green marks legal targets and `bg-selected` gold marks the picked checker.
- **Ludo:** four bright flat quadrants on white (`ludo-red`, `ludo-green`, `ludo-yellow`, `ludo-blue`) with `card-line` track lines and gold for the active turn.
- **Domino:** white tiles (`card`) with `card-edge` lips and `ink` pips, on an ink table or a table in the domino hue (`domino-primary` / `domino-secondary`).
- Canvas code reads every board colour through `getColor('token_name', fallback)` so the admin overrides reach the boards too.
......
......@@ -327,15 +327,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
<?php
$bgBoardColors = [
['key' => 'bg_board_border', 'label' => 'Board Border', 'default' => '#14121A', 'hint' => 'Outer frame'],
['key' => 'bg_board_light', 'label' => 'Board Light', 'default' => '#22A57E', 'hint' => 'Printed board field'],
['key' => 'bg_board_dark', 'label' => 'Board Dark', 'default' => '#1B9070', 'hint' => 'Printed board field, shade'],
['key' => 'bg_board_light', 'label' => 'Board Light', 'default' => '#177F68', 'hint' => 'Printed board field'],
['key' => 'bg_board_dark', 'label' => 'Board Dark', 'default' => '#157862', 'hint' => 'Printed board field, shade'],
['key' => 'bg_bar_dark', 'label' => 'Bar Dark', 'default' => '#14121A', 'hint' => 'Center bar dark'],
['key' => 'bg_bar_light', 'label' => 'Bar Light', 'default' => '#1D1A25', 'hint' => 'Center bar light'],
['key' => 'bg_bearoff', 'label' => 'Bear-off Area', 'default' => '#1D1A25', 'hint' => 'Bear-off tray'],
['key' => 'bg_point_dark', 'label' => 'Dark Point', 'default' => '#FF4458', 'hint' => 'Dark triangle base'],
['key' => 'bg_point_dark_tip', 'label' => 'Dark Point Tip', 'default' => '#FF4458', 'hint' => 'Dark triangle tip'],
['key' => 'bg_point_light', 'label' => 'Light Point', 'default' => '#F4F2F8', 'hint' => 'Light triangle base'],
['key' => 'bg_point_light_tip', 'label' => 'Light Point Tip', 'default' => '#F4F2F8', 'hint' => 'Light triangle tip'],
['key' => 'bg_bearoff', 'label' => 'Bear-off Area', 'default' => '#6B6585', 'hint' => 'Bear-off tray'],
['key' => 'bg_point_dark', 'label' => 'Dark Point', 'default' => '#F03E52', 'hint' => 'Dark triangle base'],
['key' => 'bg_point_dark_tip', 'label' => 'Dark Point Tip', 'default' => '#F03E52', 'hint' => 'Dark triangle tip'],
['key' => 'bg_point_light', 'label' => 'Light Point', 'default' => '#8A84A3', 'hint' => 'Light triangle base'],
['key' => 'bg_point_light_tip', 'label' => 'Light Point Tip', 'default' => '#8A84A3', 'hint' => 'Light triangle tip'],
['key' => 'bg_checker_white', 'label' => 'White Checker', 'default' => '#FFFFFF', 'hint' => 'White player pieces'],
['key' => 'bg_checker_black', 'label' => 'Black Checker', 'default' => '#1A1722', 'hint' => 'Black player pieces'],
['key' => 'bg_highlight', 'label' => 'Move Highlight', 'default' => '#2ED47A', 'hint' => 'Valid move indicator'],
......
......@@ -196,14 +196,19 @@ function handleRefresh(array $input): void {
'refresh_token' => $refreshToken
]);
if (isset($result['error'])) {
jsonError($result['error'], $result['code'] ?? 401);
// A rejected refresh (expired, revoked, or Auth briefly down) can come back
// without tokens and without an "error" key: answer 401, never null tokens.
if (isset($result['error']) || empty($result['access_token']) || empty($result['refresh_token'])) {
$code = (int)($result['code'] ?? 401);
// An outage keeps the session (the client tries again); only a refusal ends it.
if ($code >= 500) jsonResponse(['error' => 'Auth service unavailable', 'code' => 'auth_unavailable'], 503);
jsonError($result['error'] ?? 'Session expired', $code ?: 401);
}
jsonResponse([
'access_token' => $result['access_token'],
'refresh_token' => $result['refresh_token'],
'user' => $result['user']
'user' => $result['user'] ?? null
]);
}
......
......@@ -9,6 +9,7 @@ require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/hand-off.php';
require_once __DIR__ . '/../includes/match-presence.php';
require_once __DIR__ . '/../includes/ghost.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -26,7 +27,7 @@ switch ($action) {
case 'start': handleStart($userId, $input); break;
case 'queue': handleQueue($userId, $input); break;
case 'dequeue': handleBgDequeue($userId); break;
case 'status': handleStatus($userId); break;
case 'status': handleStatus($userId, $input); break;
case 'move': handleMove($userId, $input); break;
case 'get': handleGet($userId, $input); break;
case 'complete': handleComplete($userId, $input); break;
......@@ -73,6 +74,7 @@ function bgJson($value, array $default = []): array {
function handleBgDequeue(string $userId): void {
$sdb = supabaseService();
ghostDropStaleClaims($sdb, 'backgammon_queue', 'backgammon_matches', $userId);
// A row an opponent has already claimed must survive: deleting it strands
// them alone in a match we never hear about. Report the claim instead.
......@@ -95,6 +97,7 @@ function handleQueue(string $userId, array $input): void {
$sdb = supabaseService();
$variant = $input['variant'] ?? 'sheshbesh';
$matchLength = intval($input['match_length'] ?? 3);
ghostDropStaleClaims($sdb, 'backgammon_queue', 'backgammon_matches', $userId);
// STEP 0: If an opponent has already claimed us, hand that match back
// instead of re-queueing (deleting the row would strand them).
......@@ -219,8 +222,9 @@ function bgEnqueue($sdb, string $userId, string $variant, int $matchLength): voi
jsonResponse(['queued' => true]);
}
function handleStatus(string $userId): void {
function handleStatus(string $userId, array $input = []): void {
$sdb = supabaseService();
ghostDropStaleClaims($sdb, 'backgammon_queue', 'backgammon_matches', $userId);
$entry = $sdb->get('backgammon_queue', [
'user_id' => 'eq.' . $userId,
......@@ -234,9 +238,8 @@ function handleStatus(string $userId): void {
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])
&& $entry[0]['match_id'] !== BG_CLAIM_PLACEHOLDER) {
$matchId = $entry[0]['match_id'];
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
$matches = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,colors', 'limit' => 1]);
$matches = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,colors,game_state,variant,match_length', 'limit' => 1]);
$players = [];
$colors = [];
if (!empty($matches) && !isset($matches['error'])) {
......@@ -244,6 +247,27 @@ function handleStatus(string $userId): void {
$colors = bgJson($matches[0]['colors'] ?? null);
}
$playerIndex = array_search($userId, $players, true);
// A bot-fill match (includes/ghost.php): played on this device.
$gs = !empty($matches) && !isset($matches['error']) ? bgJson($matches[0]['game_state'] ?? null) : [];
if (($gs['mode'] ?? '') === 'ghost') {
$cards = handOffCards($sdb, $players);
$ghostId = $gs['ghost_id'] ?? '';
jsonResponse([
'match_id' => $matchId,
'mode' => 'ghost',
'player_index' => $playerIndex !== false ? $playerIndex : 0,
'players' => $players,
'color' => 'white',
'opponent_id' => $ghostId,
'variant' => $matches[0]['variant'] ?? 'sheshbesh',
'match_length' => intval($matches[0]['match_length'] ?? 1),
'cards' => $cards,
'ghost' => ghostHandOff(isset($cards[$ghostId]) ? [$cards[$ghostId]] : [], $gs['bot_level'] ?? 'medium'),
]);
}
// Handed over: the row has done its job (a bot-fill row stays a
// moment, GHOST_CLAIM_TTL, so a lost answer is handed over again).
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
jsonResponse([
'match_id' => $matchId,
......@@ -261,11 +285,70 @@ function handleStatus(string $userId): void {
if (!empty($entry) && !isset($entry['error']) && empty($entry[0]['match_id'])) {
$found = bgMatchFromStatus($sdb, $userId, $entry[0]['id']);
if ($found) jsonResponse($found);
// Bot fill: a minute and nobody (includes/ghost.php).
if (!empty($input['ghost'])) {
$ghost = bgGhostMatch($sdb, $userId, $entry[0]['id'], $input);
if ($ghost) jsonResponse($ghost);
}
}
jsonResponse(['waiting' => true]);
}
/**
* Bot fill: hold our row, make the opponent, and open a bot game against it
* (players [me, 'bot_gh..'], game_state.mode 'ghost'), played on our device.
* handleComplete already pays a match with a 'bot_' seat by the bot rules.
* Returns the hand-off, or null.
*/
function bgGhostMatch($sdb, string $userId, string $myRowId, array $input): ?array {
$mine = $sdb->get('backgammon_queue', ['id' => 'eq.' . $myRowId, 'select' => 'id,variant,match_length,created_at', 'limit' => 1]);
if (!is_array($mine) || isset($mine['error']) || empty($mine)) return null;
$mine = $mine[0];
if (!ghostDue($input, $mine['created_at'] ?? null)) return null;
$lock = $sdb->update('backgammon_queue', ['match_id' => BG_CLAIM_PLACEHOLDER], ['id' => 'eq.' . $mine['id'], 'match_id' => 'is.null']);
if (empty($lock) || isset($lock['error'])) return null; // a person claimed us just now
$release = fn() => $sdb->update('backgammon_queue', ['match_id' => null], ['id' => 'eq.' . $mine['id'], 'match_id' => 'eq.' . BG_CLAIM_PLACEHOLDER]);
$card = ghostCreate($sdb, $userId, 'backgammon');
if (!$card) { $release(); return null; }
$level = in_array($card['strength'], ['easy', 'medium', 'hard'], true) ? $card['strength'] : 'medium';
$variant = in_array($mine['variant'] ?? '', ['sheshbesh', 'mahbousa', 'thirtyone'], true) ? $mine['variant'] : 'sheshbesh';
$length = max(1, min(31, intval($mine['match_length'] ?? 1)));
$players = [$userId, $card['id']];
$match = $sdb->insert('backgammon_matches', [
'status' => 'in_progress',
'players' => $players,
'colors' => ['white', 'black'],
'variant' => $variant,
'match_length' => $length,
'scores' => [0, 0],
'current_turn' => 0,
'game_state' => ['mode' => 'ghost', 'bot_level' => $level, 'ghost_id' => $card['id']],
'host_id' => $userId,
'created_by' => $userId,
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if (!$matchId) { ghostDiscard($sdb, [$card['id']]); $release(); return null; }
ghostAttach($sdb, [$card['id']], $matchId);
// The row points at the match, as a person's claim does: if this answer
// is lost on the way, the next poll hands the same match over.
$sdb->update('backgammon_queue', ['match_id' => $matchId], ['id' => 'eq.' . $mine['id']]);
return [
'match_id' => $matchId,
'mode' => 'ghost',
'player_index' => 0,
'players' => $players,
'color' => 'white',
'opponent_id' => $card['id'],
'variant' => $variant,
'match_length' => $length,
'cards' => handOffCards($sdb, [$userId]) + [$card['id'] => $card],
'ghost' => ghostHandOff([$card], $level),
];
}
/**
* Seat the oldest fresh player waiting for the same game (variant and length)
* who queued before us. Only towards older rows, so two pollers never claim
......@@ -445,6 +528,8 @@ function handleComplete(string $userId, array $input): void {
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId, 'completed_at' => 'is.null']);
if (isset($claimed['error'])) jsonError('Could not complete the match', 500);
// A bot-fill match's queue row (kept a moment after the hand-off) is done.
$sdb->delete('backgammon_queue', ['match_id' => 'eq.' . $matchId]);
if (empty($claimed)) jsonResponse(['success' => true, 'already_completed' => true, 'rewards' => []]);
if ($tooQuick) jsonResponse(['success' => true, 'too_short' => true, 'rewards' => []]);
// Bot ids map to null: the function skips nulls, and a 'bot_' id fails its uuid cast.
......
This diff is collapsed.
......@@ -135,6 +135,14 @@ if ($method === 'POST') {
$input = getInput();
$action = $input['action'] ?? '';
// A generated opponent (bot fill, includes/ghost.php) is not an account:
// it cannot be befriended, and muting, blocking or reporting it is a no-op.
require_once __DIR__ . '/../includes/ghost.php';
if (isGhostId($input['target_id'] ?? null)) {
if ($action === 'request') jsonError('This player is not taking friend requests', 409);
jsonResponse(['success' => true]);
}
if ($action === 'request') {
$targetId = $input['target_id'] ?? '';
if (!$targetId) jsonError('target_id required');
......
......@@ -10,6 +10,7 @@ require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/match-presence.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';
require_once __DIR__ . '/../includes/ghost.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -50,6 +51,14 @@ switch ($action) {
jsonError('Invalid action');
}
/** Seconds a game against a bot must last before it pays (see finaliseMatch). */
const CHESS_BOT_MIN_PAID_SECONDS = 60;
function chessBotTooShort(?string $startedAt): bool {
$ts = strtotime((string) $startedAt);
return !$ts || $ts > time() - CHESS_BOT_MIN_PAID_SECONDS;
}
function handleGet($db, string $userId, array $input): void {
$matchId = $_GET['match_id'] ?? ($input['match_id'] ?? '');
if (!$matchId) jsonError('match_id required');
......@@ -166,6 +175,16 @@ function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = nu
$metadata['winner'] = $derived['winner']; // 'white' | 'black' | null
if ($actorId) $metadata['ended_by'] = $actorId;
// Bot fill (includes/ghost.php): paid by the bot rules, so a game against
// a generated opponent finished within GHOST_MIN_PAID_SECONDS pays nothing.
$isGhost = ($match['match_type'] ?? '') === 'ghost';
$ghostUnpaid = $isGhost && ghostTooShort($match['started_at'] ?? null);
if ($ghostUnpaid) $metadata['paid'] = 'too_short';
// A game against a bot pays only once it has really been played: a
// start-and-resign loop used to pay the loss reward on every click.
$botUnpaid = ($match['match_type'] ?? '') === 'bot' && chessBotTooShort($match['started_at'] ?? null);
if ($botUnpaid) { $metadata['paid'] = 'too_short'; $GLOBALS['el3ab_bot_unpaid'] = true; }
$update = array_merge([
'status' => 'completed',
'result' => $derived['result'],
......@@ -192,12 +211,20 @@ function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = nu
$winnerId = chessWinnerId($derived['winner'], $match['white_player_id'], $match['black_player_id']);
$winners = $winnerId ? [$winnerId] : [];
supabaseRpc('complete_match', [
'p_game_key' => $match['game_key'] ?? 'chess',
'p_match_id' => $matchId,
'p_winners' => $winners,
'p_reason' => $derived['reason'],
]);
// A bot-fill match's queue row (kept a moment after the hand-off) is done.
if ($isGhost) $sdb->delete('matchmaking_queue', ['match_id' => 'eq.' . $matchId]);
if (!$ghostUnpaid && !$botUnpaid) {
$paid = supabaseRpc('complete_match', [
'p_game_key' => $match['game_key'] ?? 'chess',
'p_match_id' => $matchId,
'p_winners' => $winners,
'p_reason' => $derived['reason'],
]);
if ($isGhost) $GLOBALS['el3ab_ghost_rewards'] = is_array($paid) ? ($paid['rewards'] ?? []) : [];
} elseif ($isGhost) {
$GLOBALS['el3ab_ghost_rewards'] = [];
}
applyRatings($sdb, $match, $derived);
......@@ -585,7 +612,7 @@ function handleComplete($db, string $userId, array $input): void {
? ($fresh['white_rating_after'] ?? null)
: ($fresh['black_rating_after'] ?? null);
jsonResponse([
$out = [
'success' => true,
'result' => $derived['result'],
'reason' => $derived['reason'],
......@@ -593,7 +620,11 @@ function handleComplete($db, string $userId, array $input): void {
'my_color' => $myColor,
'rating_change' => $ratingChange,
'rating_after' => $ratingAfter,
]);
];
// Bot fill: what was paid, so the result card shows the real payout.
if (array_key_exists('el3ab_ghost_rewards', $GLOBALS)) $out['rewards'] = $GLOBALS['el3ab_ghost_rewards'];
if (!empty($GLOBALS['el3ab_bot_unpaid'])) $out['paid'] = false;
jsonResponse($out);
}
/**
......
This diff is collapsed.
......@@ -9,6 +9,7 @@ require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/hand-off.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/ghost.php';
$token = requireAuth();
$userId = getUserId($token);
......@@ -194,6 +195,10 @@ function handleStatus($db, string $userId, array $input): void {
if (!$claim) {
$claim = matchFromStatus($sdb, $userId);
}
if (!$claim) {
// A minute and nobody: a generated opponent (includes/ghost.php).
$claim = ghostChessFromStatus($sdb, $userId, $input);
}
if (!$claim) {
jsonResponse(['waiting' => true]);
}
......@@ -201,6 +206,23 @@ function handleStatus($db, string $userId, array $input): void {
$match = $claim['match'];
$color = ($match['white_player_id'] === $userId) ? 'w' : 'b';
// Bot fill: the seat across is the generated opponent, not a column. Its
// row stays a moment (GHOST_CLAIM_TTL), so a lost answer is handed over
// again by the next poll.
if (($match['match_type'] ?? '') === 'ghost') {
$meta = is_array($match['metadata'] ?? null) ? $match['metadata'] : (json_decode((string) ($match['metadata'] ?? ''), true) ?: []);
$ghostId = $meta['ghost_id'] ?? '';
$cards = handOffCards($sdb, [$userId, $ghostId]);
jsonResponse([
'match_id' => $match['id'],
'mode' => 'ghost',
'color' => $color,
'opponent_id' => $ghostId,
'cards' => $cards,
'ghost' => ghostHandOff(isset($cards[$ghostId]) ? [$cards[$ghostId]] : [], $meta['bot_id'] ?? 'nour'),
]);
}
// The hand-off has been delivered, so the queue row has done its job.
$sdb->delete('matchmaking_queue', ['id' => 'eq.' . $claim['queue_id']]);
......@@ -248,14 +270,16 @@ function liveClaimedMatch($sdb, string $userId): ?array {
} else {
$m = $sdb->get('matches', [
'id' => 'eq.' . $row['match_id'],
'select' => 'id,white_player_id,black_player_id,status',
'select' => 'id,white_player_id,black_player_id,status,match_type,metadata,started_at',
'limit' => 1,
]);
$m = (is_array($m) && !empty($m) && !isset($m['error'])) ? $m[0] : null;
$playable = $m
&& in_array($m['status'], ['waiting', 'ready', 'in_progress'], true)
&& ($m['white_player_id'] === $userId || $m['black_player_id'] === $userId);
&& ($m['white_player_id'] === $userId || $m['black_player_id'] === $userId)
// A bot-fill match is handed over only while it is new.
&& (($m['match_type'] ?? '') !== 'ghost' || ghostClaimLive($m['started_at'] ?? null));
if ($playable && $live === null) {
$live = ['queue_id' => $row['id'], 'match' => $m];
......@@ -271,6 +295,7 @@ function liveClaimedMatch($sdb, string $userId): ?array {
function handleDequeue($db, string $userId, array $input): void {
$sdb = supabaseService();
liveClaimedMatch($sdb, $userId); // clears stale claims (an old bot-fill match among them)
// 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
......@@ -433,3 +458,77 @@ function mpLog(string $matchId, string $gameKey, string $playerId, string $event
'p_payload' => is_array($payload) ? $payload : []
]);
}
/**
* Bot fill: we have waited a minute and nobody came. Hold our own row (so no
* one can seat us while this is built), make the opponent, and open the match
* as a bot game against it: never rated, paid by the bot rules (game.php
* finaliseMatch), marked match_type 'ghost'. Returns the claim, shaped like
* liveClaimedMatch()'s, or null.
*/
function ghostChessFromStatus($sdb, string $userId, array $input): ?array {
$mine = $sdb->get('matchmaking_queue', [
'player_id' => 'eq.' . $userId,
'status' => 'eq.waiting',
'select' => 'id,game_key,time_control,queued_at',
'order' => 'queued_at.desc',
'limit' => 1,
]);
if (!is_array($mine) || isset($mine['error']) || empty($mine)) return null;
$row = $mine[0];
if (($row['game_key'] ?? 'chess') !== 'chess' || !ghostDue($input, $row['queued_at'] ?? null)) return null;
$lock = $sdb->update('matchmaking_queue', ['status' => 'matched'], ['id' => 'eq.' . $row['id'], 'status' => 'eq.waiting']);
if (empty($lock) || isset($lock['error'])) return null; // a person claimed us just now
$tc = $row['time_control'];
$card = ghostCreate($sdb, $userId, 'chess', ['time_control' => $tc]);
if (!$card) { releaseClaim($sdb, $row['id']); return null; }
$botId = $card['strength'];
$isWhite = random_int(0, 1) === 0;
$clock = chessTimeControlMs($tc);
$match = $sdb->insert('matches', [
'game_key' => 'chess',
'match_type' => 'ghost',
'white_player_id' => $isWhite ? $userId : null,
'black_player_id' => $isWhite ? null : $userId,
'status' => 'in_progress',
'time_control' => $tc,
'initial_time_ms' => $clock['initial'],
'increment_ms' => $clock['increment'],
'white_time_remaining_ms' => $clock['initial'],
'black_time_remaining_ms' => $clock['initial'],
'starting_fen' => CHESS_START_FEN,
'current_fen' => CHESS_START_FEN,
'moves' => [],
'move_count' => 0,
'bot_id' => $botId,
'bot_difficulty' => 'ghost',
'is_rated' => false,
'game_state' => ['mode' => 'ghost', 'ghost_id' => $card['id']],
'metadata' => ['mode' => 'ghost', 'bot_id' => $botId, 'ghost_id' => $card['id']],
'started_at' => gmdate('c'),
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if (!$matchId) { ghostDiscard($sdb, [$card['id']]); releaseClaim($sdb, $row['id']); return null; }
ghostAttach($sdb, [$card['id']], $matchId);
// The row now points at the match, as a person's claim does: if this
// answer is lost on the way, the next poll hands the same match over.
$sdb->update('matchmaking_queue', ['match_id' => $matchId], ['id' => 'eq.' . $row['id']]);
mpLog($matchId, 'chess', $userId, 'match_created', ['mode' => 'ghost', 'ghost' => $card['id'], 'bot_id' => $botId, 'time_control' => $tc]);
return [
'queue_id' => $row['id'],
'match' => [
'id' => $matchId,
'white_player_id' => $isWhite ? $userId : null,
'black_player_id' => $isWhite ? null : $userId,
'status' => 'in_progress',
'match_type' => 'ghost',
'metadata' => ['mode' => 'ghost', 'bot_id' => $botId, 'ghost_id' => $card['id']],
],
];
}
......@@ -20,6 +20,18 @@ if ($method === 'GET') {
$db = supabase($token);
$sdb = supabaseService();
// A generated opponent (bot fill, includes/ghost.php): the basic card it
// showed at the table, and nothing a person's profile has (no friendship,
// no history, no add button on the client).
require_once __DIR__ . '/../includes/ghost.php';
if (isGhostId($targetId)) {
$card = ghostCards($sdb, [$targetId])[$targetId] ?? null;
if (!$card) jsonError('Profile not found', 404);
unset($card['strength']);
$card['friendship_status'] = null;
jsonResponse($card);
}
// If viewing another player's profile, check blocks
if ($targetId !== $userId) {
$blocked = $sdb->get('player_blocks', [
......
......@@ -170,6 +170,8 @@ if ($method === 'POST') {
if ($action === 'request') {
$targetId = $input['target_id'] ?? '';
// A generated opponent (bot fill) is not an account.
if (is_string($targetId) && str_starts_with($targetId, 'bot_')) jsonError('This player is not taking friend requests', 409);
if (!isUuid($targetId)) jsonError('target_id required');
if ($targetId === $userId) jsonError('Cannot add yourself');
if (isBlockedEitherWay($sdb, $userId, $targetId)) jsonError('Cannot send request to this player', 403);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -79,6 +79,16 @@ function verifyToken(string $token): ?array {
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 0 || $httpCode >= 500) {
// The auth service did not answer, which says nothing about the token.
// Answering 401 (and caching it) signed players out for 30 s at a time
// whenever GoTrue was slow; a 503 is retried and the session is kept.
http_response_code(503);
header('Content-Type: application/json');
header('Retry-After: 2');
echo json_encode(['error' => 'Auth service unavailable', 'code' => 'auth_unavailable']);
exit;
}
if ($httpCode !== 200) {
// Cache the rejection too, briefly, so a client stuck retrying with a
// dead token cannot hammer the auth service.
......
......@@ -87,6 +87,21 @@ function chessParseFen(?string $fen): ?array {
];
}
/**
* Could `colour` ('w'|'b') still mate with what it has on the board? A lone
* king, or a king with one bishop or one knight, cannot (the rule players
* know from chess.com and lichess: losing on time to a side that cannot mate
* is a draw). Anything else, a single pawn included, can. True when unsure.
*/
function chessCanMate(?string $fen, string $colour): bool {
$p = chessParseFen($fen);
if (!$p) return true;
$c = $p['counts'];
$k = fn(string $piece) => (int)($c[$colour === 'w' ? strtoupper($piece) : $piece] ?? 0);
if ($k('p') + $k('r') + $k('q') > 0) return true;
return $k('b') + $k('n') >= 2;
}
/** 'w' | 'b' | null — whose turn it is in this FEN. */
function chessSideToMove(?string $fen): ?string {
$p = chessParseFen($fen);
......@@ -190,6 +205,8 @@ function chessDeriveResult(string $reason, ?string $finalFen, ?string $actorColo
// whose clock was running (the side to move).
$loser = ($actorColor === 'w' || $actorColor === 'b') ? $actorColor : $sideToMove;
if ($loser === null) return $drawn;
// Out of time against a side that cannot mate: a draw.
if (!chessCanMate($finalFen, $loser === 'w' ? 'b' : 'w')) return $drawn;
return $win($loser === 'w' ? 'b' : 'w');
case 'stalemate':
......
This diff is collapsed.
......@@ -13,13 +13,17 @@
* @return array<string, array> id => card
*/
function handOffCards($sdb, array $ids): array {
// Generated opponents (bot fill, includes/ghost.php) have a card too, so
// the VS screen and the table show them exactly as they show a person.
require_once __DIR__ . '/ghost.php';
$ghosts = ghostCards($sdb, $ids);
$ids = array_values(array_unique(array_filter($ids, fn($x) => is_string($x) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $x))));
if (!$ids) return [];
if (!$ids) return $ghosts;
$rows = $sdb->get('profiles', [
'id' => 'in.(' . implode(',', $ids) . ')',
'select' => 'id,username,display_name,avatar_url,level,elo_rapid,elo_blitz,elo_bullet,elo_classical,elo_domino,total_wins',
]);
if (!is_array($rows) || isset($rows['error'])) return [];
if (!is_array($rows) || isset($rows['error'])) return $ghosts;
$ratings = $sdb->get('player_game_ratings', [
'player_id' => 'in.(' . implode(',', $ids) . ')',
'game_key' => 'in.(ludo,backgammon)',
......@@ -34,5 +38,5 @@ function handOffCards($sdb, array $ids): array {
$r['ratings'] = $byPlayer[$r['id']] ?? [];
$cards[$r['id']] = $r;
}
return $cards;
return $cards + $ghosts;
}
......@@ -59,6 +59,10 @@ function supabaseAuth(string $method, string $endpoint, ?array $body = null, ?st
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// No answer at all is an outage, not a rejection.
if ($response === false || $httpCode === 0) {
return ['error' => 'Auth service unavailable', 'code' => 503];
}
$decoded = json_decode($response, true);
if ($httpCode >= 400) {
return ['error' => $decoded['error_description'] ?? $decoded['msg'] ?? $decoded['message'] ?? 'Auth error', 'code' => $httpCode];
......
-- complete_match: XP was added but the level never followed it (levels are
-- floor(xp/500)+1 in the app). Level now rises with XP and never drops.
CREATE OR REPLACE FUNCTION public.complete_match(p_game_key text, p_match_id uuid, p_winners jsonb, p_reason text DEFAULT 'normal'::text)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
DECLARE
v_player_id uuid;
v_placement int;
v_coins int;
v_xp int;
v_config_key text;
v_total_players int;
v_result json;
v_rewards jsonb := '[]'::jsonb;
BEGIN
v_total_players := jsonb_array_length(p_winners);
-- Process each player in the winners array (ordered by placement)
FOR v_placement IN 0..(v_total_players - 1) LOOP
v_player_id := (p_winners->>v_placement)::uuid;
-- Skip bots (their IDs start with 'bot_')
IF v_player_id IS NULL OR (p_winners->>v_placement) LIKE 'bot_%' THEN
CONTINUE;
END IF;
-- Determine coins based on placement
IF v_placement = 0 THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_win_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_win_xp';
ELSIF v_placement = 1 THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_2nd_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_2nd_xp';
ELSIF v_placement = 2 THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_3rd_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_3rd_xp';
ELSE
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_4th_coins';
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_4th_xp';
IF v_coins IS NULL THEN
SELECT value::int INTO v_coins FROM reward_config WHERE key = p_game_key || '_loss_coins';
END IF;
IF v_xp IS NULL THEN
SELECT value::int INTO v_xp FROM reward_config WHERE key = p_game_key || '_loss_xp';
END IF;
END IF;
-- Fallbacks
v_coins := COALESCE(v_coins, 5);
v_xp := COALESCE(v_xp, 5);
-- Zero rewards for abandons
IF p_reason = 'abandon' AND v_placement = v_total_players - 1 THEN
v_coins := 0; v_xp := 0;
UPDATE profiles SET abandon_count = abandon_count + 1, last_abandon_at = now() WHERE id = v_player_id;
END IF;
-- Award coins
PERFORM award_coins(v_player_id, v_coins, p_game_key || ' ' ||
CASE v_placement WHEN 0 THEN 'win' WHEN 1 THEN '2nd place' WHEN 2 THEN '3rd place' ELSE 'loss' END);
-- Award XP
UPDATE profiles SET xp = xp + v_xp, level = GREATEST(COALESCE(level, 1), floor((COALESCE(xp, 0) + v_xp) / 500)::int + 1) WHERE id = v_player_id;
-- Update global stats
UPDATE profiles SET
total_games_played = total_games_played + 1,
games_played = games_played + 1,
total_wins = total_wins + CASE WHEN v_placement = 0 THEN 1 ELSE 0 END,
total_losses = total_losses + CASE WHEN v_placement > 0 THEN 1 ELSE 0 END,
win_streak = CASE WHEN v_placement = 0 THEN win_streak + 1 ELSE 0 END,
best_win_streak = CASE WHEN v_placement = 0 AND win_streak + 1 > best_win_streak THEN win_streak + 1 ELSE best_win_streak END
WHERE id = v_player_id;
-- Upsert game-specific rating
INSERT INTO player_game_ratings (player_id, game_key, mode, games_played, wins, losses, last_played_at)
VALUES (v_player_id, p_game_key, 'default', 1,
CASE WHEN v_placement = 0 THEN 1 ELSE 0 END,
CASE WHEN v_placement > 0 THEN 1 ELSE 0 END,
now())
ON CONFLICT (player_id, game_key, mode) DO UPDATE SET
games_played = player_game_ratings.games_played + 1,
wins = player_game_ratings.wins + CASE WHEN v_placement = 0 THEN 1 ELSE 0 END,
losses = player_game_ratings.losses + CASE WHEN v_placement > 0 THEN 1 ELSE 0 END,
win_streak = CASE WHEN v_placement = 0 THEN player_game_ratings.win_streak + 1 ELSE 0 END,
best_win_streak = CASE WHEN v_placement = 0 AND player_game_ratings.win_streak + 1 > player_game_ratings.best_win_streak
THEN player_game_ratings.win_streak + 1 ELSE player_game_ratings.best_win_streak END,
last_played_at = now(),
updated_at = now();
v_rewards := v_rewards || jsonb_build_object('player_id', v_player_id, 'placement', v_placement + 1, 'coins', v_coins, 'xp', v_xp);
END LOOP;
RETURN json_build_object('success', true, 'rewards', v_rewards, 'game_key', p_game_key, 'match_id', p_match_id);
END;
$function$;
-- Domino tables of three and four (مرابعة فردي), and what every domino place pays.
--
-- A two-player domino match is made by the queue or a friend's challenge. A
-- table of three or four fills seat by seat: its host opens it (public, for
-- anyone looking for a table of that size, or private, joined by its code or
-- a friend's invite), people sit down, and it starts the moment the last seat
-- is taken (or when the host fills the empty seats with computer players).
--
-- Sitting down and standing up change the `players` array of one row while
-- others may be doing the same, so they are functions that lock the row: two
-- players can never take the same seat, and a full table can never take a
-- fifth. Seat order is the order people sat down in, which is the turn order.
--
-- Hands are revealed for scoring by every seat at once when a round ends; a
-- read-modify-write of game_state would let one reveal overwrite another, so
-- they merge in place with domino_merge_state().
-- Returns {"ok": bool} (an object: the API's RPC helper reads JSON objects).
DROP FUNCTION IF EXISTS public.domino_merge_state(uuid, jsonb);
CREATE OR REPLACE FUNCTION public.domino_merge_state(p_match uuid, p_patch jsonb)
RETURNS jsonb
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
WITH u AS (
UPDATE domino_matches
SET game_state = COALESCE(game_state, '{}'::jsonb) || p_patch,
last_activity = now(),
updated_at = now()
WHERE id = p_match AND status = 'in_progress'
RETURNING 1
)
SELECT jsonb_build_object('ok', EXISTS (SELECT 1 FROM u));
$$;
-- Take the next seat at a waiting table. Returns the row as it is now, with
-- `seat` (this player's index) and `joined` (false when the table was full,
-- gone or already started without them). The last seat taken starts it.
CREATE OR REPLACE FUNCTION public.domino_table_join(p_match uuid, p_user uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
r domino_matches%ROWTYPE;
seat int;
n int;
BEGIN
SELECT * INTO r FROM domino_matches WHERE id = p_match FOR UPDATE;
IF NOT FOUND THEN RETURN jsonb_build_object('joined', false, 'reason', 'not_found'); END IF;
n := COALESCE(r.player_count, 2);
-- Already sitting here: hand back the seat (a second tap, a refresh).
SELECT i - 1 INTO seat FROM jsonb_array_elements_text(COALESCE(r.players, '[]'::jsonb)) WITH ORDINALITY AS p(id, i) WHERE p.id = p_user::text;
IF seat IS NOT NULL THEN
RETURN to_jsonb(r) || jsonb_build_object('joined', true, 'seat', seat);
END IF;
IF r.status <> 'waiting' THEN RETURN jsonb_build_object('joined', false, 'reason', 'started'); END IF;
IF jsonb_array_length(COALESCE(r.players, '[]'::jsonb)) >= n THEN RETURN jsonb_build_object('joined', false, 'reason', 'full'); END IF;
seat := jsonb_array_length(COALESCE(r.players, '[]'::jsonb));
UPDATE domino_matches
SET players = COALESCE(players, '[]'::jsonb) || to_jsonb(p_user::text),
status = CASE WHEN seat + 1 >= n THEN 'in_progress' ELSE status END,
last_activity = now(),
updated_at = now()
WHERE id = p_match
RETURNING * INTO r;
RETURN to_jsonb(r) || jsonb_build_object('joined', true, 'seat', seat);
END;
$$;
-- Stand up from a waiting table (the seats after close up). A table left
-- empty is closed. Returns the row as it is now.
CREATE OR REPLACE FUNCTION public.domino_table_leave(p_match uuid, p_user uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
r domino_matches%ROWTYPE;
rest jsonb;
BEGIN
SELECT * INTO r FROM domino_matches WHERE id = p_match FOR UPDATE;
IF NOT FOUND OR r.status <> 'waiting' THEN RETURN jsonb_build_object('left', false); END IF;
SELECT COALESCE(jsonb_agg(p.id ORDER BY p.i), '[]'::jsonb) INTO rest
FROM jsonb_array_elements(COALESCE(r.players, '[]'::jsonb)) WITH ORDINALITY AS p(id, i)
WHERE p.id <> to_jsonb(p_user::text);
UPDATE domino_matches
SET players = rest,
host_id = CASE WHEN r.host_id = p_user THEN NULLIF(rest->>0, '')::uuid ELSE r.host_id END,
status = CASE WHEN jsonb_array_length(rest) = 0 THEN 'abandoned' ELSE status END,
updated_at = now()
WHERE id = p_match
RETURNING * INTO r;
RETURN to_jsonb(r) || jsonb_build_object('left', true);
END;
$$;
GRANT EXECUTE ON FUNCTION public.domino_merge_state(uuid, jsonb) TO service_role;
GRANT EXECUTE ON FUNCTION public.domino_table_join(uuid, uuid) TO service_role;
GRANT EXECUTE ON FUNCTION public.domino_table_leave(uuid, uuid) TO service_role;
-- What each place pays. complete_match() looks up <game>_2nd/_3rd/_4th; domino
-- had only win and loss keys, so a two-player loss fell through to the
-- built-in 5 coins instead of the 10 the loss key says. Second place is the
-- loss of a two-player match.
INSERT INTO public.reward_config (key, value, description) VALUES
('domino_2nd_coins', '10', 'Domino: second place (the loser of a two-player match)'),
('domino_2nd_xp', '10', 'Domino: second place XP'),
('domino_3rd_coins', '7', 'Domino: third place'),
('domino_3rd_xp', '8', 'Domino: third place XP'),
('domino_4th_coins', '5', 'Domino: fourth place'),
('domino_4th_xp', '6', 'Domino: fourth place XP'),
('domino_round_coins', '3', 'Domino: each round won, paid with the match'),
('domino_round_xp', '5', 'Domino: XP for each round won, paid with the match')
ON CONFLICT (key) DO NOTHING;
-- 2026-10-25: generated opponents for a search that finds nobody (bot fill).
--
-- After a minute in any queue with no one to play, the server seats the player
-- against a generated opponent: a name, an avatar, a level and a rating close
-- to the player's own, driven by the game's existing bot. Each generated
-- profile is kept here, never in public.profiles, so it cannot reach the
-- leaderboards, the player search or the friends system, which all read
-- profiles. The id is the seat id the match rows carry ('bot_gh' + hex), so
-- every existing bot guard (rewards, ratings, uuid casts) applies to it.
--
-- The match rows say so as well: matches.match_type = 'ghost' and
-- game_state.mode = 'ghost' on all four tables, metadata.mode = 'ghost' on
-- chess. An audit joins on match_id.
--
-- Read and written by the API with the service key only.
begin;
create table if not exists public.ghost_profiles (
id text primary key check (id like 'bot\_gh%'),
match_id uuid,
game_key text not null,
for_player uuid not null,
display_name text not null,
avatar_url text,
level int not null default 1,
rating int,
total_wins int not null default 0,
strength text,
created_at timestamptz not null default now()
);
create index if not exists idx_ghost_profiles_match on public.ghost_profiles (match_id);
create index if not exists idx_ghost_profiles_player on public.ghost_profiles (for_player, created_at desc);
alter table public.ghost_profiles enable row level security;
-- The API's service key, and nobody else (anon and authenticated get nothing).
grant select, insert, update, delete on public.ghost_profiles to service_role;
commit;
-- Group chat was readable by every signed-in user ("viewable by channel
-- participants" had USING (true)). Group messages are now visible only to the
-- group's members; match chat keeps its previous visibility (served by the API).
DROP POLICY IF EXISTS "Chat messages viewable by channel participants" ON public.chat_messages;
CREATE POLICY "Chat messages viewable by channel participants" ON public.chat_messages
FOR SELECT TO authenticated
USING (
channel_type <> 'group'
OR EXISTS (SELECT 1 FROM public.group_members gm
WHERE gm.group_id::text = chat_messages.channel_id::text AND gm.user_id = auth.uid())
);
-- 2026-10-26 Manager columns.
--
-- The staff manager (El3ab-management) signs in with admin_users accounts, but
-- several tables point their "who did this" column at profiles (players). An
-- admin id there is rejected by the foreign key, so audit rows and moderation
-- reviews failed to save. Rather than loosen those keys, each table gets a
-- separate column that points at admin_users.
--
-- Idempotent: safe to run more than once.
-- Backup taken before the first run: /root/backups/el3ab-manager-columns-20261026-pre*.sql
BEGIN;
-- 1. audit_log -----------------------------------------------------------------
-- actor_id stays a player profile (null for staff actions). admin_user_id names
-- the staff account. resource_id is a uuid, but the manager also logs things
-- keyed by text (config keys, flag ids, tournament names), so those go in
-- resource_ref instead of failing the uuid cast.
ALTER TABLE public.audit_log ADD COLUMN IF NOT EXISTS admin_user_id uuid;
ALTER TABLE public.audit_log ADD COLUMN IF NOT EXISTS resource_ref text;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'audit_log_admin_user_id_fkey') THEN
ALTER TABLE public.audit_log
ADD CONSTRAINT audit_log_admin_user_id_fkey
FOREIGN KEY (admin_user_id) REFERENCES public.admin_users(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_audit_log_admin_user ON public.audit_log (admin_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON public.audit_log (created_at DESC);
-- 2. cheat_reports -------------------------------------------------------------
-- reviewed_by references profiles; a staff reviewer is recorded here instead.
ALTER TABLE public.cheat_reports ADD COLUMN IF NOT EXISTS reviewed_by_admin uuid;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cheat_reports_reviewed_by_admin_fkey') THEN
ALTER TABLE public.cheat_reports
ADD CONSTRAINT cheat_reports_reviewed_by_admin_fkey
FOREIGN KEY (reviewed_by_admin) REFERENCES public.admin_users(id) ON DELETE SET NULL;
END IF;
END $$;
-- 3. admin_adjust_currency -----------------------------------------------------
-- Staff grants and revokes used to read the balance, add in PHP and write it
-- back: two staff (or a staff member and a finishing game) racing each other
-- lost one of the changes, and a failed second write left a balance with no
-- ledger row. award_coins() is atomic but only handles coins, never refuses to
-- go below zero, and books everything as 'game_reward', which would mix staff
-- grants into game income. This is the same single-statement pattern for both
-- currencies, booked as admin_grant / debit.
CREATE OR REPLACE FUNCTION public.admin_adjust_currency(
p_player_id uuid,
p_currency text,
p_amount integer,
p_reason text,
p_admin text DEFAULT NULL
) RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_balance integer;
BEGIN
IF p_currency NOT IN ('coins', 'gems') THEN
RAISE EXCEPTION 'unsupported currency: %', p_currency USING ERRCODE = '22023';
END IF;
IF p_amount IS NULL OR p_amount = 0 THEN
RAISE EXCEPTION 'amount must be a non-zero integer' USING ERRCODE = '22023';
END IF;
IF p_currency = 'coins' THEN
UPDATE profiles SET coins = COALESCE(coins, 0) + p_amount, updated_at = now()
WHERE id = p_player_id AND COALESCE(coins, 0) + p_amount >= 0
RETURNING coins INTO v_balance;
ELSE
UPDATE profiles SET gems = COALESCE(gems, 0) + p_amount, updated_at = now()
WHERE id = p_player_id AND COALESCE(gems, 0) + p_amount >= 0
RETURNING gems INTO v_balance;
END IF;
IF v_balance IS NULL THEN
IF NOT EXISTS (SELECT 1 FROM profiles WHERE id = p_player_id) THEN
RAISE EXCEPTION 'player not found' USING ERRCODE = 'P0002';
END IF;
RAISE EXCEPTION 'insufficient balance' USING ERRCODE = 'P0001';
END IF;
INSERT INTO economy_transactions (player_id, type, currency, amount, balance_after, reason, source_id)
VALUES (p_player_id,
CASE WHEN p_amount > 0 THEN 'admin_grant' ELSE 'debit' END,
p_currency, p_amount, v_balance, p_reason, p_admin);
RETURN v_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.admin_adjust_currency(uuid, text, integer, text, text) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.admin_adjust_currency(uuid, text, integer, text, text) FROM anon, authenticated;
GRANT EXECUTE ON FUNCTION public.admin_adjust_currency(uuid, text, integer, text, text) TO service_role;
-- 4. system_config double encoding ---------------------------------------------
-- The settings page json_encode()d values before PostgREST encoded them again,
-- so elo_k_factor_master holds the JSON string "\"20\"". Unwrap any string whose
-- text is itself JSON (a quoted string, a number, an array or an object); plain
-- text such as platform_name "EL3AB" does not parse and is left alone. Two
-- passes unwrap the doubly-encoded case completely.
DO $$
DECLARE
r record;
v jsonb;
pass int;
BEGIN
FOR pass IN 1..3 LOOP
FOR r IN SELECT key, value FROM public.system_config WHERE jsonb_typeof(value) = 'string' LOOP
BEGIN
v := (r.value #>> '{}')::jsonb;
EXCEPTION WHEN others THEN
v := NULL;
END;
IF v IS NOT NULL AND v IS DISTINCT FROM r.value THEN
UPDATE public.system_config SET value = v, updated_at = now() WHERE key = r.key;
END IF;
END LOOP;
END LOOP;
END $$;
-- 5. el3ab_tournament_rounds write policy --------------------------------------
-- "authenticated_write_tournament_rounds" let any signed-in player INSERT,
-- UPDATE or DELETE any tournament round through the REST API, i.e. rewrite
-- pairings and results directly. Nothing legitimate uses it: the game engine
-- (includes/tournament-engine.php) and the manager both write rounds with the
-- service key, and the client only subscribes to the table for realtime.
-- Reads stay public through tournament_rounds_viewable_by_everyone.
DROP POLICY IF EXISTS authenticated_write_tournament_rounds ON public.el3ab_tournament_rounds;
COMMIT;
-- PostgREST caches the schema; make it see the new columns and function now.
NOTIFY pgrst, 'reload schema';
-- 2026-10-26 One organisation model: el3ab_organizations is canonical.
--
-- The Swiss service's `organizations` / `org_memberships` stay untouched (the
-- Swiss API owns them). Their two test orgs, two memberships and the 13 pending
-- applications are copied or re-pointed into the El3ab model with their ids
-- kept, and org_membership_applications.org_id now references
-- el3ab_organizations. Spec: docs/org-invites-and-events-plan.md 2.1.
--
-- org_members.member_count is kept by a trigger from here on, so every writer
-- (redeem_code, decide_application, the manager, orgs.php) stays consistent.
--
-- Idempotent. Backup: /root/backups/2026-10-26-org-redeem/ (see docs/redeem-contract.md).
begin;
-- 1. el3ab_organizations ------------------------------------------------------
alter table public.el3ab_organizations
add column if not exists tag text,
add column if not exists join_policy text not null default 'approval',
add column if not exists games text[] not null default '{chess}',
add column if not exists hue text not null default 'purple',
alter column max_members set default 500,
alter column primary_color drop default;
do $$
begin
if not exists (select 1 from pg_constraint where conname = 'el3ab_org_join_policy_check') then
alter table public.el3ab_organizations add constraint el3ab_org_join_policy_check
check (join_policy in ('open','approval','code_only','closed'));
end if;
if not exists (select 1 from pg_constraint where conname = 'el3ab_org_tag_check') then
alter table public.el3ab_organizations add constraint el3ab_org_tag_check
check (tag is null or (char_length(tag) between 2 and 5 and tag !~ '\s'));
end if;
-- The sticker colour families (DESIGN.md, Stickers); never a free hex.
if not exists (select 1 from pg_constraint where conname = 'el3ab_org_hue_check') then
alter table public.el3ab_organizations add constraint el3ab_org_hue_check
check (hue in ('gold','silver','red','green','blue','purple','orange','cream','teal','wood','pink'));
end if;
if not exists (select 1 from pg_constraint where conname = 'el3ab_org_games_check') then
alter table public.el3ab_organizations add constraint el3ab_org_games_check
check (games <@ array['chess','ludo','domino','backgammon']::text[]);
end if;
end $$;
create unique index if not exists el3ab_org_tag_uq
on public.el3ab_organizations (upper(tag)) where tag is not null;
-- Copy the Swiss-world orgs, keeping ids so the applications stay valid.
-- swiss_api_org_id is the bridge back to the Swiss service.
insert into public.el3ab_organizations
(id, swiss_api_org_id, name, slug, logo_url, website, country_code, contact_email,
max_members, created_at, updated_at)
select o.id, o.id, o.name, o.slug, o.logo_url, o.website,
nullif(upper(btrim(o.country_code)), ''), o.contact_email,
500, o.created_at, o.updated_at
from public.organizations o
on conflict (id) do nothing;
-- 2. org_members --------------------------------------------------------------
alter table public.org_members
add column if not exists status text not null default 'active',
add column if not exists left_at timestamptz,
add column if not exists ban_reason text,
add column if not exists redemption_id uuid;
do $$
begin
if not exists (select 1 from pg_constraint where conname = 'org_members_status_check') then
alter table public.org_members add constraint org_members_status_check
check (status in ('active','suspended','banned','left'));
end if;
end $$;
-- A membership without an org or a player is meaningless; the unique key
-- (org_id, player_id) also only holds when both are set.
alter table public.org_members alter column org_id set not null;
alter table public.org_members alter column player_id set not null;
create index if not exists org_members_player_active_idx
on public.org_members (player_id) where status = 'active';
create index if not exists org_members_org_status_idx
on public.org_members (org_id, status);
-- Copy the Swiss memberships (ids kept). 'invited' is not a membership.
insert into public.org_members (id, org_id, player_id, role, status, joined_via, invited_by, joined_at)
select m.id, m.organization_id, m.user_id,
case m.role::text when 'org_admin' then 'owner' when 'arbiter' then 'arbiter' else 'member' end,
case m.status::text when 'suspended' then 'suspended' else 'active' end,
'migrated',
case when exists (select 1 from public.profiles p where p.id = m.invited_by) then m.invited_by end,
coalesce(m.joined_at, m.created_at)
from public.org_memberships m
where m.status::text <> 'invited'
and exists (select 1 from public.profiles p where p.id = m.user_id)
and exists (select 1 from public.el3ab_organizations o where o.id = m.organization_id)
on conflict (org_id, player_id) do nothing;
-- 3. org_membership_applications ----------------------------------------------
alter table public.org_membership_applications
drop constraint if exists org_membership_applications_org_id_fkey;
alter table public.org_membership_applications
add constraint org_membership_applications_org_id_fkey
foreign key (org_id) references public.el3ab_organizations(id) on delete cascade;
-- reviewed_by stays a profiles FK for player reviewers (org owners in the app);
-- staff reviewers from the manager go in reviewed_by_admin.
alter table public.org_membership_applications
add column if not exists review_note text,
add column if not exists reviewed_by_admin uuid references public.admin_users(id) on delete set null,
add column if not exists redemption_id uuid;
create index if not exists org_apps_org_status_idx
on public.org_membership_applications (org_id, status, created_at desc);
create index if not exists org_apps_player_idx
on public.org_membership_applications (player_id, created_at desc);
-- api/profile.php my-applications embeds `organizations(id,name,slug,logo_url)`
-- through the FK that just moved. This computed relationship (PostgREST 12)
-- keeps that embed working, now answered from el3ab_organizations. Drop it
-- once profile.php stops embedding `organizations`.
create or replace function public.organizations(public.org_membership_applications)
returns setof public.el3ab_organizations
language sql stable rows 1
as $$ select o.* from public.el3ab_organizations o where o.id = $1.org_id $$;
revoke all on function public.organizations(public.org_membership_applications) from public, anon, authenticated;
grant execute on function public.organizations(public.org_membership_applications) to service_role;
-- 4. profiles.primary_org_id --------------------------------------------------
alter table public.profiles
add column if not exists primary_org_id uuid references public.el3ab_organizations(id) on delete set null;
-- 5. member_count and primary_org_id follow org_members -----------------------
create or replace function public.org_members_sync()
returns trigger
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_old boolean := false;
v_new boolean := false;
begin
if tg_op in ('UPDATE','DELETE') then v_old := old.status = 'active'; end if;
if tg_op in ('INSERT','UPDATE') then v_new := new.status = 'active'; end if;
if tg_op = 'UPDATE' and v_old = v_new
and old.org_id = new.org_id and old.player_id = new.player_id then
return null;
end if;
if v_old then
update el3ab_organizations set member_count = greatest(coalesce(member_count, 0) - 1, 0)
where id = old.org_id;
update profiles p
set primary_org_id = (select m.org_id from org_members m
where m.player_id = old.player_id and m.status = 'active'
and m.org_id <> old.org_id
order by m.joined_at limit 1)
where p.id = old.player_id and p.primary_org_id = old.org_id;
end if;
if v_new then
update el3ab_organizations set member_count = coalesce(member_count, 0) + 1
where id = new.org_id;
update profiles set primary_org_id = new.org_id
where id = new.player_id and primary_org_id is null;
end if;
return null;
end $$;
revoke all on function public.org_members_sync() from public, anon, authenticated;
drop trigger if exists org_members_sync on public.org_members;
create trigger org_members_sync
after insert or delete or update of status, org_id, player_id on public.org_members
for each row execute function public.org_members_sync();
-- One-off recount and backfill; the trigger keeps both current afterwards.
update public.el3ab_organizations o
set member_count = c.n
from (select o2.id, (select count(*) from public.org_members m
where m.org_id = o2.id and m.status = 'active')::int as n
from public.el3ab_organizations o2) c
where c.id = o.id and o.member_count is distinct from c.n;
update public.profiles p
set primary_org_id = f.org_id
from (select distinct on (player_id) player_id, org_id
from public.org_members where status = 'active'
order by player_id, joined_at) f
where f.player_id = p.id and p.primary_org_id is null;
commit;
notify pgrst, 'reload schema';
This diff is collapsed.
......@@ -186,15 +186,17 @@
--board-frame: #1D1A25;
--bg-board-border: #14121A;
--bg-board-light: #22A57E;
--bg-board-dark: #1B9070;
/* Tawla: field, points and tray all sit in one mid-tone band, so white and
ink checkers both hold 3:1 or more on every surface they stand on. */
--bg-board-light: #177F68;
--bg-board-dark: #157862;
--bg-bar-dark: #14121A;
--bg-bar-light: #1D1A25;
--bg-bearoff: #1D1A25;
--bg-point-dark: #FF4458;
--bg-point-dark-tip: #FF4458;
--bg-point-light: #F4F2F8;
--bg-point-light-tip: #F4F2F8;
--bg-bearoff: #6B6585;
--bg-point-dark: #F03E52;
--bg-point-dark-tip: #F03E52;
--bg-point-light: #8A84A3;
--bg-point-light-tip: #8A84A3;
--bg-checker-white: #FFFFFF;
--bg-checker-black: #1A1722;
--bg-highlight: #2ED47A;
......
This diff is collapsed.
......@@ -47,13 +47,19 @@ export async function api(endpoint, options = {}) {
}
if (!res.ok) {
// The server could not reach the auth service; nothing ran, so asking
// again is safe whatever the method.
if (res.status === 503 && data?.code === 'auth_unavailable' && !options._authWait) {
await new Promise(r => setTimeout(r, 1500));
return api(endpoint, { ...options, _authWait: true });
}
if (res.status === 401 && !options._retried) {
const refreshed = await refreshToken();
if (refreshed) {
const retryRes = await api(endpoint, { ...options, _retried: true });
return retryRes;
}
bus.emit('auth:expired');
const outcome = await refreshToken();
if (outcome === 'ok') return api(endpoint, { ...options, _retried: true });
// Only a refresh the server turned down ends the session. A dropped line
// must never sign the player out: a guest has no password to come back with.
if (outcome === 'invalid') bus.emit('auth:expired');
else throw new ApiError('Connection problem', 503);
} else if (res.status === 401 && options._retried) {
bus.emit('auth:expired');
}
......@@ -72,8 +78,8 @@ export async function get(endpoint, params = {}) {
return api(url);
}
export async function post(endpoint, body) {
return api(endpoint, { method: 'POST', body });
export async function post(endpoint, body, opts = {}) {
return api(endpoint, { ...opts, method: 'POST', body });
}
export async function patch(endpoint, body) {
......@@ -91,25 +97,40 @@ async function refreshToken() {
try { return await refreshPromise; } finally { refreshPromise = null; }
}
/**
* Trade the refresh token for a new pair.
* Resolves 'ok', 'invalid' (the server refused the session) or 'offline'
* (no clear answer: keep the tokens and try again later).
*/
async function doRefresh() {
const refreshToken = store.get('auth.refreshToken');
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/auth.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'refresh', refresh_token: refreshToken })
});
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) return false;
const data = await res.json();
if (data.access_token) {
store.set('auth.token', data.access_token);
store.set('auth.refreshToken', data.refresh_token);
return true;
if (!refreshToken) return 'invalid';
for (let attempt = 0; attempt < 2; attempt++) {
if (attempt) await new Promise(r => setTimeout(r, 1500));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(`${API_BASE}/auth.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'refresh', refresh_token: refreshToken }),
signal: controller.signal,
});
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) continue;
const data = await res.json();
if (data.access_token) {
store.set('auth.token', data.access_token);
store.set('auth.refreshToken', data.refresh_token);
return 'ok';
}
if (res.status === 400 || res.status === 401 || res.status === 403) return 'invalid';
} catch (e) {
} finally {
clearTimeout(timer);
}
} catch (e) {}
return false;
}
return 'offline';
}
class ApiError extends Error {
......
......@@ -239,12 +239,21 @@ function plateEl(seatInfo) {
// Sending
// ---------------------------------------------------------------------------
// Who wants to hear about the player's own reactions (a generated opponent,
// modules/play/ghost.js, answers some of them). Returns the unsubscribe.
const sendListeners = new Set();
export function onSend(fn) {
sendListeners.add(fn);
return () => sendListeners.delete(fn);
}
export async function send(kind, key) {
if (!session) return false;
const now = Date.now();
if (now - session.lastSentAt < SEND_COOLDOWN_MS) return false;
session.lastSentAt = now;
for (const fn of sendListeners) { try { fn(kind, key); } catch (e) {} }
// Show our own reaction immediately. Waiting for the round trip makes the
// button feel broken on a slow connection.
......
......@@ -2,6 +2,7 @@
// Used for live multiplayer in Chess and Ludo
import * as store from './store.js';
import * as bus from './bus.js';
import { SUPABASE_URL } from './config.js';
const REALTIME_URL = SUPABASE_URL.replace('https://', 'wss://') + '/realtime/v1/websocket';
......@@ -56,6 +57,11 @@ export function connect() {
} else if (msg.event === 'broadcast' && broadcasts[msg.topic]) {
const b = msg.payload || {};
broadcasts[msg.topic].forEach(cb => cb(b.event, b.payload || {}));
} else if ((msg.event === 'phx_error' || (msg.event === 'system' && msg.payload?.status === 'error'))
&& (subscriptions[msg.topic] || broadcasts[msg.topic])) {
// The server dropped a channel we still want (an expired token, a
// restart): join it again with the current token, at most every 10 s.
rejoinSoon(msg.topic);
} 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.
......@@ -197,6 +203,26 @@ function joinChannel(topic) {
});
}
const lastRejoin = {};
function rejoinSoon(topic) {
if (Date.now() - (lastRejoin[topic] || 0) < 10000) return;
lastRejoin[topic] = Date.now();
setTimeout(() => {
if (!connected) return;
if (subscriptions[topic]) joinChannel(topic);
else if (broadcasts[topic]) joinBroadcastTopic(topic);
}, 1500);
}
// A refreshed session reaches the open channels. Without it every channel is
// closed when the old token expires (an hour in) and the app goes quiet.
bus.on('store:auth.token', (tok) => {
if (!connected || !tok) return;
for (const topic of [...Object.keys(subscriptions), ...Object.keys(broadcasts)]) {
send({ topic, event: 'access_token', payload: { access_token: tok }, ref: String(++ref) });
}
});
function leaveChannel(topic) {
send({ topic, event: 'phx_leave', payload: {}, ref: String(++ref) });
}
......
// The tawla board, Night Arcade: flat and stylised. An ink case on a solid
// lip, a teal field in both halves, red and white points, an ink bar and a
// bear-off tray on the home side. Checkers are flat discs, white and ink, each
// on a darker lip of its own shape. No textures, no gloss, no outlines.
// lip, a deep teal field in both halves, red and slate points, an ink bar and
// a slate bear-off tray on the home side. Checkers are flat discs, white and
// ink, each on a darker lip of its own shape; the white one stands on a deep
// lip so its edge reads on every surface. Field, points and tray share one
// mid-tone band (tokens.css), so both colours of checker hold 3:1 or more on
// all of them. No textures, no gloss, no outlines.
//
// One geometry (computeLayout) serves drawing, tapping, animation and tests.
// The board is always drawn from the viewer's side: their home board is
......@@ -63,12 +66,12 @@ export function palette() {
selected: tok('bg_selected', '#FFC531'),
frame: tok('bg_board_border', '#14121A'),
lip: tok('back_950', '#0D0C12'),
fieldLight: tok('bg_board_light', '#22A57E'),
fieldDark: tok('bg_board_dark', '#1B9070'),
fieldLight: tok('bg_board_light', '#177F68'),
fieldDark: tok('bg_board_dark', '#157862'),
bar: tok('bg_bar_dark', '#14121A'),
tray: tok('bg_bearoff', '#1D1A25'),
pointRed: tok('bg_point_dark', '#FF4458'),
pointWhite: tok('bg_point_light', '#F4F2F8'),
tray: tok('bg_bearoff', '#6B6585'),
pointRed: tok('bg_point_dark', '#F03E52'),
pointWhite: tok('bg_point_light', '#8A84A3'),
white: tok('bg_checker_white', '#FFFFFF'),
black: tok('bg_checker_black', '#1A1722'),
};
......@@ -253,9 +256,10 @@ function paintChecker(r, pad, size, k, type, P) {
const cx = pad + r, cy = pad + r - r * 0.09;
const white = type === WHITE;
const face = white ? P.white : P.black;
const lipCol = white ? P.cardEdge : shade(P.black, -0.55);
// The white disc stands on a deep slate lip: a crisp edge on any surface.
const lipCol = white ? shade(P.cardEdge, -0.6) : shade(P.black, -0.55);
const ring = white ? P.card3 : shade(P.black, 0.16);
g.beginPath(); g.arc(cx, cy + r * 0.18, r * 0.98, 0, TAU);
g.beginPath(); g.arc(cx, cy + r * 0.2, r * 0.98, 0, TAU);
g.fillStyle = lipCol;
g.fill();
g.beginPath(); g.arc(cx, cy, r * 0.98, 0, TAU);
......@@ -388,10 +392,19 @@ export function drawBoard(ctx, L, state, opts = {}) {
}
}
// The bar
// The bar. Checkers on it sit in a slate socket: an ink checker on the ink
// bar would vanish.
for (const type of [WHITE, BLACK]) {
const pieces = hidden && hidden.size ? state.bar[type].filter(p => !hidden.has(p.id)) : state.bar[type];
const n = pieces.length;
if (n) {
const a = barPos(L, type, 0), z = barPos(L, type, Math.min(n, 4) - 1);
const pad = L.r + 3;
const y0 = Math.min(a.y, z.y) - pad, y1 = Math.max(a.y, z.y) + pad + L.r * 0.2;
roundRect(ctx, a.x - pad, y0, pad * 2, y1 - y0, pad);
ctx.fillStyle = P.tray;
ctx.fill();
}
for (let j = 0; j < Math.min(n, 4); j++) {
const bp = barPos(L, type, j);
const isTop = j === Math.min(n, 4) - 1;
......@@ -435,7 +448,7 @@ export function drawSlice(ctx, L, type, k, P = palette(), opts = {}) {
const x = tp.x - w / 2, y = tp.y - h / 2;
const white = type === WHITE;
roundRect(ctx, x, y + 1.2, w, h, Math.min(3, h / 2));
ctx.fillStyle = white ? P.cardEdge : shade(P.black, -0.55);
ctx.fillStyle = white ? shade(P.cardEdge, -0.6) : shade(P.black, -0.55);
ctx.fill();
roundRect(ctx, x, y, w, h, Math.min(3, h / 2));
ctx.fillStyle = white ? P.white : shade(P.black, 0.12);
......
......@@ -11,8 +11,13 @@
// opposing checker pins it (it cannot move until the pinner leaves) and the
// point then belongs to the pinner. Gammon 2.
// - 31 (wahed w talateen): laid out like mahbousa, but one checker owns a
// point: nothing can land on it, hit it or pin it. The winner scores one
// point for every checker the loser still has on the board.
// point: nothing can land on it, hit it or pin it. The opening rule: the
// first checker to leave the starting point has to travel all the way into
// its owner's home board (where the opponent's stack stands) before a
// second checker may leave the start; until then only that one checker
// moves. Once it has arrived, the rest of the dice, this turn included,
// are free. The winner scores one point for every checker the loser still
// has on the board; the match goes to 31.
//
// Moves follow the standard rules: play as many dice as possible; when only
// one die can be played, play the larger; bear off with a larger die only from
......@@ -35,18 +40,24 @@ const STACK_SETUP = [[WHITE, 23, 15], [BLACK, 0, 15]];
const VARIANTS = {
sheshbesh: { capture: 'hit', setup: FULL_SETUP, scoring: 'backgammon', lengths: [1, 3, 5, 7], defaultLength: 3 },
mahbousa: { capture: 'pin', setup: STACK_SETUP, scoring: 'gammon', lengths: [1, 3, 5, 7], defaultLength: 3 },
thirtyone: { capture: 'block', setup: STACK_SETUP, scoring: 'checkers', lengths: [1, 11, 21, 31], defaultLength: 31 },
thirtyone: { capture: 'block', setup: STACK_SETUP, scoring: 'checkers', lengths: [1, 11, 21, 31], defaultLength: 31, firstRunner: true },
};
export function getVariants() { return Object.keys(VARIANTS); }
/** What a variant does: capture ('hit' | 'pin' | 'block'), scoring, match lengths. */
/**
* What a variant does: capture ('hit' | 'pin' | 'block'), scoring, match
* lengths, and firstRunner (31: one checker must reach home before the rest
* may leave the start).
*/
export function variantInfo(variant) {
const v = VARIANTS[variant] || VARIANTS.sheshbesh;
return { capture: v.capture, scoring: v.scoring, lengths: v.lengths.slice(), defaultLength: v.defaultLength };
return { capture: v.capture, scoring: v.scoring, lengths: v.lengths.slice(), defaultLength: v.defaultLength, firstRunner: !!v.firstRunner };
}
const ruleOf = (variant) => VARIANTS[variant] || VARIANTS.sheshbesh;
/** What move generation needs: how a lone checker is taken, and 31's opening rule. */
const moveRule = (variant) => { const v = ruleOf(variant); return { capture: v.capture, runner: !!v.firstRunner }; };
const norm = (pos, type) => (type === WHITE ? pos : 23 - pos);
const other = (type) => (type === WHITE ? BLACK : WHITE);
......@@ -153,7 +164,8 @@ function landing(b, dest, type, capture) {
}
/** Where one die takes a checker from `from` ('bar' or a point), or null. */
function stepTarget(b, type, from, die, capture) {
function stepTarget(b, type, from, die, rule) {
const capture = rule.capture;
if (from === 'bar') {
const dest = type === WHITE ? 24 - die : die - 1;
const kind = landing(b, dest, type, capture);
......@@ -176,11 +188,45 @@ function stepTarget(b, type, from, die, capture) {
return { to: 'off', kind: BEAR };
}
function sources(b, type) {
const startOf = (type) => (type === WHITE ? 23 : 0);
/**
* 31's opening rule, read off the position: true while no checker of `type`
* has reached its home board (or been borne off) and one has already left the
* starting point. The start is then closed until that runner arrives.
*/
function startLocked(b, type) {
if (b.off[type]) return false;
const start = startOf(type);
let out = false;
for (let i = 0; i < 24; i++) {
if (i === start || !b.pts[i].includes(type)) continue;
if (norm(i, type) < 6) return false;
out = true;
}
return out;
}
/**
* Where 31's opening rule stands for `type`: null in the other variants (and
* once a checker has reached home); otherwise { start, locked }, locked once
* the runner has left and the start is closed until it arrives.
*/
export function openingRule(game, type = game.turn) {
if (!ruleOf(game.variant).firstRunner) return null;
const b = compact(game.state);
if (b.off[type] || b.bar[type]) return null;
for (let i = 0; i < 24; i++) if (b.pts[i].includes(type) && norm(i, type) < 6) return null;
return { start: startOf(type), locked: startLocked(b, type) };
}
function sources(b, type, runner = false) {
if (b.bar[type]) return ['bar'];
const locked = runner && startLocked(b, type);
const out = [];
for (let i = 0; i < 24; i++) {
const pt = b.pts[i];
if (locked && i === startOf(type)) continue;
if (pt.length && pt[pt.length - 1] === type) out.push(i);
}
return out;
......@@ -201,7 +247,7 @@ function applyCompact(b, type, from, r) {
const sum = (a) => a.reduce((s, x) => s + x, 0);
/** The most pips' worth of dice that can still be played from here. */
function bestUse(b, type, dice, capture, memo) {
function bestUse(b, type, dice, rule, memo) {
if (!dice.length) return 0;
const key = keyOf(b) + '#' + dice.join('');
const hit = memo.get(key);
......@@ -215,10 +261,10 @@ function bestUse(b, type, dice, capture, memo) {
if (tried.has(d)) continue;
tried.add(d);
const rest = dice.slice(0, k).concat(dice.slice(k + 1));
for (const from of sources(b, type)) {
const r = stepTarget(b, type, from, d, capture);
for (const from of sources(b, type, rule.runner)) {
const r = stepTarget(b, type, from, d, rule);
if (!r) continue;
const v = d + bestUse(applyCompact(b, type, from, r), type, rest, capture, memo);
const v = d + bestUse(applyCompact(b, type, from, r), type, rest, rule, memo);
if (v > best) { best = v; if (best === total) break outer; }
}
}
......@@ -227,9 +273,9 @@ function bestUse(b, type, dice, capture, memo) {
}
/** Every legal first move: { from, steps, to, kind }. */
function legalFirst(b, type, dice, capture) {
function legalFirst(b, type, dice, rule) {
const memo = new Map();
const best = bestUse(b, type, dice, capture, memo);
const best = bestUse(b, type, dice, rule, memo);
if (!best) return [];
const out = [];
const tried = new Set();
......@@ -238,10 +284,10 @@ function legalFirst(b, type, dice, capture) {
if (tried.has(d)) continue;
tried.add(d);
const rest = dice.slice(0, k).concat(dice.slice(k + 1));
for (const from of sources(b, type)) {
const r = stepTarget(b, type, from, d, capture);
for (const from of sources(b, type, rule.runner)) {
const r = stepTarget(b, type, from, d, rule);
if (!r) continue;
if (d + bestUse(applyCompact(b, type, from, r), type, rest, capture, memo) === best) {
if (d + bestUse(applyCompact(b, type, from, r), type, rest, rule, memo) === best) {
out.push({ from, steps: d, to: r.to, kind: r.kind });
}
}
......@@ -274,8 +320,7 @@ export function getValidMoves(game) {
const sig = keyOf(b) + '#' + game.turn + '#' + game.movesLeft.join('') + '#' + game.variant;
const c = cache.get(game);
if (c && c.sig === sig) return c.moves;
const capture = ruleOf(game.variant).capture;
const moves = legalFirst(b, game.turn, game.movesLeft, capture).map(m => {
const moves = legalFirst(b, game.turn, game.movesLeft, moveRule(game.variant)).map(m => {
const stack = m.from === 'bar' ? game.state.bar[game.turn] : game.state.points[m.from];
const top = stack[stack.length - 1];
return { ...m, piece: { id: top.id, type: top.type } };
......@@ -353,7 +398,6 @@ function playMove(game, move) {
// the pinner still has checkers on its own starting point (its own mother is
// still in danger). Both mothers pinned: the game is a tie. Without this rule
// two pinned mothers freeze the board for ever.
const startOf = (type) => (type === WHITE ? 23 : 0);
function motherPinned(state, type) {
const pt = state.points[startOf(type)];
return pt.length > 1 && pt[0].type === type && pt[pt.length - 1].type !== type;
......@@ -421,11 +465,11 @@ export function isPinnedAt(state, pos, index) {
*/
export function getTurnPlays(game) {
if (!game.dice || !game.movesLeft.length) return [];
const capture = ruleOf(game.variant).capture;
const rule = moveRule(game.variant);
const type = game.turn;
const b0 = compact(game.state);
const memo = new Map();
const best = bestUse(b0, type, game.movesLeft, capture, memo);
const best = bestUse(b0, type, game.movesLeft, rule, memo);
if (!best) return [];
const results = new Map();
const walk = (b, dice, used, moves) => {
......@@ -440,11 +484,11 @@ export function getTurnPlays(game) {
if (tried.has(d)) continue;
tried.add(d);
const rest = dice.slice(0, k).concat(dice.slice(k + 1));
for (const from of sources(b, type)) {
const r = stepTarget(b, type, from, d, capture);
for (const from of sources(b, type, rule.runner)) {
const r = stepTarget(b, type, from, d, rule);
if (!r) continue;
const nb = applyCompact(b, type, from, r);
if (used + d + bestUse(nb, type, rest, capture, memo) !== best) continue;
if (used + d + bestUse(nb, type, rest, rule, memo) !== best) continue;
walk(nb, rest, used + d, moves.concat([{ from, steps: d, to: r.to, kind: r.kind }]));
}
}
......
This diff is collapsed.
This diff is collapsed.
......@@ -90,6 +90,23 @@ export function kingSquare(color) {
return null;
}
/**
* Could `color` still mate with what it has? A lone king, or a king and one
* bishop or one knight, cannot: running out of time against it is a draw
* (api: includes/chess.php chessCanMate, the same rule).
*/
export function canMate(color) {
let minors = 0;
for (const row of game.board()) {
for (const p of row) {
if (!p || p.color !== color || p.type === 'k') continue;
if (p.type === 'b' || p.type === 'n') minors++;
else return true;
}
}
return minors >= 2;
}
export function getResult(playerColor) {
if (!isGameOver()) return null;
if (isCheckmate()) {
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -22,7 +22,7 @@ function reasonLine(reason, result) {
const map = {
checkmate: 'chess.reason_checkmate',
resign: won ? 'chess.reason_resign_won' : 'chess.reason_resign_lost',
timeout: won ? 'chess.reason_timeout_won' : 'chess.reason_timeout_lost',
timeout: result === 'draw' ? 'chess.reason_timeout_draw' : won ? 'chess.reason_timeout_won' : 'chess.reason_timeout_lost',
abandon: won ? 'chess.reason_abandon_won' : 'chess.reason_abandon_lost',
stalemate: 'chess.reason_stalemate',
agreement: 'chess.reason_agreement',
......@@ -99,6 +99,7 @@ export function mountResult(el, params) {
<dd><span class="rs-value num" id="rs-xp">+0</span><span class="rs-unit">${t('game.xp')}</span></dd>
</div>
</dl>
${params.unpaid ? `<p class="rs-unpaid">${t('game.too_short_unpaid')}</p>` : ''}
</article>
<div class="rs-actions">
......@@ -124,6 +125,7 @@ export function mountResult(el, params) {
tableBoard.setPosition(params.finalFen);
if (params.lastMove) tableBoard.setLastMove(params.lastMove.from, params.lastMove.to);
if (params.check) tableBoard.highlights.check = params.check;
if (params.mated) tableBoard.setMate(params.mated, { instant: true });
tableBoard.draw();
}
......@@ -313,6 +315,10 @@ const RESULT_STYLE = `
.rs-ribbon h1 { font-family: var(--font-display); font-size: var(--fs-2xl); line-height: 1.1; color: var(--text-primary); }
.rs-sticker { display: flex; align-items: center; justify-content: center; height: 118px; animation: bounceIn 620ms var(--ease-pop) 640ms both; }
.rs-win .rs-sticker .gi { animation: rsBob 2.4s ease-in-out 1.4s infinite; }
/* The handshake is drawn pale for the ink stage: on the white card it
stands on a disc in the draw's blue, or it all but disappears. */
.rs-draw .rs-sticker { width: 118px; border-radius: 50%; background: var(--btn-blue-2); box-shadow: 0 5px 0 var(--btn-blue-edge); }
.rs-draw .rs-sticker .gi { width: 84px; height: 84px; }
@keyframes rsBob { 0%, 100% { translate: 0 0; rotate: 0deg; } 50% { translate: 0 -6px; rotate: 3deg; } }
.rs-toppled { animation: rsTopple 900ms var(--ease-pop) 900ms both; transform-origin: 70% 90%; }
@keyframes rsTopple { 0% { rotate: 0deg; } 60% { rotate: 98deg; } 80% { rotate: 84deg; } 100% { rotate: 90deg; } }
......@@ -345,6 +351,7 @@ const RESULT_STYLE = `
}
.rs-cell dt .gi { width: 30px; height: 30px; }
.rs-cell dd { display: flex; flex-direction: column; align-items: flex-start; gap: 0; line-height: 1; }
.rs-unpaid { font-size: var(--fs-xs); font-weight: 700; color: var(--ink-3); text-align: center; }
.rs-unit { font-size: var(--fs-xs); font-weight: 700; color: var(--ink-3); margin-top: 2px; }
.rs-row dd { display: inline-flex; align-items: baseline; gap: 8px; }
.rs-value { font-family: var(--font-display); font-size: var(--fs-xl); line-height: 1; color: var(--ink); }
......@@ -364,6 +371,8 @@ const RESULT_STYLE = `
.rs-card { padding-top: 34px; gap: 4px; }
.rs-sticker { height: 76px; }
.rs-sticker .gi { width: 72px; height: 72px; }
.rs-draw .rs-sticker { width: 76px; }
.rs-draw .rs-sticker .gi { width: 54px; height: 54px; }
.rs-row { min-height: 40px; }
.rs-cell { min-height: 44px; }
.rs-actions { gap: var(--s-2); }
......
......@@ -193,7 +193,6 @@ function renderReview(el, results, moves, playerColor) {
renderEvalGraph(el.querySelector('#eval-graph-container'), results);
el.querySelector('#btn-full-analysis')?.addEventListener('click', () => {
audio.play('click');
scene.push('chess-analysis', { moveHistory: moves, playerColor, pgn: moves.map(m => m.san).join(' ') });
});
}
......
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.
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.
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