Commit a855527a authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(backgammon): forced moves, variant rules, online sync, bot payouts

Rules: move generation is rewritten so both dice must be played when they
can be, otherwise the larger. 31 used a long-backgammon setup and allowed
pins; it now starts like mahbousa, a single checker closes a point and the
winner scores the loser's remaining checkers (lengths 1/11/21/31).
Mahbousa could freeze forever when both starting stacks were pinned; the
plakoto mother rule now settles it. Turning the cube off lasted one game,
taking the bot's double froze the game, and a player on black saw the
board upside down with a swapped score.

Online: a poll could wipe unsent moves, the cube never synced, 'opponent'
was sent as the winner id, and a refreshed tab rejoined as white. Online
is now a numbered move log plus a snapshot; the other side replays the log
with animation and checks it matches. Timers stop when the scene closes.

Bot games get a server match row (action start) and complete pays the
creator once; a row under 90 s old closes with no payout.

Look: flat teal board, red and white points, checkers on a lip, flat dice
and cube. backgammon-rules 659/659 (brute-force cross-check); browser
suite 122 + online 39/39 + layout 20/20, zero console errors.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 8a0227c2
...@@ -21,6 +21,7 @@ $action = $input['action'] ?? ''; ...@@ -21,6 +21,7 @@ $action = $input['action'] ?? '';
const BG_CLAIM_PLACEHOLDER = '00000000-0000-0000-0000-000000000000'; const BG_CLAIM_PLACEHOLDER = '00000000-0000-0000-0000-000000000000';
switch ($action) { switch ($action) {
case 'start': handleStart($userId, $input); break;
case 'queue': handleQueue($userId, $input); break; case 'queue': handleQueue($userId, $input); break;
case 'dequeue': handleBgDequeue($userId); break; case 'dequeue': handleBgDequeue($userId); break;
case 'status': handleStatus($userId); break; case 'status': handleStatus($userId); break;
...@@ -33,6 +34,31 @@ switch ($action) { ...@@ -33,6 +34,31 @@ switch ($action) {
default: jsonError('Invalid action'); default: jsonError('Invalid action');
} }
/**
* A game against the bot gets a real match row, players [me, 'bot_<level>'],
* so its win can be paid once by complete (as domino does).
*/
function handleStart(string $userId, array $input): void {
$level = in_array($input['bot_level'] ?? '', ['easy', 'medium', 'hard'], true) ? $input['bot_level'] : 'medium';
$variant = in_array($input['variant'] ?? '', ['sheshbesh', 'mahbousa', 'thirtyone'], true) ? $input['variant'] : 'sheshbesh';
$length = max(1, min(31, intval($input['match_length'] ?? 3)));
$match = supabaseService()->insert('backgammon_matches', [
'status' => 'in_progress',
'players' => [$userId, 'bot_' . $level],
'colors' => ['white', 'black'],
'variant' => $variant,
'match_length' => $length,
'scores' => [0, 0],
'current_turn' => 0,
'game_state' => ['mode' => 'bot', 'bot_level' => $level],
'host_id' => $userId,
'created_by' => $userId,
]);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if (!$matchId) jsonError('Could not start the match', 500);
jsonResponse(['match_id' => $matchId]);
}
/** jsonb columns arrive as arrays; rows written json_encode()d arrive as strings. */ /** jsonb columns arrive as arrays; rows written json_encode()d arrive as strings. */
function bgJson($value, array $default = []): array { function bgJson($value, array $default = []): array {
if (is_array($value)) return $value; if (is_array($value)) return $value;
...@@ -296,8 +322,38 @@ function handleComplete(string $userId, array $input): void { ...@@ -296,8 +322,38 @@ function handleComplete(string $userId, array $input): void {
// The client names the winner as its own id or as 'opponent'. Resolve that // The client names the winner as its own id or as 'opponent'. Resolve that
// against the seats, so winner_id is always a seated player's id (or null) // against the seats, so winner_id is always a seated player's id (or null)
// and never the string 'opponent', which a uuid column refuses. // and never the string 'opponent', which a uuid column refuses.
$rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players', 'limit' => 1]); $rows = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,status,created_by,created_at', 'limit' => 1]);
$players = (is_array($rows) && !isset($rows['error']) && !empty($rows)) ? bgJson($rows[0]['players'] ?? null) : []; $row = (is_array($rows) && !isset($rows['error']) && !empty($rows)) ? $rows[0] : null;
$players = $row ? bgJson($row['players'] ?? null) : [];
$isBot = (bool) array_filter($players, fn($p) => is_string($p) && str_starts_with($p, 'bot_'));
if ($isBot) {
// A bot match pays its creator once, while it is still being played.
if (!$row || ($row['status'] ?? '') !== 'in_progress') jsonResponse(['success' => true, 'already_completed' => true, 'rewards' => []]);
if (($row['created_by'] ?? null) !== $userId) jsonError('Not your match', 403);
$botId = array_values(array_filter($players, fn($p) => is_string($p) && str_starts_with($p, 'bot_')))[0];
if ($winner !== $userId) $winner = $botId;
// Anti-farming: a real game against the bot takes minutes.
$tooQuick = strtotime($row['created_at'] ?? 'now') > time() - 90;
$claimed = $sdb->update('backgammon_matches', [
'status' => 'completed',
'completed_at' => date('c'),
'winner_id' => $winner === $userId ? $userId : null,
'result' => $tooQuick ? 'too_short' : $reason,
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId, 'completed_at' => 'is.null']);
if (isset($claimed['error'])) jsonError('Could not complete the match', 500);
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.
$result = supabaseRpc('complete_match', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_winners' => [$winner === $userId ? $userId : null],
'p_reason' => $reason
]);
jsonResponse($result ?: ['success' => true, 'rewards' => []]);
}
if ($winner === 'opponent') { if ($winner === 'opponent') {
$others = array_values(array_filter($players, fn($p) => $p !== $userId)); $others = array_values(array_filter($players, fn($p) => $p !== $userId));
$winner = $others[0] ?? null; $winner = $others[0] ?? null;
......
// Premium 60fps board renderer — wood grain, 3D checkers, glow, particles // 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.
//
// One geometry (computeLayout) serves drawing, tapping, animation and tests.
// The board is always drawn from the viewer's side: their home board is
// bottom right, next to their tray; a player on black sees it rotated.
//
// The case, field and points are painted once into an offscreen canvas; each
// frame only stamps it and draws what moves.
import { WHITE, BLACK } from '../logic/rules.js'; import { WHITE, BLACK } from '../logic/rules.js';
import { getColor } from '../../../core/theme.js'; import { getColor, COLOR_KEYS } from '../../../core/theme.js';
const t = (k, fb) => getColor(k, null) || (window.__EL3AB_THEME?.[k]) || fb; const TAU = Math.PI * 2;
const EDITABLE = new Set(COLOR_KEYS);
export const FONT = "'El3ab Display', Rubik, system-ui, sans-serif";
export function drawBoard(ctx, w, h, state, options = {}) { /** A board colour: stored admin value or token for editable keys, the token otherwise. */
const { highlights = [], selectedPoint = null, animFrame, particles = [] } = options; function tok(key, fallback) {
if (EDITABLE.has(key)) return getColor(key, fallback);
const margin = 8; const v = getComputedStyle(document.documentElement).getPropertyValue('--' + key.replace(/_/g, '-')).trim();
const barWidth = Math.round(w * 0.06); return v || fallback;
const bearW = Math.round(w * 0.07); }
const boardW = w - margin * 2;
const boardH = h - margin * 2;
const innerW = boardW - bearW;
const pointW = (innerW - barWidth) / 12;
const pointH = boardH * 0.42;
const checkerR = Math.min(pointW * 0.43, 17);
const layout = { margin, pointW, pointH, barWidth, boardW, boardH, bearW, innerW, checkerR, w, h };
const barX = margin + 6 * pointW;
ctx.clearRect(0, 0, w, h);
// ── Board frame ──
ctx.fillStyle = t('bg_board_border', '#5C3A0E');
roundRect(ctx, margin - 3, margin - 3, boardW + 6, boardH + 6, 6);
ctx.fill();
// ── Board surface (wood grain) ──
const woodGrad = ctx.createLinearGradient(margin, margin, margin, margin + boardH);
woodGrad.addColorStop(0, t('bg_board_light', '#3D6B1E'));
woodGrad.addColorStop(0.5, t('bg_board_dark', '#2D5016'));
woodGrad.addColorStop(1, t('bg_board_light', '#3D6B1E'));
ctx.fillStyle = woodGrad;
ctx.fillRect(margin, margin, boardW, boardH);
// Wood grain lines
ctx.globalAlpha = 0.06;
for (let i = 0; i < 30; i++) {
const gy = margin + (boardH / 30) * i + Math.sin(i * 0.7) * 3;
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.5 + Math.random() * 0.5;
ctx.beginPath();
ctx.moveTo(margin, gy);
ctx.bezierCurveTo(margin + boardW * 0.3, gy + 2, margin + boardW * 0.7, gy - 2, margin + boardW, gy);
ctx.stroke();
}
ctx.globalAlpha = 1;
// ── Bar ──
const barGrad = ctx.createLinearGradient(barX, 0, barX + barWidth, 0);
barGrad.addColorStop(0, t('bg_bar_dark', '#2A1800'));
barGrad.addColorStop(0.5, t('bg_bar_light', '#3D2B1A'));
barGrad.addColorStop(1, t('bg_bar_dark', '#2A1800'));
ctx.fillStyle = barGrad;
ctx.fillRect(barX, margin, barWidth, boardH);
// Bar edge shadows
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.fillRect(barX, margin, 1.5, boardH);
ctx.fillRect(barX + barWidth - 1.5, margin, 1.5, boardH);
// ── Bear-off tray ──
const bearX = margin + innerW;
ctx.fillStyle = t('bg_bearoff', '#1A0E00');
roundRect(ctx, bearX, margin, bearW, boardH, 3);
ctx.fill();
ctx.strokeStyle = 'rgba(139,69,19,0.4)';
ctx.lineWidth = 1;
roundRect(ctx, bearX, margin, bearW, boardH, 3);
ctx.stroke();
// Bear-off divider
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.moveTo(bearX, margin + boardH / 2);
ctx.lineTo(bearX + bearW, margin + boardH / 2);
ctx.stroke();
// ── Points (triangles) ──
for (let i = 0; i < 24; i++) {
const { x, y, isTop } = getPointCoords(i, margin, pointW, pointH, barWidth, boardH);
const isDark = i % 2 === 0;
const isValid = highlights.includes(i);
const isSel = selectedPoint === i;
ctx.beginPath();
if (isTop) {
ctx.moveTo(x, y);
ctx.lineTo(x + pointW, y);
ctx.lineTo(x + pointW / 2, y + pointH);
} else {
ctx.moveTo(x, y);
ctx.lineTo(x + pointW, y);
ctx.lineTo(x + pointW / 2, y - pointH);
}
ctx.closePath();
if (isSel) { function rgbOf(c) {
ctx.fillStyle = 'rgba(255, 215, 0, 0.35)'; const s = String(c || '').trim();
ctx.fill(); let m = s.match(/^#([0-9a-f]{3,8})$/i);
ctx.strokeStyle = '#FFD700'; if (m) {
ctx.lineWidth = 2; let h = m[1];
ctx.stroke(); if (h.length < 6) h = h.split('').map(x => x + x).join('');
} else if (isValid) { const n = parseInt(h.slice(0, 6), 16);
ctx.fillStyle = 'rgba(16, 185, 129, 0.35)'; return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
ctx.fill();
ctx.strokeStyle = '#10B981';
ctx.lineWidth = 2.5;
ctx.shadowColor = '#10B981';
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
} else {
const ptGrad = ctx.createLinearGradient(x, isTop ? y : y - pointH, x, isTop ? y + pointH : y);
if (isDark) {
ptGrad.addColorStop(0, t('bg_point_dark', '#8B4513'));
ptGrad.addColorStop(1, t('bg_point_dark_tip', '#5C2D0A'));
} else {
ptGrad.addColorStop(0, t('bg_point_light', '#D2691E'));
ptGrad.addColorStop(1, t('bg_point_light_tip', '#A0522D'));
}
ctx.fillStyle = ptGrad;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
ctx.lineWidth = 0.5;
ctx.stroke();
}
} }
m = s.match(/rgba?\(([^)]+)\)/i);
if (m) { const p = m[1].split(/[\s,/]+/).map(Number); return [p[0] | 0, p[1] | 0, p[2] | 0]; }
return [128, 128, 128];
}
export function withAlpha(c, a) { const [r, g, b] = rgbOf(c); return `rgba(${r},${g},${b},${a})`; }
/** Mix a colour toward white (k > 0) or black (k < 0). */
export function shade(c, k) {
const [r, g, b] = rgbOf(c);
const t = k > 0 ? 255 : 0, f = Math.abs(k);
return `rgb(${Math.round(r + (t - r) * f)},${Math.round(g + (t - g) * f)},${Math.round(b + (t - b) * f)})`;
}
// ── Point numbers ── // The palette, read from the tokens at most every two seconds.
ctx.font = `bold ${Math.max(8, pointW * 0.32)}px sans-serif`; const PAL = { at: 0, sig: '' };
ctx.textAlign = 'center'; export function palette() {
ctx.fillStyle = 'rgba(255,255,255,0.15)'; const now = performance.now();
for (let i = 0; i < 24; i++) { if (PAL.at && now - PAL.at < 2000) return PAL;
const { x, y, isTop } = getPointCoords(i, margin, pointW, pointH, barWidth, boardH); PAL.at = now;
const ny = isTop ? y - 6 : y + 10; const p = {
ctx.fillText(String(i + 1), x + pointW / 2, ny); card: tok('card', '#FFFFFF'),
} card3: tok('card_3', '#E8E5EF'),
cardEdge: tok('card_edge', '#CFC9DA'),
ink: tok('ink', '#1A1722'),
redInk: tok('red_ink', '#F0384B'),
gold: tok('gold', '#FFC531'),
goldSoft: tok('gold_soft', '#FFD866'),
goldDark: tok('gold_dark', '#D18D08'),
win: tok('bg_highlight', '#2ED47A'),
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'),
bar: tok('bg_bar_dark', '#14121A'),
tray: tok('bg_bearoff', '#1D1A25'),
pointRed: tok('bg_point_dark', '#FF4458'),
pointWhite: tok('bg_point_light', '#F4F2F8'),
white: tok('bg_checker_white', '#FFFFFF'),
black: tok('bg_checker_black', '#1A1722'),
};
const sig = Object.values(p).join('|');
if (sig !== PAL.sig) { Object.assign(PAL, p); PAL.sig = sig; }
return PAL;
}
// ── Checkers ── // ── Geometry ───────────────────────────────────────────────────────────────
for (let i = 0; i < 24; i++) {
const pieces = state.points[i]; /**
if (pieces.length === 0) continue; * The board's geometry for a canvas of w x h CSS pixels. flip: the viewer
const { x, y, isTop } = getPointCoords(i, margin, pointW, pointH, barWidth, boardH); * plays black, so the board is drawn from black's side.
const cx = x + pointW / 2; */
const maxVis = 5; export function computeLayout(w, h, flip = false) {
const count = pieces.length; const F = Math.max(10, Math.round(w * 0.034)); // the case
const vis = Math.min(count, maxVis); const T = Math.max(24, Math.round(w * 0.082)); // bear-off tray
const spacing = Math.min(checkerR * 2, pointH / vis); const D = Math.max(5, Math.round(w * 0.016)); // the case between field and tray
const B = Math.max(20, Math.round(w * 0.068)); // the bar
const pointIsSelected = selectedPoint === i; const pw = (w - 2 * F - D - T - B) / 12;
const pointIsValid = highlights.includes(i); const lip = Math.max(3, Math.round(F * 0.32)); // the case stands on a lip
const fieldTop = F, fieldBottom = h - F - lip, fieldH = fieldBottom - fieldTop;
const leftX = F, barX = F + 6 * pw, rightX = barX + B, fieldRight = rightX + 6 * pw;
const trayX = fieldRight + D;
const r = Math.max(8, Math.min(pw * 0.47, fieldH * 0.042));
return {
w, h, F, T, D, B, lip, pw, r, checkerR: r,
pointH: fieldH * 0.41,
fieldTop, fieldBottom, fieldH, leftX, barX, rightX, fieldRight, trayX,
midY: F + fieldH / 2,
flip: !!flip, viewer: flip ? BLACK : WHITE,
};
}
for (let j = 0; j < vis; j++) { /** A point's column: x (left edge), cx, whether it hangs from the top, its base y. */
const cy = isTop ? y + checkerR + j * spacing : y - checkerR - j * spacing; export function pointGeom(L, i) {
const isTopChecker = j === vis - 1; const d = L.flip ? 23 - i : i;
drawChecker(ctx, cx, cy, pieces[j].type, checkerR, { const top = d >= 12;
glow: pointIsSelected && isTopChecker, const col = top ? d - 12 : 11 - d;
pulse: pointIsValid && isTopChecker const x = col < 6 ? L.leftX + col * L.pw : L.rightX + (col - 6) * L.pw;
}); return { x, cx: x + L.pw / 2, top, baseY: top ? L.fieldTop : L.fieldBottom, dir: top ? 1 : -1 };
} }
if (count > maxVis) {
const ly = isTop ? y + checkerR + (vis - 1) * spacing : y - checkerR - (vis - 1) * spacing;
ctx.fillStyle = '#fff';
ctx.font = `bold ${checkerR * 0.65}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(count), cx, ly);
}
}
// ── Bar checkers ── /** Centre of the k-th checker (0 = at the base) on point i. Five show; the rest stack under a count. */
const barCx = barX + barWidth / 2; export function slotPos(L, i, k) {
drawBarCheckers(ctx, state.bar[WHITE], barCx, margin + boardH * 0.65, checkerR, WHITE, 1); const g = pointGeom(L, i);
drawBarCheckers(ctx, state.bar[BLACK], barCx, margin + boardH * 0.35, checkerR, BLACK, -1); const kk = Math.min(k, 4);
return { x: g.cx, y: g.baseY + g.dir * (L.r + 1.5 + kk * 2 * L.r) };
}
// ── Bear-off ── /** Centre of the k-th checker on the bar. The viewer's sit below the middle. */
drawBearOff(ctx, state.outside[WHITE], bearX, bearW, margin + boardH * 0.55, checkerR, WHITE, 1); export function barPos(L, type, k) {
drawBearOff(ctx, state.outside[BLACK], bearX, bearW, margin + boardH * 0.45, checkerR, BLACK, -1); const mine = type === L.viewer;
// clear of the cube, which rests on the middle of the bar
const off = Math.min(L.B - 2, 28) / 2 + 4 + L.r + Math.min(k, 3) * 2 * L.r;
return { x: L.barX + L.B / 2, y: L.midY + (mine ? off : -off) };
}
// Highlight bear-off if valid /** The k-th slice in a player's tray (borne-off checkers stand on edge). */
if (highlights.includes('off')) { export function trayPos(L, type, k) {
ctx.fillStyle = 'rgba(16, 185, 129, 0.25)'; const mine = type === L.viewer;
ctx.strokeStyle = '#10B981'; const sliceH = Math.min(L.r * 0.62, (L.fieldH / 2 - 14) / 15);
ctx.lineWidth = 2; const y = mine ? L.fieldBottom - 6 - (k + 0.5) * sliceH : L.fieldTop + 6 + (k + 0.5) * sliceH;
ctx.shadowColor = '#10B981'; return { x: L.trayX + L.T / 2, y, sliceH, w: L.T - 8 };
ctx.shadowBlur = 6; }
roundRect(ctx, bearX + 2, margin + 2, bearW - 4, boardH - 4, 3);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
}
// ── Particles ── /** Where a side's dice land: the viewer's in the right half, the opponent's in the left. */
if (particles.length) drawParticles(ctx, particles); export function diceCenter(L, type) {
const mine = type === L.viewer;
return { x: mine ? L.rightX + 3 * L.pw : L.leftX + 3 * L.pw, y: L.midY };
}
return layout; /** What a tap at (x, y) touches: a point, the bar or the tray. */
export function hitTest(x, y, L) {
if (!L || y < 0 || y > L.h) return null;
if (x >= L.barX && x < L.rightX) return { type: 'bar' };
if (x >= L.fieldRight) return { type: 'tray' };
let col;
if (x < L.barX) col = Math.max(0, Math.min(5, Math.floor((x - L.leftX) / L.pw)));
else col = 6 + Math.max(0, Math.min(5, Math.floor((x - L.rightX) / L.pw)));
const top = y < L.midY;
const d = top ? 12 + col : 11 - col;
return { type: 'point', index: L.flip ? 23 - d : d };
} }
function drawChecker(ctx, cx, cy, type, r, opts = {}) { // ── Offscreen caches ─────────────────────────────────────────────────────
const isWhite = type === WHITE; const boardCache = { canvas: null, key: '' };
const checkerCache = { key: '', white: null, black: null, size: 0 };
const dpr = () => Math.min(3, window.devicePixelRatio || 1);
function offscreen(w, h, k) {
const c = document.createElement('canvas');
c.width = Math.max(1, Math.round(w * k));
c.height = Math.max(1, Math.round(h * k));
const x = c.getContext('2d');
x.setTransform(k, 0, 0, k, 0, 0);
return { c, x };
}
// Drop shadow export function roundRect(ctx, x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx + 1, cy + 2, r, 0, Math.PI * 2); ctx.moveTo(x + rr, y);
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.fill(); ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// Base /** A point: a flat triangle with a softly rounded tip. */
ctx.beginPath(); function pointPath(g, L, p, inset = 0.07) {
ctx.arc(cx, cy, r, 0, Math.PI * 2); const tipY = p.baseY + p.dir * L.pointH;
const grad = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.3, r * 0.1, cx, cy, r); const i = L.pw * inset;
if (isWhite) { g.beginPath();
grad.addColorStop(0, '#FFFFFF'); g.moveTo(p.x + i, p.baseY);
grad.addColorStop(0.6, '#F5E6D3'); g.lineTo(p.x + L.pw - i, p.baseY);
grad.addColorStop(1, '#C8A882'); g.lineTo(p.cx + 2, tipY - p.dir * 4);
} else { g.quadraticCurveTo(p.cx, tipY, p.cx - 2, tipY - p.dir * 4);
grad.addColorStop(0, '#555555'); g.closePath();
grad.addColorStop(0.6, '#2A2A2A'); }
grad.addColorStop(1, '#0A0A0A');
function printBoard(L, P, k) {
const { c, x: g } = offscreen(L.w, L.h, k);
const { w, h, F, T, B, fieldTop, fieldH, leftX, barX, rightX, fieldRight, trayX } = L;
const R = Math.min(18, F * 1.4);
const lip = L.lip;
// The case on its lip
roundRect(g, 0, lip, w, h - lip, R);
g.fillStyle = P.lip;
g.fill();
roundRect(g, 0, 0, w, h - lip, R);
g.fillStyle = P.frame;
g.fill();
// The field: two flat teal halves, the home side a step deeper
const fieldB = fieldH;
const half = (x0, x1, col) => { roundRect(g, x0, fieldTop, x1 - x0, fieldB, 8); g.fillStyle = col; g.fill(); };
half(leftX, barX, P.fieldLight);
half(rightX, fieldRight, P.fieldDark);
// Points: red and white, flat
g.save();
g.beginPath(); g.rect(leftX, fieldTop, fieldRight - leftX, fieldB); g.clip();
for (let i = 0; i < 24; i++) {
const p = pointGeom(L, i);
const d = L.flip ? 23 - i : i;
pointPath(g, L, p);
g.fillStyle = d % 2 === 0 ? P.pointRed : P.pointWhite;
g.fill();
} }
ctx.fillStyle = grad; g.restore();
ctx.fill();
// The bar and the tray: ink, set into the case
roundRect(g, barX + 2, fieldTop, B - 4, fieldB, 6);
g.fillStyle = P.bar;
g.fill();
const tray = (y0, y1) => { roundRect(g, trayX, y0, T, y1 - y0, 7); g.fillStyle = P.tray; g.fill(); };
tray(fieldTop, L.midY - 3);
tray(L.midY + 3, fieldTop + fieldB);
return c;
}
// Edge ring // ── Checkers ───────────────────────────────────────────────────────────────
ctx.strokeStyle = isWhite ? 'rgba(160,120,60,0.5)' : 'rgba(100,100,100,0.5)';
ctx.lineWidth = 1.5; function checkerSprites(r, k, P) {
ctx.stroke(); const key = `${r}|${k}|${P.sig}`;
if (checkerCache.key === key) return checkerCache;
const pad = Math.ceil(r * 0.3) + 2;
const size = r * 2 + pad * 2;
checkerCache.white = paintChecker(r, pad, size, k, WHITE, P);
checkerCache.black = paintChecker(r, pad, size, k, BLACK, P);
checkerCache.size = size;
checkerCache.key = key;
return checkerCache;
}
// Inner decoration ring /** A flat disc on a darker lip of its own shape, with one flat ring for the groove. */
ctx.beginPath(); function paintChecker(r, pad, size, k, type, P) {
ctx.arc(cx, cy, r * 0.62, 0, Math.PI * 2); const { c, x: g } = offscreen(size, size, k);
ctx.strokeStyle = isWhite ? 'rgba(180,140,80,0.25)' : 'rgba(120,120,120,0.25)'; const cx = pad + r, cy = pad + r - r * 0.09;
ctx.lineWidth = 1; const white = type === WHITE;
ctx.stroke(); const face = white ? P.white : P.black;
const lipCol = white ? P.cardEdge : 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.fillStyle = lipCol;
g.fill();
g.beginPath(); g.arc(cx, cy, r * 0.98, 0, TAU);
g.fillStyle = face;
g.fill();
g.beginPath(); g.arc(cx, cy, r * 0.62, 0, TAU);
g.strokeStyle = ring;
g.lineWidth = Math.max(1.5, r * 0.16);
g.stroke();
return c;
}
// Specular highlight /** Draw one checker centred at (x, y). opts: lift (px), scale, alpha. */
export function drawChecker(ctx, x, y, type, r, opts = {}) {
const { lift = 0, scale = 1, alpha = 1 } = opts;
const P = palette();
const s = checkerSprites(r, dpr(), P);
const size = s.size * scale;
if (lift > 0) {
// A short soft shadow on the field below a lifted checker
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(cx - r * 0.2, cy - r * 0.25, r * 0.35, r * 0.2, -0.3, 0, Math.PI * 2); ctx.ellipse(x, y + r * 0.3, r * 0.95, r * 0.6, 0, 0, TAU);
ctx.fillStyle = `rgba(255,255,255,${isWhite ? 0.3 : 0.12})`; ctx.fillStyle = `rgba(0,0,0,${Math.max(0.08, 0.22 - lift * 0.006)})`;
ctx.fill(); ctx.fill();
// Glow for selected
if (opts.glow) {
ctx.beginPath();
ctx.arc(cx, cy, r + 3, 0, Math.PI * 2);
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2.5;
ctx.shadowColor = '#FFD700';
ctx.shadowBlur = 12;
ctx.stroke();
ctx.shadowBlur = 0;
} }
if (alpha < 1) ctx.globalAlpha = alpha;
ctx.drawImage(type === WHITE ? s.white : s.black, x - size / 2, y - lift - size / 2 + r * 0.09, size, size);
if (alpha < 1) ctx.globalAlpha = 1;
} }
function drawBarCheckers(ctx, pieces, cx, startY, r, type, dir) { /** Back-compat name used by older callers. */
const count = pieces.length; export function drawLiftedChecker(ctx, x, y, type, r, scale = 1) {
if (!count) return; drawChecker(ctx, x, y, type, r, { lift: 8, scale });
const vis = Math.min(count, 4);
for (let j = 0; j < vis; j++) {
drawChecker(ctx, cx, startY + dir * j * r * 1.9, type, r);
}
if (count > 4) {
ctx.fillStyle = '#fff';
ctx.font = `bold ${r * 0.7}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(count), cx, startY + dir * vis * r * 1.9);
}
} }
function drawBearOff(ctx, pieces, bearX, bearW, startY, r, type, dir) { function countBadge(ctx, n, x, y, r, type, P) {
const count = pieces.length; ctx.font = `800 ${Math.max(12, Math.round(r * 1.0))}px ${FONT}`;
if (!count) return;
const cx = bearX + bearW / 2;
const sliceH = Math.min(6, r * 0.4);
const vis = Math.min(count, 15);
const isWhite = type === WHITE;
for (let i = 0; i < vis; i++) {
const sy = startY + dir * i * sliceH;
ctx.fillStyle = isWhite ? '#F5E6D3' : '#2A2A2A';
ctx.fillRect(cx - r + 2, sy, (r - 2) * 2, sliceH - 1);
ctx.strokeStyle = isWhite ? 'rgba(160,120,60,0.3)' : 'rgba(80,80,80,0.3)';
ctx.lineWidth = 0.5;
ctx.strokeRect(cx - r + 2, sy, (r - 2) * 2, sliceH - 1);
}
ctx.fillStyle = '#fff';
ctx.font = `bold ${r * 0.7}px sans-serif`;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(String(count), cx, startY + dir * (vis * sliceH + r)); ctx.fillStyle = type === WHITE ? P.ink : P.card;
ctx.fillText(String(n), x, y + 1);
} }
function drawParticles(ctx, particles) { /** A small flat padlock on a pinned checker (mahbousa). */
for (const p of particles) { function lockMark(ctx, x, y, r, P) {
if (p.alpha <= 0) continue; const s = r * 0.5;
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color || '#FFD700';
if (p.shape === 'star') {
drawStar(ctx, p.x, p.y, p.size, p.rot || 0);
} else {
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
function drawStar(ctx, x, y, size, rotation) {
ctx.save(); ctx.save();
ctx.translate(x, y); ctx.translate(x, y);
ctx.rotate(rotation); ctx.strokeStyle = P.gold;
ctx.beginPath(); ctx.lineWidth = Math.max(1.6, r * 0.14);
for (let i = 0; i < 5; i++) { ctx.beginPath(); ctx.arc(0, -s * 0.15, s * 0.42, Math.PI, 0); ctx.stroke();
const a = (i * 4 * Math.PI) / 5 - Math.PI / 2; roundRect(ctx, -s * 0.62, -s * 0.15, s * 1.24, s * 0.95, 2.5);
ctx[i === 0 ? 'moveTo' : 'lineTo'](Math.cos(a) * size, Math.sin(a) * size); ctx.fillStyle = P.gold;
}
ctx.closePath();
ctx.fill(); ctx.fill();
ctx.fillStyle = P.goldDark;
ctx.beginPath(); ctx.arc(0, s * 0.3, s * 0.14, 0, TAU); ctx.fill();
ctx.restore(); ctx.restore();
} }
export function getPointCoords(pointIndex, margin, pointW, pointH, barWidth, boardH) { // ── The frame ──────────────────────────────────────────────────────────────
let x, y, isTop;
if (pointIndex >= 12) { /**
const col = pointIndex - 12; * Draw the board and its checkers.
x = margin + col * pointW + (col >= 6 ? barWidth : 0); * opts: highlights (point indices and/or 'off'), selected (point index or
y = margin; * 'bar'), movable (point indices / 'bar' that can move now), hidden (Set of
isTop = true; * checker ids not to draw: they are in flight), pulses (Map id -> start time,
} else { * a checker that just landed), time (ms), turn (the side to move).
const col = 11 - pointIndex; */
x = margin + col * pointW + (col >= 6 ? barWidth : 0); export function drawBoard(ctx, L, state, opts = {}) {
y = margin + boardH; const { highlights = [], selected = null, movable = [], hidden = null, pulses = null, time = performance.now(), turn = null } = opts;
isTop = false; const P = palette();
const k = dpr();
const key = `${L.w}x${L.h}|${k}|${L.flip}|${P.sig}`;
if (boardCache.key !== key) {
boardCache.canvas = printBoard(L, P, k);
boardCache.key = key;
}
ctx.clearRect(0, 0, L.w, L.h);
ctx.drawImage(boardCache.canvas, 0, 0, L.w, L.h);
const beat = 0.5 + 0.5 * Math.sin(time / 240);
// Legal destinations: the point lights up in flat green, pulsing
for (const hl of highlights) {
if (hl === 'off') continue;
const p = pointGeom(L, hl);
// Paint over the point's own colour first, so the green stays clean.
pointPath(ctx, L, p, 0.04);
const d = L.flip ? 23 - hl : hl;
const homeHalf = d < 6 || d >= 18; // the right half of the board
ctx.fillStyle = homeHalf ? P.fieldDark : P.fieldLight;
ctx.fill();
ctx.fillStyle = withAlpha(P.win, 0.7 + 0.3 * beat);
ctx.fill();
const n = state.points[hl].length;
const sp = slotPos(L, hl, n >= 5 ? 4 : n);
ctx.beginPath();
ctx.arc(sp.x, sp.y, L.r * 0.9, 0, TAU);
ctx.fillStyle = withAlpha(P.card, 0.35 + 0.3 * beat);
ctx.fill();
} }
return { x, y, isTop };
}
export function hitTest(canvasX, canvasY, layout, state) {
if (!layout) return null;
const { margin, pointW, pointH, barWidth, boardH, bearW, innerW, checkerR } = layout;
const barX = margin + 6 * pointW;
const bearX = margin + innerW;
if (canvasX >= barX && canvasX <= barX + barWidth) { // Checkers on the points
if (canvasY > margin + boardH / 2) return { type: 'bar', player: WHITE }; const sel = typeof selected === 'number' ? selected : null;
return { type: 'bar', player: BLACK }; for (let i = 0; i < 24; i++) {
const all = state.points[i];
const pieces = hidden && hidden.size ? all.filter(p => !hidden.has(p.id)) : all;
const n = pieces.length;
if (!n) continue;
// Bottom checkers as they are, the top one last; a tall stack shows five.
const vis = Math.min(n, 5);
const order = n <= 5 ? pieces : pieces.slice(0, 4).concat([pieces[n - 1]]);
for (let j = 0; j < vis; j++) {
const pc = order[j];
const sp = slotPos(L, i, j);
const isTop = j === vis - 1;
let scale = 1;
const t0 = pulses?.get(pc.id);
if (t0 !== undefined) {
const e = (time - t0) / 220;
if (e < 1) scale = 1 + 0.16 * Math.sin(Math.PI * e) * (1 - e);
} }
if (canvasX >= bearX && canvasX <= bearX + bearW) { const lifted = isTop && sel === i;
return { type: 'bearoff' }; if (isTop && movable.includes(i) && !lifted && sel === null) ring(ctx, sp.x, sp.y, L.r, withAlpha(P.selected, 0.35 + 0.55 * beat), 2.4);
drawChecker(ctx, sp.x, sp.y, pc.type, L.r, { lift: lifted ? 6 : 0, scale });
if (lifted) ring(ctx, sp.x, sp.y - 6, L.r, P.selected, 3);
// A pinned checker (under an opposing one)
if (j === 0 && n > 1 && pieces[0].type !== pieces[n - 1].type) lockMark(ctx, sp.x, sp.y, L.r, P);
if (isTop && n > 5) countBadge(ctx, n, sp.x, sp.y - (lifted ? 6 : 0), L.r, pc.type, P);
} }
for (let i = 0; i < 24; i++) {
const { x, y, isTop } = getPointCoords(i, margin, pointW, pointH, barWidth, boardH);
if (canvasX >= x && canvasX <= x + pointW) {
if (isTop && canvasY >= margin && canvasY <= margin + pointH + checkerR * 2) {
return { type: 'point', index: i };
} }
if (!isTop && canvasY >= margin + boardH - pointH - checkerR * 2 && canvasY <= margin + boardH) {
return { type: 'point', index: i }; // The bar
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;
for (let j = 0; j < Math.min(n, 4); j++) {
const bp = barPos(L, type, j);
const isTop = j === Math.min(n, 4) - 1;
const lifted = isTop && selected === 'bar' && type === turn;
if (isTop && movable.includes('bar') && type === turn && selected === null) ring(ctx, bp.x, bp.y, L.r, withAlpha(P.selected, 0.35 + 0.55 * beat), 2.4);
drawChecker(ctx, bp.x, bp.y, type, L.r, { lift: lifted ? 6 : 0 });
if (lifted) ring(ctx, bp.x, bp.y - 6, L.r, P.selected, 3);
if (isTop && n > 4) countBadge(ctx, n, bp.x, bp.y, L.r, type, P);
}
} }
// The trays
for (const type of [WHITE, BLACK]) {
const pieces = hidden && hidden.size ? state.outside[type].filter(p => !hidden.has(p.id)) : state.outside[type];
for (let j = 0; j < pieces.length; j++) drawSlice(ctx, L, type, j, P);
} }
if (highlights.includes('off')) {
const y0 = L.midY + 3, y1 = L.fieldBottom;
roundRect(ctx, L.trayX, y0, L.T, y1 - y0, 7);
ctx.fillStyle = withAlpha(P.win, 0.35 + 0.35 * beat);
ctx.fill();
ctx.strokeStyle = P.win;
ctx.lineWidth = 2.5;
ctx.stroke();
} }
return null; return L;
} }
export function getCheckerScreenPos(pointIndex, stackIdx, layout) { function ring(ctx, x, y, r, color, width) {
const { margin, pointW, pointH, barWidth, boardH, bearW, innerW, checkerR } = layout; ctx.beginPath();
const barX = margin + 6 * pointW; ctx.arc(x, y, r + 2.5, 0, TAU);
const bearX = margin + innerW; ctx.strokeStyle = color;
ctx.lineWidth = width;
if (pointIndex === 'bar-white') return { x: barX + barWidth / 2, y: margin + boardH * 0.65 + stackIdx * checkerR * 1.9 }; ctx.stroke();
if (pointIndex === 'bar-black') return { x: barX + barWidth / 2, y: margin + boardH * 0.35 - stackIdx * checkerR * 1.9 }; }
if (pointIndex === 'off') return { x: bearX + bearW / 2, y: margin + boardH / 2 };
const { x, y, isTop } = getPointCoords(pointIndex, margin, pointW, pointH, barWidth, boardH); /** One borne-off checker, standing on edge in its tray: a flat bar on a lip. */
const spacing = Math.min(checkerR * 2, pointH / 5); export function drawSlice(ctx, L, type, k, P = palette(), opts = {}) {
const cx = x + pointW / 2; const tp = trayPos(L, type, k);
const cy = isTop ? y + checkerR + stackIdx * spacing : y - checkerR - stackIdx * spacing; const w = tp.w * (opts.scale || 1), h = Math.max(3, tp.sliceH - 1.2);
return { x: cx, y: cy }; 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.fill();
roundRect(ctx, x, y, w, h, Math.min(3, h / 2));
ctx.fillStyle = white ? P.white : shade(P.black, 0.12);
ctx.fill();
} }
function roundRect(ctx, x, y, w, h, r) { /** Back-compat: the centre of a checker slot by name. */
ctx.beginPath(); export function getCheckerScreenPos(where, k, L) {
ctx.moveTo(x + r, y); if (where === 'bar-white') return barPos(L, WHITE, k);
ctx.arcTo(x + w, y, x + w, y + h, r); if (where === 'bar-black') return barPos(L, BLACK, k);
ctx.arcTo(x + w, y + h, x, y + h, r); if (where === 'off') return trayPos(L, L.viewer, k);
ctx.arcTo(x, y + h, x, y, r); return slotPos(L, where, k);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
} }
export function getPointCoords(i, L) { return pointGeom(L, i); }
// 3D dice renderer with physics bounce & spin animation // Dice, Night Arcade: flat white cubes with ink pips, standing on a lip. A
// throw comes in from the thrower's edge of the board, tumbling (faces
const DIE_SIZE = 38; // flicker, the cube spins and its lip deepens as it rolls), hops twice and
const DOT_RADIUS = 3.5; // settles on its value.
import { palette, roundRect } from './board-renderer.js';
export function drawDice(ctx, x, y, values, options = {}) {
const { used = [], rolling = false, frame = 0, totalFrames = 24 } = options; const TAU = Math.PI * 2;
for (let i = 0; i < values.length; i++) { /** Both players' dice are white; kept as a hook for a themed set. */
const dx = x + i * (DIE_SIZE + 12); export const skinFor = () => 'white';
const val = rolling ? Math.floor(Math.random() * 6) + 1 : values[i];
const isUsed = used.includes(i); function pipSpots(v) {
const a = 0.27, b = 0.5, c = 0.73;
// Stagger animation per die switch (v) {
const dieFrame = rolling ? Math.max(0, frame - i * 3) : 0; case 1: return [[b, b]];
drawSingleDie(ctx, dx, y, val, isUsed, dieFrame, totalFrames); case 2: return [[a, c], [c, a]];
case 3: return [[a, c], [b, b], [c, a]];
case 4: return [[a, a], [a, c], [c, a], [c, c]];
case 5: return [[a, a], [a, c], [b, b], [c, a], [c, c]];
default: return [[a, a], [a, b], [a, c], [c, a], [c, b], [c, c]];
} }
} }
function drawSingleDie(ctx, x, y, value, isUsed, frame, totalFrames) { /**
const t = frame > 0 ? frame / totalFrames : 0; * One die centred at (x, y). opts: rot (radians), scale, used (dimmed),
* lift (height above the field, px), roll (0..1, mid-tumble), alpha.
// Physics: bounce with damping */
const bounceY = frame > 0 ? -Math.abs(Math.sin(t * Math.PI * 3)) * 12 * (1 - t) : 0; export function drawDie(ctx, x, y, size, value, opts = {}) {
const rotation = frame > 0 ? t * Math.PI * 6 * (1 - t * 0.5) : 0; const { rot = 0, scale = 1, used = false, lift = 0, roll = 0, alpha = 1 } = opts;
const scale = frame > 0 ? 1 + Math.sin(t * Math.PI) * 0.15 : 1; const P = palette();
const s = size * scale * (used ? 0.86 : 1);
const r = s * 0.24;
const a = alpha * (used ? 0.38 : 1);
if (lift > 0.5) {
// A short soft shadow on the field while it is in the air
ctx.save(); ctx.save();
ctx.translate(x + DIE_SIZE / 2, y + DIE_SIZE / 2 + bounceY); ctx.globalAlpha = a;
ctx.rotate(rotation);
ctx.scale(scale, scale);
ctx.translate(-DIE_SIZE / 2, -DIE_SIZE / 2);
// Shadow (moves with bounce)
if (!isUsed) {
const shadowBlur = 6 + Math.abs(bounceY) * 0.5;
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = shadowBlur;
ctx.shadowOffsetY = 3 - bounceY * 0.3;
}
// Die body — 3D face with gradient
const bodyGrad = ctx.createLinearGradient(0, 0, DIE_SIZE, DIE_SIZE);
if (isUsed) {
bodyGrad.addColorStop(0, 'rgba(180, 180, 180, 0.35)');
bodyGrad.addColorStop(1, 'rgba(140, 140, 140, 0.25)');
} else {
bodyGrad.addColorStop(0, '#FFFEF5');
bodyGrad.addColorStop(0.4, '#FFF8E8');
bodyGrad.addColorStop(1, '#F0E0C0');
}
ctx.fillStyle = bodyGrad;
roundRect(ctx, 0, 0, DIE_SIZE, DIE_SIZE, 6);
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Border
ctx.strokeStyle = isUsed ? 'rgba(150,150,150,0.3)' : '#8B6914';
ctx.lineWidth = isUsed ? 1 : 1.5;
roundRect(ctx, 0, 0, DIE_SIZE, DIE_SIZE, 6);
ctx.stroke();
// Inner edge highlight (3D effect)
if (!isUsed) {
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1;
roundRect(ctx, 1.5, 1.5, DIE_SIZE - 3, DIE_SIZE - 3, 5);
ctx.stroke();
}
if (isUsed) ctx.globalAlpha = 0.35;
// Dots with slight shadow
const dotColor = isUsed ? '#666' : '#1A0800';
const positions = getDotPositions(value, DIE_SIZE);
for (const [dx, dy] of positions) {
if (!isUsed) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(dx + 0.5, dy + 0.5, DOT_RADIUS, 0, Math.PI * 2); ctx.ellipse(x, y + s * 0.42, s * (0.5 + lift * 0.006), s * 0.18, 0, 0, TAU);
ctx.fillStyle = 'rgba(0,0,0,0.15)'; ctx.fillStyle = `rgba(0,0,0,${Math.max(0.08, 0.24 - lift * 0.006)})`;
ctx.fill(); ctx.fill();
ctx.restore();
} }
ctx.save();
ctx.globalAlpha = a;
ctx.translate(x, y - lift);
ctx.rotate(rot);
const depth = s * (0.12 + 0.1 * Math.abs(Math.sin(roll * Math.PI)));
roundRect(ctx, -s / 2, -s / 2 + depth, s, s, r);
ctx.fillStyle = P.cardEdge;
ctx.fill();
roundRect(ctx, -s / 2, -s / 2, s, s, r);
ctx.fillStyle = P.card;
ctx.fill();
const pr = s * 0.09;
ctx.fillStyle = P.ink;
for (const [px, py] of pipSpots(value)) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(dx, dy, DOT_RADIUS, 0, Math.PI * 2); ctx.arc(-s / 2 + px * s, -s / 2 + py * s, pr, 0, TAU);
ctx.fillStyle = dotColor;
ctx.fill(); ctx.fill();
} }
ctx.restore(); ctx.restore();
} }
function getDotPositions(value, size) { /** Where n dice sit around a centre point. */
const c = size / 2; export function diceSpots(center, n, size) {
const q1 = size * 0.24; const gap = size * 0.24;
const q3 = size * 0.76; const total = n * size + (n - 1) * gap;
const out = [];
switch (value) { for (let i = 0; i < n; i++) out.push({ x: center.x - total / 2 + size / 2 + i * (size + gap), y: center.y });
case 1: return [[c, c]]; return out;
case 2: return [[q1, q3], [q3, q1]];
case 3: return [[q1, q3], [c, c], [q3, q1]];
case 4: return [[q1, q1], [q1, q3], [q3, q1], [q3, q3]];
case 5: return [[q1, q1], [q1, q3], [c, c], [q3, q1], [q3, q3]];
case 6: return [[q1, q1], [q1, c], [q1, q3], [q3, q1], [q3, c], [q3, q3]];
default: return [[c, c]];
}
} }
function roundRect(ctx, x, y, w, h, r) { const easeOut = (t) => 1 - Math.pow(1 - t, 3);
ctx.beginPath();
ctx.moveTo(x + r, y); /**
ctx.arcTo(x + w, y, x + w, y + h, r); * A throw of two dice: from `from` (the thrower's edge) to their resting
ctx.arcTo(x + w, y + h, x, y + h, r); * spots. frame(now) returns each die's pose; done(now) once they rest.
ctx.arcTo(x, y + h, x, y, r); * onBounce(i) fires when die i touches the field (for the clack).
ctx.arcTo(x, y, x + w, y, r); */
ctx.closePath(); export function createThrow({ values, from, spots, duration = 760, onBounce = null, now = performance.now() }) {
} const dice = values.map((v, i) => ({
value: v,
export function createRollAnimation(onFrame, onComplete) { from: { x: from.x + (i ? 1 : -1) * 18, y: from.y },
const totalFrames = 24; to: spots[i],
const duration = 700; spin: (i ? -1 : 1) * (2.2 + Math.random() * 1.4),
let startTime = null; delay: i * 70,
face: 1 + Math.floor(Math.random() * 6),
function tick(now) { lastFlip: 0,
if (!startTime) startTime = now; bounced: [false, false, false],
const elapsed = now - startTime; }));
const frame = Math.min(totalFrames, Math.floor((elapsed / duration) * totalFrames)); const start = now;
onFrame(frame, totalFrames); return {
duration: duration + 70,
if (elapsed < duration) { frame(t) {
requestAnimationFrame(tick); return dice.map((d, i) => {
} else { const p = Math.max(0, Math.min(1, (t - start - d.delay) / duration));
onComplete(); const e = easeOut(p);
} const x = d.from.x + (d.to.x - d.from.x) * e;
} const y = d.from.y + (d.to.y - d.from.y) * e;
requestAnimationFrame(tick); // Two hops, lower each time, then rest
const hop = Math.abs(Math.sin(p * Math.PI * 2.6)) * Math.pow(1 - p, 1.3);
const lift = hop * 26;
const contact = [0.385, 0.77, 0.99].findIndex((c, k) => p >= c && !d.bounced[k]);
if (contact >= 0) { d.bounced[contact] = true; onBounce?.(i, contact); }
if (p < 0.82 && t - d.lastFlip > 55) { d.face = 1 + Math.floor(Math.random() * 6); d.lastFlip = t; }
const value = p >= 0.82 ? d.value : d.face;
const rot = d.spin * (1 - e) * Math.PI * 1.5 + (p >= 1 ? 0 : 0);
const roll = p < 0.82 ? (p * 9) % 1 : 0;
const squash = lift < 1.5 && p < 0.98 && p > 0.05 ? 0.92 : 1;
return { x, y, lift, rot, value, roll, scale: (1 + lift * 0.006) * squash, p };
});
},
done(t) { return t - start >= this.duration; },
};
} }
// Arc-based move animator with overshoot bounce + particle burst // Flights for checkers: a moved checker is lifted, carried in an arc and set
import { getPointCoords } from './board-renderer.js'; // down; a hit checker is knocked off its point and tumbles to the bar; a
// borne-off checker flies into the tray and turns on edge as it drops in.
// The scene draws each pose; nothing here touches the game state.
const MOVE_DURATION = 320; const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
const HIT_DURATION = 420; const easeOut = (t) => 1 - Math.pow(1 - t, 3);
const BEAR_DURATION = 380;
export function createMoveAnimation(from, to, layout, onFrame, onComplete) { function readNumber(key, fallback) {
const startPos = getPixelPos(from, layout); const v = Number(window.__EL3AB_THEME?.[key]);
const endPos = getPixelPos(to, layout); return Number.isFinite(v) && v > 0 ? v : fallback;
if (!startPos || !endPos) { onComplete(); return; }
const startTime = performance.now();
const isHit = to === 'bar';
const isBear = to === 'off';
const duration = isHit ? HIT_DURATION : isBear ? BEAR_DURATION : MOVE_DURATION;
// Arc height proportional to distance
const dist = Math.hypot(endPos.x - startPos.x, endPos.y - startPos.y);
const arcHeight = Math.min(dist * 0.4, 60);
function tick(now) {
const elapsed = now - startTime;
const raw = Math.min(1, elapsed / duration);
// Ease: overshoot for moves, bounce for hits
const t = isHit ? easeOutBounce(raw) : easeOutBack(raw);
const x = startPos.x + (endPos.x - startPos.x) * t;
const y = startPos.y + (endPos.y - startPos.y) * t;
// Parabolic arc
const arcY = -arcHeight * 4 * raw * (1 - raw);
// Scale pulse on landing
const scale = raw > 0.85 ? 1 + (1 - raw) * 2 * 0.15 : 1;
onFrame({ x, y: y + arcY, progress: raw, scale, isLanding: raw > 0.95 });
if (raw < 1) {
requestAnimationFrame(tick);
} else {
onComplete();
}
}
requestAnimationFrame(tick);
} }
export function createHitAnimation(hitPos, onFrame, onComplete) { /** How long a move takes, from the branding admin (bg_move_duration), 260..600 ms. */
const startTime = performance.now(); export function moveDuration() { return Math.max(260, Math.min(600, readNumber('bg_move_duration', 340))); }
const duration = 300; function arcRatio() { return Math.max(0, Math.min(1, readNumber('bg_arc_height', 0.4))); }
function tick(now) { /**
const elapsed = now - startTime; * from/to: {x, y} canvas points. kind: 'move' | 'hit' | 'bear'.
const t = Math.min(1, elapsed / duration); * speed divides the duration (tests run fast).
const shake = Math.sin(t * Math.PI * 4) * (1 - t) * 6; */
const scale = 1 + Math.sin(t * Math.PI) * 0.3; export function createFlight({ from, to, kind = 'move', speed = 1, now = performance.now() }) {
onFrame({ shake, scale, progress: t }); const dist = Math.hypot(to.x - from.x, to.y - from.y);
if (t < 1) requestAnimationFrame(tick); const base = kind === 'hit' ? 380 : kind === 'bear' ? moveDuration() + 60 : moveDuration();
else onComplete(); const duration = (base + Math.min(160, dist * 0.35)) / speed;
} const arcH = kind === 'hit' ? Math.min(46, 18 + dist * 0.18) : Math.min(64, 10 + dist * arcRatio() * 0.5);
requestAnimationFrame(tick); const spin = kind === 'hit' ? (to.x > from.x ? 1 : -1) * Math.PI * 3 : 0;
return {
kind, duration, from, to,
pose(t) {
const p = Math.max(0, Math.min(1, (t - now) / duration));
const e = kind === 'hit' ? easeOut(p) : easeInOut(p);
const x = from.x + (to.x - from.x) * e;
const y = from.y + (to.y - from.y) * e;
const lift = arcH * 4 * p * (1 - p);
const scale = kind === 'bear' ? 1 - 0.25 * easeOut(p) : 1 + lift * 0.004;
return { x, y, lift, scale, rot: spin * (1 - e), p };
},
done(t) { return t - now >= duration; },
};
} }
export function createBearOffAnimation(pos, onFrame, onComplete) { /** Back-compat wrapper: animate from one point to another with callbacks. */
const startTime = performance.now(); export function createMoveAnimation(fromPos, toPos, _layout, onFrame, onComplete) {
const duration = 400; if (!fromPos || !toPos) { onComplete(); return; }
const f = createFlight({ from: fromPos, to: toPos });
function tick(now) { const tick = (t) => {
const elapsed = now - startTime; const ps = f.pose(t);
const t = Math.min(1, elapsed / duration); onFrame({ x: ps.x, y: ps.y - ps.lift, scale: ps.scale, progress: ps.p });
const ease = easeOutCubic(t); if (f.done(t)) onComplete(); else requestAnimationFrame(tick);
const scale = 1 - ease * 0.6; };
const alpha = 1 - ease;
const y = pos.y + ease * 30;
onFrame({ x: pos.x, y, scale, alpha, progress: t });
if (t < 1) requestAnimationFrame(tick);
else onComplete();
}
requestAnimationFrame(tick); requestAnimationFrame(tick);
} }
function getPixelPos(location, layout) {
if (!layout) return null;
const { margin, pointW, pointH, barWidth, boardH, bearW, innerW, checkerR } = layout;
const barX = margin + 6 * pointW;
const bearX = margin + innerW;
if (location === 'bar') return { x: barX + barWidth / 2, y: margin + boardH / 2 };
if (location === 'off') return { x: bearX + bearW / 2, y: margin + boardH / 2 };
if (typeof location === 'number' && location >= 0 && location <= 23) {
const { x, y, isTop } = getPointCoords(location, margin, pointW, pointH, barWidth, boardH);
return { x: x + pointW / 2, y: isTop ? y + checkerR * 2 : y - checkerR * 2 };
}
return null;
}
function easeOutBack(t) {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function easeOutBounce(t) {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) { t -= 1.5 / 2.75; return 7.5625 * t * t + 0.75; }
if (t < 2.5 / 2.75) { t -= 2.25 / 2.75; return 7.5625 * t * t + 0.9375; }
t -= 2.625 / 2.75;
return 7.5625 * t * t + 0.984375;
}
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
// Bot AI for Backgammon — 3 difficulty levels // Tawla bot. It looks at every distinct complete play for its roll and scores
import { getValidMoves, applyMove, cloneState, getPipCount, WHITE, BLACK } from './rules.js'; // the position each one leaves: the race, made points and primes, blots the
// opponent can reach, pins (mahbousa) and blocks (31). Easy plays any legal
export function getBotMove(game, difficulty = 'medium') { // play, medium scores with some noise, hard plays the best it sees.
const moves = getValidMoves(game); import { getTurnPlays, getValidMoves, variantInfo, WHITE, BLACK } from './rules.js';
if (moves.length === 0) return null;
const norm = (pos, type) => (type === WHITE ? pos : 23 - pos);
if (difficulty === 'easy') return getEasyMove(moves);
if (difficulty === 'hard') return getHardMove(game, moves); /** The whole turn the bot will play: { moves: [{from, steps, to, kind}] } or null. */
return getMediumMove(game, moves); export function getBotPlay(game, difficulty = 'medium') {
} const plays = getTurnPlays(game);
if (!plays.length) return null;
function getEasyMove(moves) { if (difficulty === 'easy') return plays[Math.floor(Math.random() * plays.length)];
return moves[Math.floor(Math.random() * moves.length)]; const capture = variantInfo(game.variant).capture;
} let best = plays[0], bestScore = -Infinity;
for (const p of plays) {
function getMediumMove(game, moves) { let s = evaluate(p.board, game.turn, capture);
let best = moves[0]; if (difficulty === 'medium') s += (Math.random() - 0.5) * 14;
let bestScore = -Infinity; if (s > bestScore) { bestScore = s; best = p; }
for (const move of moves) {
const score = scoreMoveSimple(game, move);
if (score > bestScore) {
bestScore = score;
best = move;
}
}
return best;
}
function getHardMove(game, moves) {
let best = moves[0];
let bestScore = -Infinity;
for (const move of moves) {
const score = scoreMoveAdvanced(game, move);
if (score > bestScore) {
bestScore = score;
best = move;
}
} }
return best; return best;
} }
function scoreMoveSimple(game, move) { /** One move, for callers that still play move by move. */
let score = 0; export function getBotMove(game, difficulty = 'medium') {
for (const action of move.actions) { const play = getBotPlay(game, difficulty);
if (action.type === 'hit') score += 30; if (!play?.moves.length) return null;
if (action.type === 'bear') score += 50; const first = play.moves[0];
} return getValidMoves(game).find(m => m.from === first.from && m.steps === first.steps) || null;
// Prefer moving from bar
if (move.from === 'bar') score += 20;
// Prefer making points (landing where we already have pieces)
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const destCount = game.state.points[move.to].filter(p => p.type === game.turn).length;
if (destCount === 1) score += 15; // making a point
}
// Avoid leaving blots
if (move.from >= 0 && move.from <= 23) {
const remaining = game.state.points[move.from].filter(p => p.type === game.turn).length - 1;
if (remaining === 1) score -= 10;
}
return score;
} }
function scoreMoveAdvanced(game, move) { function pips(b, type) {
let score = 0; let n = 0;
const type = game.turn; for (let i = 0; i < 24; i++) for (const t of b.pts[i]) if (t === type) n += norm(i, type) + 1;
const opp = type === WHITE ? BLACK : WHITE; return n + b.bar[type] * 25;
// Hit value — sending opponent back is huge
for (const action of move.actions) {
if (action.type === 'hit') score += 40;
if (action.type === 'bear') score += 60;
}
// Blot safety — penalize leaving blots
if (move.from >= 0 && move.from <= 23) {
const remaining = game.state.points[move.from].filter(p => p.type === type).length - 1;
if (remaining === 1) {
// How exposed is this blot?
const exposure = calculateBlotExposure(game.state, move.from, opp);
score -= exposure * 8;
}
}
// Point-making — landing to create a 2+ stack
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const friendsAtDest = game.state.points[move.to].filter(p => p.type === type).length;
if (friendsAtDest === 1) score += 20; // making a new point
if (friendsAtDest >= 2) score += 5; // adding to existing point
}
// Prime building — consecutive blocked points
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
score += primeBonus(game.state, move.to, type) * 8;
}
// Anchor value — holding points deep in opponent's board
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const norm = type === WHITE ? move.to : 23 - move.to;
if (norm >= 18) score += 12; // anchor in opponent's home
}
// Race — prefer bearing off and advancing when ahead
const myPips = getPipCount(game.state, type, game.variant);
const oppPips = getPipCount(game.state, opp, game.variant);
if (myPips < oppPips) {
score += move.steps * 2; // racing advantage, advance further
}
// Advancing runners from deep position
if (move.from >= 0 && move.from <= 23) {
const normFrom = type === WHITE ? move.from : 23 - move.from;
if (normFrom >= 20) score += 8; // escape back runners
}
return score;
} }
function calculateBlotExposure(state, pos, opponentType) { /** Rough count of rolls that bring an opposing checker onto point i. */
let threats = 0; function shotsAt(b, i, opp) {
for (let dist = 1; dist <= 6; dist++) { let shots = 0;
const fromPos = opponentType === WHITE ? pos + dist : pos - dist; for (let d = 1; d <= 12; d++) {
if (fromPos < 0 || fromPos > 23) continue; const j = opp === WHITE ? i + d : i - d; // where an opposing checker would come from
if (state.points[fromPos].some(p => p.type === opponentType)) { let src = false;
threats++; if (j >= 0 && j <= 23) {
} const pt = b.pts[j];
} src = pt.length && pt[pt.length - 1] === opp;
// Also count bar pieces as threats } else if ((opp === WHITE && j === 24) || (opp === BLACK && j === -1)) {
if (state.bar[opponentType].length > 0) { src = b.bar[opp] > 0;
const barEntry = opponentType === WHITE ? (23 - pos) : pos; }
if (barEntry >= 1 && barEntry <= 6) threats += state.bar[opponentType].length; if (src) shots += d <= 6 ? 1 : 0.35;
} }
return threats; return shots;
} }
function primeBonus(state, point, type) { function evaluate(b, me, capture) {
let consecutive = 0; const opp = me === WHITE ? BLACK : WHITE;
for (let i = point - 3; i <= point + 3; i++) { const myPips = pips(b, me), oppPips = pips(b, opp);
if (i < 0 || i > 23) continue; let s = (oppPips - myPips) * 1.0 + (b.off[me] - b.off[opp]) * 4;
if (state.points[i].filter(p => p.type === type).length >= 2) {
consecutive++; // The opponent's rearmost checker: points beyond it no longer block anything.
} else { let oppBack = -1;
if (i > point) break; for (let i = 0; i < 24; i++) if (b.pts[i].includes(opp)) oppBack = Math.max(oppBack, norm(i, opp));
consecutive = 0; if (b.bar[opp]) oppBack = 24;
} let myBack = -1;
} for (let i = 0; i < 24; i++) if (b.pts[i].includes(me)) myBack = Math.max(myBack, norm(i, me));
return Math.max(0, consecutive - 1); if (b.bar[me]) myBack = 24;
const contact = myBack + oppBack >= 23;
let run = 0, bestRun = 0;
for (let n = 23; n >= 0; n--) {
const i = me === WHITE ? n : 23 - n;
const pt = b.pts[i];
const top = pt.length ? pt[pt.length - 1] : null;
const mine = pt.filter(t => t === me).length;
// In front of the opponent's checkers (from its side), a point blocks it.
const blocks = 23 - n <= oppBack;
const held = top === me && (capture === 'block' ? mine >= 1 : mine >= 2);
if (held && blocks && contact) {
run++; bestRun = Math.max(bestRun, run);
s += n <= 5 ? 5 + (n >= 3 ? 2 : 0) : n >= 18 ? 3 : 3;
} else if (!(top === me && mine >= 1 && capture === 'block')) {
run = 0;
}
if (contact && capture !== 'block' && top === me && pt.length === 1) {
const shots = shotsAt(b, i, opp);
if (shots) s -= capture === 'hit' ? shots * (2.2 + (23 - n) * 0.18) : shots * (3 + (23 - n) * 0.22);
}
if (capture === 'pin' && pt.length > 1 && pt[0] !== top) {
const depth = norm(i, pt[0]) + 1; // how far the pinned checker still has to go
const v = 12 + depth * 0.5;
s += top === me ? v : -v;
}
}
if (contact) s += bestRun * bestRun * 1.6;
s += (b.bar[opp] - b.bar[me]) * 10;
return s;
} }
// Doubling Cube logic for Backgammon match play // The doubling cube. Centred, either player may double before rolling; once
// taken, only its owner may redouble. A dropped double loses the game at the
// cube's current value.
import { WHITE, BLACK } from './rules.js'; import { WHITE, BLACK } from './rules.js';
export function createCube() { export function createCube() {
return { return {
value: 1, value: 1,
owner: null, // null = center (either can double), WHITE or BLACK = only that player can owner: null, // null = centre; WHITE or BLACK = only that player may double
offered: false, offered: false,
offeredBy: null offeredBy: null,
}; };
} }
export function canDouble(cube, playerType) { export function canDouble(cube, playerType) {
if (cube.offered) return false; if (!cube || cube.offered) return false;
if (cube.owner === null) return true; if (cube.value >= 64) return false;
return cube.owner === playerType; return cube.owner === null || cube.owner === playerType;
} }
export function offerDouble(cube, playerType) { export function offerDouble(cube, playerType) {
...@@ -24,7 +26,7 @@ export function offerDouble(cube, playerType) { ...@@ -24,7 +26,7 @@ export function offerDouble(cube, playerType) {
} }
export function acceptDouble(cube) { export function acceptDouble(cube) {
if (!cube.offered) return false; if (!cube?.offered) return false;
cube.value *= 2; cube.value *= 2;
cube.owner = cube.offeredBy === WHITE ? BLACK : WHITE; cube.owner = cube.offeredBy === WHITE ? BLACK : WHITE;
cube.offered = false; cube.offered = false;
...@@ -32,33 +34,35 @@ export function acceptDouble(cube) { ...@@ -32,33 +34,35 @@ export function acceptDouble(cube) {
return true; return true;
} }
/** Returns the side that declined (and so loses the game). */
export function declineDouble(cube) { export function declineDouble(cube) {
if (!cube.offered) return false; if (!cube?.offered) return null;
cube.offered = false;
const decliner = cube.offeredBy === WHITE ? BLACK : WHITE; const decliner = cube.offeredBy === WHITE ? BLACK : WHITE;
cube.offered = false;
cube.offeredBy = null; cube.offeredBy = null;
return decliner; // returns who declined (and thus loses) return decliner;
} }
export function getStake(cube, gameScore) { export function getStake(cube, gameScore) {
return cube.value * gameScore; return (cube ? cube.value : 1) * gameScore;
} }
// Bot doubling decision based on pip count advantage /**
export function shouldBotDouble(cube, myPips, oppPips, difficulty) { * The bot doubles when it leads the race clearly. myPips/oppPips are from
if (!canDouble(cube, BLACK)) return false; * the bot's side. Easy never doubles.
*/
const advantage = (oppPips - myPips) / oppPips; export function shouldBotDouble(cube, myPips, oppPips, difficulty, botType = BLACK) {
if (!canDouble(cube, botType)) return false;
if (difficulty === 'easy') return false; if (difficulty === 'easy') return false;
const advantage = (oppPips - myPips) / Math.max(1, oppPips);
if (difficulty === 'medium') return advantage > 0.25; if (difficulty === 'medium') return advantage > 0.25;
return advantage > 0.15; // hard bot doubles more aggressively return advantage > 0.15;
} }
/** Take or drop: myPips/oppPips are from the bot's side. Easy always takes. */
export function shouldBotAccept(cube, myPips, oppPips, difficulty) { export function shouldBotAccept(cube, myPips, oppPips, difficulty) {
const disadvantage = (myPips - oppPips) / myPips; if (difficulty === 'easy') return true;
const disadvantage = (myPips - oppPips) / Math.max(1, myPips);
if (difficulty === 'easy') return true; // easy bot always accepts
if (difficulty === 'medium') return disadvantage < 0.35; if (difficulty === 'medium') return disadvantage < 0.35;
return disadvantage < 0.25; // hard bot drops when too far behind return disadvantage < 0.25;
} }
// Match system — first-to-N points, Crawford rule // A match: first to N points, the doubling cube, and the Crawford rule (no
import { WHITE, BLACK, createGame, getGameScore } from './rules.js'; // cube in the game right after someone reaches match point).
import { WHITE, BLACK, createGame } from './rules.js';
import { createCube } from './doubling.js'; import { createCube } from './doubling.js';
export function createMatch(matchLength = 5, variant = 'sheshbesh') { export function createMatch(matchLength = 3, variant = 'sheshbesh', useCube = true) {
return { return {
length: matchLength, length: Math.max(1, Number(matchLength) || 1),
variant, variant,
useCube: !!useCube,
scores: [0, 0], // [WHITE, BLACK] scores: [0, 0], // [WHITE, BLACK]
gameNumber: 0, gameNumber: 0,
currentGame: null, currentGame: null,
cube: null, cube: null,
crawford: false, crawford: false,
crawfordUsed: false, crawfordUsed: false,
postCrawford: false,
isOver: false, isOver: false,
winner: null, winner: null,
postCrawford: false lastResult: null,
}; };
} }
...@@ -22,55 +25,60 @@ export function startNewGame(match) { ...@@ -22,55 +25,60 @@ export function startNewGame(match) {
match.gameNumber++; match.gameNumber++;
match.currentGame = createGame(match.variant); match.currentGame = createGame(match.variant);
// Crawford rule: no doubling in the game immediately after someone reaches match-point - 1
const atMatchPoint = match.scores[WHITE] === match.length - 1 || match.scores[BLACK] === match.length - 1; const atMatchPoint = match.scores[WHITE] === match.length - 1 || match.scores[BLACK] === match.length - 1;
if (atMatchPoint && !match.crawfordUsed) { if (atMatchPoint && !match.crawfordUsed) {
match.crawford = true; match.crawford = true;
match.crawfordUsed = true; match.crawfordUsed = true;
match.cube = null;
} else { } else {
match.crawford = false; match.crawford = false;
if (match.crawfordUsed) match.postCrawford = true; if (match.crawfordUsed) match.postCrawford = true;
match.cube = createCube();
} }
match.cube = match.useCube && !match.crawford ? createCube() : null;
// Alternate who goes first each game (simplified — real backgammon uses highest die) // White opens the first game; after that the games alternate.
if (match.gameNumber > 1) {
match.currentGame.turn = match.gameNumber % 2 === 0 ? BLACK : WHITE; match.currentGame.turn = match.gameNumber % 2 === 0 ? BLACK : WHITE;
}
return match.currentGame; return match.currentGame;
} }
/** Score a finished game. gameScore is the game's own value, before the cube. */
export function endGame(match, winner, gameScore) { export function endGame(match, winner, gameScore) {
const stake = match.cube ? match.cube.value * gameScore : gameScore; const stake = (match.cube ? match.cube.value : 1) * gameScore;
match.scores[winner] += stake; match.scores[winner] += stake;
if (match.scores[winner] >= match.length) { if (match.scores[winner] >= match.length) {
match.isOver = true; match.isOver = true;
match.winner = winner; match.winner = winner;
} }
match.lastResult = { winner, gameScore, stake, scores: [...match.scores], matchOver: match.isOver };
return { return match.lastResult;
winner,
gameScore,
stake,
scores: [...match.scores],
matchOver: match.isOver
};
} }
export function canUseCube(match) { export function canUseCube(match) {
return !match.crawford && match.cube !== null; return !match.crawford && match.cube !== null && !match.isOver;
} }
export function getMatchState(match) { export function getMatchState(match) {
return { return {
length: match.length, length: match.length,
variant: match.variant,
useCube: match.useCube,
scores: [...match.scores], scores: [...match.scores],
gameNumber: match.gameNumber, gameNumber: match.gameNumber,
crawford: match.crawford, crawford: match.crawford,
crawfordUsed: match.crawfordUsed,
postCrawford: match.postCrawford,
isOver: match.isOver, isOver: match.isOver,
winner: match.winner winner: match.winner,
cube: match.cube ? { ...match.cube } : null,
}; };
} }
/** Restore a match from getMatchState() output (multiplayer sync). */
export function applyMatchState(match, ms) {
if (!ms) return match;
for (const k of ['length', 'variant', 'useCube', 'gameNumber', 'crawford', 'crawfordUsed', 'postCrawford', 'isOver', 'winner']) {
if (ms[k] !== undefined) match[k] = ms[k];
}
if (Array.isArray(ms.scores)) match.scores = [...ms.scores];
match.cube = ms.cube ? { ...ms.cube } : null;
return match;
}
// Backgammon rules engine — ported from backgammonjs-master/lib/ // Tawla rules: shesh besh, mahbousa and 31.
// Supports Standard, Gul Bara, and Tapa variants //
// The board is 24 points, index 0..23. White moves 23 -> 0 and bears off past
// 0 (home board 0..5); Black moves 0 -> 23 and bears off past 23 (home 18..23).
// "norm" is a point seen from its owner: 0 is the ace point, 23 the far end.
//
// - shesh besh: backgammon. A lone checker (a blot) can be hit and sent to the
// bar; a checker on the bar must come back in before anything else moves.
// Gammon 2, backgammon 3.
// - mahbousa: everyone starts with 15 on the far point. Landing on a lone
// 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.
//
// 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
// the highest occupied point. Legality is decided over the whole turn: a move
// is legal only if the rest of the dice can still be played after it.
export const WHITE = 0; export const WHITE = 0;
export const BLACK = 1; export const BLACK = 1;
...@@ -8,160 +26,52 @@ export const MOVE = 'move'; ...@@ -8,160 +26,52 @@ export const MOVE = 'move';
export const RECOVER = 'recover'; export const RECOVER = 'recover';
export const HIT = 'hit'; export const HIT = 'hit';
export const BEAR = 'bear'; export const BEAR = 'bear';
export const PIN = 'pin';
// ===== STATE ===== const FULL_SETUP = [[WHITE, 23, 2], [WHITE, 12, 5], [WHITE, 7, 3], [WHITE, 5, 5],
[BLACK, 0, 2], [BLACK, 11, 5], [BLACK, 16, 3], [BLACK, 18, 5]];
const STACK_SETUP = [[WHITE, 23, 15], [BLACK, 0, 15]];
export function createState() { const VARIANTS = {
const points = []; sheshbesh: { capture: 'hit', setup: FULL_SETUP, scoring: 'backgammon', lengths: [1, 3, 5, 7], defaultLength: 3 },
for (let i = 0; i < 24; i++) points.push([]); mahbousa: { capture: 'pin', setup: STACK_SETUP, scoring: 'gammon', lengths: [1, 3, 5, 7], defaultLength: 3 },
return { thirtyone: { capture: 'block', setup: STACK_SETUP, scoring: 'checkers', lengths: [1, 11, 21, 31], defaultLength: 31 },
points, };
bar: [[], []],
outside: [[], []],
nextId: 1
};
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
function place(state, count, type, position) {
for (let i = 0; i < count; i++) {
const piece = { type, id: state.nextId++ };
state.points[position].push(piece);
}
}
function countAtPos(state, position, type) {
return state.points[position].filter(p => p.type === type).length;
}
function countAllAtPos(state, position) {
return state.points[position].length;
}
function getTopPiece(state, position) { export function getVariants() { return Object.keys(VARIANTS); }
const pt = state.points[position];
return pt.length > 0 ? pt[pt.length - 1] : null;
}
function getPiecePos(state, piece) { /** What a variant does: capture ('hit' | 'pin' | 'block'), scoring, match lengths. */
for (let i = 0; i < state.points.length; i++) { export function variantInfo(variant) {
for (let k = 0; k < state.points[i].length; k++) { const v = VARIANTS[variant] || VARIANTS.sheshbesh;
if (state.points[i][k].id === piece.id) return i; return { capture: v.capture, scoring: v.scoring, lengths: v.lengths.slice(), defaultLength: v.defaultLength };
}
}
return null;
} }
function isPieceOnBar(state, piece) { const ruleOf = (variant) => VARIANTS[variant] || VARIANTS.sheshbesh;
return state.bar[piece.type].some(p => p.id === piece.id); const norm = (pos, type) => (type === WHITE ? pos : 23 - pos);
} const other = (type) => (type === WHITE ? BLACK : WHITE);
function isPieceOutside(state, piece) { // ── State ──────────────────────────────────────────────────────────────────
return state.outside[piece.type].some(p => p.id === piece.id); // points[i] is a stack of { id, type }, bottom first. bar[type] and
} // outside[type] hold that player's checkers on the bar and borne off.
function havePiecesOnBar(state, type) { export function createState() {
return state.bar[type].length > 0; const points = [];
for (let i = 0; i < 24; i++) points.push([]);
return { points, bar: [[], []], outside: [[], []], nextId: 1 };
} }
function getBarTopPiece(state, type) { export function cloneState(state) {
const bar = state.bar[type]; return JSON.parse(JSON.stringify(state));
return bar.length > 0 ? bar[bar.length - 1] : null;
} }
// ===== VARIANTS ===== function place(state, type, pos, count) {
for (let i = 0; i < count; i++) state.points[pos].push({ type, id: state.nextId++ });
const VARIANTS = {
sheshbesh: {
name: 'شيش بيش',
nameEn: 'Shesh Besh',
allowHit: true,
resetState(state) {
place(state, 5, WHITE, 5);
place(state, 3, WHITE, 7);
place(state, 5, WHITE, 12);
place(state, 2, WHITE, 23);
place(state, 5, BLACK, 18);
place(state, 3, BLACK, 16);
place(state, 5, BLACK, 11);
place(state, 2, BLACK, 0);
},
incPos(pos, type, steps) {
return type === WHITE ? pos - steps : pos + steps;
},
normPos(pos, type) {
return type === BLACK ? 23 - pos : pos;
},
denormPos(pos, type) {
return type === BLACK ? 23 - pos : pos;
}
},
mahbousa: {
name: 'محبوسة',
nameEn: 'Mahbousa',
allowHit: false,
resetState(state) {
place(state, 15, WHITE, 23);
place(state, 15, BLACK, 0);
},
incPos(pos, type, steps) {
return type === WHITE ? pos - steps : pos + steps;
},
normPos(pos, type) {
return type === BLACK ? 23 - pos : pos;
},
denormPos(pos, type) {
return type === BLACK ? 23 - pos : pos;
}
},
thirtyone: {
name: '٣١',
nameEn: '31',
allowHit: false,
resetState(state) {
place(state, 15, WHITE, 23);
place(state, 15, BLACK, 11);
},
incPos(pos, type, steps) {
if (type === WHITE) return pos - steps;
let np = pos - steps;
if (pos < 12 && np < 0) np = 24 + np;
else if (pos >= 12 && np <= 11) np = np - 12;
return np;
},
normPos(pos, type) {
if (type === WHITE) return pos;
if (pos < 0) return pos;
if (pos >= 12) return pos - 12;
return pos + 12;
},
denormPos(pos, type) {
if (type === WHITE) return pos;
if (pos < 0) return pos;
if (pos >= 12) return pos - 12;
return pos + 12;
}
}
};
export function getVariants() {
return Object.keys(VARIANTS).map(k => ({ key: k, name: VARIANTS[k].name }));
} }
// ===== GAME =====
export function createGame(variant = 'sheshbesh') { export function createGame(variant = 'sheshbesh') {
const rule = VARIANTS[variant] || VARIANTS.sheshbesh; if (!VARIANTS[variant]) variant = 'sheshbesh';
if (!VARIANTS[variant]) {
console.warn(`[backgammon] Unknown variant "${variant}", falling back to sheshbesh`);
variant = 'sheshbesh';
}
const state = createState(); const state = createState();
rule.resetState(state); for (const [type, pos, n] of VARIANTS[variant].setup) place(state, type, pos, n);
return { return {
variant, variant,
state, state,
...@@ -172,434 +82,381 @@ export function createGame(variant = 'sheshbesh') { ...@@ -172,434 +82,381 @@ export function createGame(variant = 'sheshbesh') {
turnNumber: 0, turnNumber: 0,
isOver: false, isOver: false,
winner: null, winner: null,
score: 0 score: 0,
endReason: null,
}; };
} }
export function rollDice(game) { /**
const d1 = Math.floor(Math.random() * 6) + 1; * Lay out any position (tests, and restoring a game). spec.points maps a point
const d2 = Math.floor(Math.random() * 6) + 1; * index to 'w5' / 'b2' (five white, two black), or to the checkers bottom
const values = [d1, d2].sort((a, b) => b - a); * first, 'bww' (a black checker pinned under two white). bar/off: [white, black].
*/
let moves = [...values]; export function setPosition(game, spec) {
if (d1 === d2) moves = [d1, d1, d1, d1]; const state = createState();
for (const [k, v] of Object.entries(spec.points || {})) {
const playable = calculatePlayableMoves(game.state, moves, game.turn, game.variant); const i = Number(k);
const m = /^([wb])(\d+)$/.exec(v);
game.dice = values; if (m) place(state, m[1] === 'w' ? WHITE : BLACK, i, Number(m[2]));
game.movesLeft = [...playable]; else for (const ch of v) place(state, ch === 'w' ? WHITE : BLACK, i, 1);
game.movesPlayed = []; }
return { values, moves: playable }; for (const type of [WHITE, BLACK]) {
} for (let k = 0; k < (spec.bar?.[type] || 0); k++) state.bar[type].push({ type, id: state.nextId++ });
for (let k = 0; k < (spec.off?.[type] || 0); k++) state.outside[type].push({ type, id: state.nextId++ });
export function getValidMoves(game) { }
if (!game.dice || game.movesLeft.length === 0) return []; game.state = state;
if (spec.turn !== undefined) game.turn = spec.turn;
const rule = VARIANTS[game.variant];
const state = game.state;
const type = game.turn;
const moves = [];
const uniqueSteps = [...new Set(game.movesLeft)];
for (const steps of uniqueSteps) {
const pieces = getMovablePieces(state, type, rule);
for (const piece of pieces) {
const actions = getMoveActions(state, piece, steps, rule);
if (actions.length > 0) {
if (!isMoveRestricted(state, game.movesLeft, piece, steps, type, rule)) {
const dest = getDestination(state, piece, steps, rule);
moves.push({ piece, steps, actions, from: getPiecePos(state, piece), to: dest });
}
}
}
}
return moves;
}
export function applyMove(game, pieceId, steps) {
const rule = VARIANTS[game.variant];
const state = game.state;
const piece = findPiece(state, pieceId, game.turn);
if (!piece) return null;
const actions = getMoveActions(state, piece, steps, rule);
if (actions.length === 0) return null;
applyActions(state, actions, rule);
const idx = game.movesLeft.indexOf(steps);
if (idx >= 0) {
game.movesLeft.splice(idx, 1);
game.movesPlayed.push(steps);
}
if (hasWon(state, game.turn)) {
game.isOver = true;
game.winner = game.turn;
game.score = getGameScore(state, game.turn, rule);
}
return actions;
}
export function nextTurn(game) {
game.turn = game.turn === WHITE ? BLACK : WHITE;
game.dice = null; game.dice = null;
game.movesLeft = []; game.movesLeft = [];
game.movesPlayed = []; game.movesPlayed = [];
game.turnNumber++; game.isOver = false;
game.winner = null;
game.score = 0;
game.endReason = null;
return game;
} }
export function hasWon(state, type) { // ── A compact board for move generation ───────────────────────────────────
return state.outside[type].length === 15; // pts[i] is a list of types bottom first; bar and off are counts.
}
export function getGameScore(state, winner, rule) { function compact(state) {
const opp = winner === WHITE ? BLACK : WHITE; return {
if (state.outside[opp].length === 0) { pts: state.points.map(p => p.map(c => c.type)),
const r = rule || VARIANTS.sheshbesh; bar: [state.bar[0].length, state.bar[1].length],
if (countAtHigherPos(state, 18, opp, r) > 0 || havePiecesOnBar(state, opp)) { off: [state.outside[0].length, state.outside[1].length],
return 3; // backgammon };
}
return 2; // gammon
}
return 1; // normal
} }
export function getPipCount(state, type, variant = 'sheshbesh') { function keyOf(b) {
const rule = VARIANTS[variant] || VARIANTS.sheshbesh; let k = '';
let pips = 0; for (let i = 0; i < 24; i++) k += b.pts[i].join('') + ',';
for (let i = 0; i < 24; i++) { return k + b.bar[0] + '.' + b.bar[1] + '.' + b.off[0] + '.' + b.off[1];
const count = countAtPos(state, i, type);
if (count > 0) {
const norm = rule.normPos(i, type);
pips += count * (norm + 1);
}
}
pips += state.bar[type].length * 25;
return pips;
} }
// ===== INTERNAL HELPERS ===== function allHome(b, type) {
if (b.bar[type]) return false;
function findPiece(state, pieceId, type) {
for (let i = 0; i < 24; i++) { for (let i = 0; i < 24; i++) {
for (const p of state.points[i]) { if (norm(i, type) >= 6 && b.pts[i].includes(type)) return false;
if (p.id === pieceId && p.type === type) return p;
} }
} return true;
for (const p of state.bar[type]) {
if (p.id === pieceId) return p;
}
return null;
} }
function getMovablePieces(state, type, rule) { /** Can `type` land on point `dest`? 'move', 'hit', 'pin' or null (blocked). */
if (havePiecesOnBar(state, type)) { function landing(b, dest, type, capture) {
const top = getBarTopPiece(state, type); const pt = b.pts[dest];
return top ? [top] : []; if (!pt.length) return MOVE;
} if (pt[pt.length - 1] === type) return MOVE;
const pieces = []; if (pt.length > 1) return null;
for (let i = 0; i < 24; i++) { if (capture === 'hit') return HIT;
const pt = state.points[i]; if (capture === 'pin') return PIN;
if (pt.length > 0 && pt[pt.length - 1].type === type) { return null;
pieces.push(pt[pt.length - 1]);
}
}
return pieces;
} }
function getMoveActions(state, piece, steps, rule) { /** Where one die takes a checker from `from` ('bar' or a point), or null. */
const actions = []; function stepTarget(b, type, from, die, capture) {
if (from === 'bar') {
function addAction(type, piece, from, to) { const dest = type === WHITE ? 24 - die : die - 1;
actions.push({ type, piece: { id: piece.id, type: piece.type }, from, to }); const kind = landing(b, dest, type, capture);
} return kind ? { to: dest, kind } : null;
try {
if (havePiecesOnBar(state, piece.type)) {
if (!isPieceOnBar(state, piece)) return [];
const dest = piece.type === WHITE ? (24 - steps) : (steps - 1);
const top = getTopPiece(state, dest);
const topType = top ? top.type : null;
if (topType === null || topType === piece.type) {
addAction(RECOVER, piece, dest);
} else if (rule.allowHit && countAtPos(state, dest, topType) === 1) {
addAction(HIT, top, dest);
addAction(RECOVER, piece, dest);
} }
return actions; const n = norm(from, type);
} const t = n - die;
if (t >= 0) {
const position = getPiecePos(state, piece); const dest = type === WHITE ? t : 23 - t;
if (position === null) return []; const kind = landing(b, dest, type, capture);
return kind ? { to: dest, kind } : null;
if (allPiecesAreHome(state, piece.type, rule)) {
const dest = rule.incPos(position, piece.type, steps);
const normDest = rule.normPos(dest, piece.type);
if (normDest >= 0) {
const top = getTopPiece(state, dest);
const topType = top ? top.type : null;
if (topType === null || topType === piece.type) {
addAction(MOVE, piece, position, dest);
} else if (rule.allowHit && countAtPos(state, dest, topType) === 1) {
addAction(HIT, top, dest);
addAction(MOVE, piece, position, dest);
} else if (!rule.allowHit && countAllAtPos(state, dest) === 1 && topType !== piece.type) {
// Tapa: can land on single opponent piece
addAction(MOVE, piece, position, dest);
}
} else if (normDest === -1) {
addAction(BEAR, piece, position);
} else {
const normSource = rule.normPos(position, piece.type);
if (countAtHigherPos(state, normSource + 1, piece.type, rule) <= 0) {
addAction(BEAR, piece, position);
}
}
} else {
const dest = rule.incPos(position, piece.type, steps);
if (dest < 0 || dest > 23) return [];
const top = getTopPiece(state, dest);
const topType = top ? top.type : null;
if (topType === null || topType === piece.type) {
addAction(MOVE, piece, position, dest);
} else if (rule.allowHit && countAtPos(state, dest, topType) === 1) {
addAction(HIT, top, dest);
addAction(MOVE, piece, position, dest);
} else if (!rule.allowHit && countAllAtPos(state, dest) === 1 && topType !== piece.type) {
addAction(MOVE, piece, position, dest);
} }
if (!allHome(b, type)) return null;
if (t < -1) {
// A larger die bears off only from the highest point still occupied.
for (let i = 0; i < 24; i++) {
if (norm(i, type) > n && b.pts[i].includes(type)) return null;
} }
} catch (e) {
return [];
} }
return { to: 'off', kind: BEAR };
return actions;
} }
function getDestination(state, piece, steps, rule) { function sources(b, type) {
if (isPieceOnBar(state, piece)) { if (b.bar[type]) return ['bar'];
return piece.type === WHITE ? (24 - steps) : (steps - 1); const out = [];
} for (let i = 0; i < 24; i++) {
const pos = getPiecePos(state, piece); const pt = b.pts[i];
if (pos === null) return null; if (pt.length && pt[pt.length - 1] === type) out.push(i);
if (allPiecesAreHome(state, piece.type, rule)) {
const dest = rule.incPos(pos, piece.type, steps);
const norm = rule.normPos(dest, piece.type);
if (norm < 0) return 'off';
return dest;
} }
return rule.incPos(pos, piece.type, steps); return out;
} }
function applyActions(state, actions, rule) { function applyCompact(b, type, from, r) {
for (const action of actions) { const nb = { pts: b.pts.map(p => p.slice()), bar: b.bar.slice(), off: b.off.slice() };
const piece = findPieceById(state, action.piece.id, action.piece.type); if (from === 'bar') nb.bar[type]--; else nb.pts[from].pop();
if (!piece) continue; if (r.to === 'off') {
nb.off[type]++;
if (action.type === MOVE) { } else {
const pos = getPiecePos(state, piece); if (r.kind === HIT) { nb.pts[r.to].pop(); nb.bar[other(type)]++; }
if (pos !== null) { nb.pts[r.to].push(type);
const idx = state.points[pos].findIndex(p => p.id === piece.id); }
if (idx >= 0) { return nb;
state.points[pos].splice(idx, 1); }
state.points[action.to].push(piece);
} const sum = (a) => a.reduce((s, x) => s + x, 0);
}
} else if (action.type === RECOVER) { /** The most pips' worth of dice that can still be played from here. */
const idx = state.bar[piece.type].findIndex(p => p.id === piece.id); function bestUse(b, type, dice, capture, memo) {
if (idx >= 0) { if (!dice.length) return 0;
state.bar[piece.type].splice(idx, 1); const key = keyOf(b) + '#' + dice.join('');
state.points[action.from].push(piece); const hit = memo.get(key);
} if (hit !== undefined) return hit;
} else if (action.type === HIT) { const total = sum(dice);
const pos = action.from; let best = 0;
const idx = state.points[pos].findIndex(p => p.id === piece.id); const tried = new Set();
if (idx >= 0) { outer:
state.points[pos].splice(idx, 1); for (let k = 0; k < dice.length; k++) {
state.bar[piece.type].push(piece); const d = dice[k];
} if (tried.has(d)) continue;
} else if (action.type === BEAR) { tried.add(d);
const pos = action.from; const rest = dice.slice(0, k).concat(dice.slice(k + 1));
const idx = state.points[pos].findIndex(p => p.id === piece.id); for (const from of sources(b, type)) {
if (idx >= 0) { const r = stepTarget(b, type, from, d, capture);
state.points[pos].splice(idx, 1); if (!r) continue;
state.outside[piece.type].push(piece); const v = d + bestUse(applyCompact(b, type, from, r), type, rest, capture, memo);
} if (v > best) { best = v; if (best === total) break outer; }
} }
} }
memo.set(key, best);
return best;
}
/** Every legal first move: { from, steps, to, kind }. */
function legalFirst(b, type, dice, capture) {
const memo = new Map();
const best = bestUse(b, type, dice, capture, memo);
if (!best) return [];
const out = [];
const tried = new Set();
for (let k = 0; k < dice.length; k++) {
const d = dice[k];
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);
if (!r) continue;
if (d + bestUse(applyCompact(b, type, from, r), type, rest, capture, memo) === best) {
out.push({ from, steps: d, to: r.to, kind: r.kind });
}
}
}
return out;
}
// ── Game flow ─────────────────────────────────────────────────────────────
/** Roll (or set, for a replayed or tested roll) the dice for the side to move. */
export function rollDice(game, values = null) {
const d1 = values ? values[0] : Math.floor(Math.random() * 6) + 1;
const d2 = values ? values[1] : Math.floor(Math.random() * 6) + 1;
game.dice = [d1, d2];
game.movesLeft = d1 === d2 ? [d1, d1, d1, d1] : [Math.max(d1, d2), Math.min(d1, d2)];
game.movesPlayed = [];
const moves = getValidMoves(game);
return { values: [d1, d2], moves };
} }
function findPieceById(state, id, type) { const cache = new WeakMap();
for (let i = 0; i < 24; i++) {
for (const p of state.points[i]) {
if (p.id === id) return p;
}
}
for (const p of state.bar[type]) {
if (p.id === id) return p;
}
return null;
}
function allPiecesAreHome(state, type, rule) { /**
return countAtHigherPos(state, 6, type, rule) <= 0; * Legal moves for the side to move, each with the checker that would go:
* { piece: {id, type}, steps, from: point | 'bar', to: point | 'off', kind }.
*/
export function getValidMoves(game) {
if (!game.dice || !game.movesLeft.length || game.isOver) return [];
const b = compact(game.state);
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 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 } };
});
cache.set(game, { sig, moves });
return moves;
} }
function countAtHigherPos(state, normPosition, type, rule) { /** No legal move left this turn (all dice played, or the rest are blocked). */
let cnt = 0; export function turnIsDone(game) {
for (let i = 23; i >= normPosition; i--) { return !game.dice || game.isOver || getValidMoves(game).length === 0;
const denorm = rule.denormPos(i, type);
if (denorm >= 0 && denorm <= 23) {
cnt += countAtPos(state, denorm, type);
}
}
return cnt;
} }
function calculatePlayableMoves(state, moves, type, variant) { /**
const rule = VARIANTS[variant] || VARIANTS.sheshbesh; * Play one die with one checker. Returns the actions (for animation and
const weight = calcMoveWeights(state, moves, type, rule, null, true); * sound) or null when the move is not legal.
return weight.playableMoves.length > 0 ? weight.playableMoves : []; */
export function applyMove(game, pieceId, steps) {
const move = getValidMoves(game).find(m => m.piece.id === pieceId && m.steps === steps);
if (!move) return null;
return playMove(game, move);
} }
function isMoveRestricted(state, movesLeft, piece, steps, type, rule) { /** Play the top checker of `from` ('bar' or a point) with one die. */
const weight = calcMoveWeights(state, movesLeft, type, rule, piece, false); export function applyMoveFrom(game, from, steps) {
const maxW = weight.max; const move = getValidMoves(game).find(m => m.from === from && m.steps === steps);
const pieceW = weight[piece.id]; if (!move) return null;
if (!pieceW || pieceW.max < maxW) return true; return playMove(game, move);
return false;
} }
function calcMoveWeights(state, movesLeft, type, rule, rootPiece, stopAtMax) { function playMove(game, move) {
const weight = { max: 0, playableMoves: [] }; const state = game.state;
const movesSum = movesLeft.reduce((a, b) => a + b, 0); const type = game.turn;
const stack = move.from === 'bar' ? state.bar[type] : state.points[move.from];
function branch(st, moves, id, sum, level, branchMoves) { const piece = stack.pop();
if (stopAtMax && weight.max >= movesSum) return; const actions = [];
const ml = [...moves];
const steps = ml.shift();
if (!steps) return;
const pieces = getMovablePiecesFromState(st, type, rule);
for (const piece of pieces) {
if (level === 0 && rootPiece && rootPiece.id !== piece.id) continue;
if (isPieceOutsideState(st, piece)) continue;
if (havePiecesOnBarState(st, type)) { if (move.to === 'off') {
const barTop = getBarTopPieceState(st, type); state.outside[type].push(piece);
if (!barTop || barTop.id !== piece.id) continue; actions.push({ type: BEAR, piece: { id: piece.id, type }, from: move.from, to: 'off' });
} else { } else {
const pos = getPiecePosState(st, piece); const dest = state.points[move.to];
if (pos === null) continue; if (move.kind === HIT) {
const top = st.points[pos].length > 0 ? st.points[pos][st.points[pos].length - 1] : null; const victim = dest.pop();
if (!top || top.id !== piece.id) continue; state.bar[victim.type].push(victim);
} actions.push({ type: HIT, piece: { id: victim.id, type: victim.type }, from: move.to, to: 'bar' });
}
const tempState = JSON.parse(JSON.stringify(st)); dest.push(piece);
const tempPiece = findPieceInState(tempState, piece.id, type); actions.push({
if (!tempPiece) continue; type: move.from === 'bar' ? RECOVER : MOVE,
piece: { id: piece.id, type },
const actions = getMoveActionsFromState(tempState, tempPiece, steps, rule); from: move.from,
if (actions.length === 0) continue; to: move.to,
pinned: move.kind === PIN,
applyActionsToState(tempState, actions, rule); });
}
const tempMoves = [...branchMoves, steps];
const pieceID = id !== 0 ? id : piece.id; const idx = game.movesLeft.indexOf(move.steps);
if (idx >= 0) game.movesLeft.splice(idx, 1);
if (!weight[pieceID]) weight[pieceID] = { max: 0, moves: [] }; game.movesPlayed.push(move.steps);
const w = sum + steps; if (hasWon(state, type)) {
if (w > weight[pieceID].max) weight[pieceID].max = w; game.isOver = true;
weight[pieceID].moves = tempMoves; game.winner = type;
if (w > weight.max) { game.score = getGameScore(state, type, game.variant);
weight.max = w; game.endReason = game.score > 1 ? 'gammon' : 'normal';
weight.playableMoves = tempMoves; } else if (ruleOf(game.variant).capture === 'pin') {
} checkMothers(game);
if (stopAtMax && weight.max >= movesSum) return;
if (ml.length > 0) branch(tempState, ml, pieceID, w, level + 1, tempMoves);
}
}
branch(state, movesLeft, 0, 0, 0, []);
if (movesLeft.length > 1 && movesLeft[0] !== movesLeft[movesLeft.length - 1]) {
const reversed = [...movesLeft].reverse();
branch(state, reversed, 0, 0, 0, []);
} }
return actions;
return weight;
} }
// State-only helpers for weight calculation (no mutation of original) // Mahbousa's mother rule (as in plakoto): the last checker on a player's
function getMovablePiecesFromState(state, type, rule) { // starting point is the mother. Pin it and that player loses two points, unless
if (state.bar[type].length > 0) { // the pinner still has checkers on its own starting point (its own mother is
const top = state.bar[type].length > 0 ? state.bar[type][state.bar[type].length - 1] : null; // still in danger). Both mothers pinned: the game is a tie. Without this rule
return top ? [top] : []; // two pinned mothers freeze the board for ever.
} const startOf = (type) => (type === WHITE ? 23 : 0);
const pieces = []; function motherPinned(state, type) {
for (let i = 0; i < 24; i++) { const pt = state.points[startOf(type)];
const pt = state.points[i]; return pt.length > 1 && pt[0].type === type && pt[pt.length - 1].type !== type;
if (pt.length > 0 && pt[pt.length - 1].type === type) {
pieces.push(pt[pt.length - 1]);
}
}
return pieces;
} }
function checkMothers(game) {
function isPieceOutsideState(state, piece) { const s = game.state;
return state.outside[piece.type].some(p => p.id === piece.id); const wPinned = motherPinned(s, WHITE), bPinned = motherPinned(s, BLACK);
if (!wPinned && !bPinned) return;
if (wPinned && bPinned) {
Object.assign(game, { isOver: true, winner: null, score: 0, endReason: 'tie' });
return;
}
const loser = wPinned ? WHITE : BLACK;
const winner = other(loser);
if (s.points[startOf(winner)].some(p => p.type === winner)) return;
Object.assign(game, { isOver: true, winner, score: 2, endReason: 'mother' });
} }
function havePiecesOnBarState(state, type) { export function nextTurn(game) {
return state.bar[type].length > 0; game.turn = other(game.turn);
game.dice = null;
game.movesLeft = [];
game.movesPlayed = [];
game.turnNumber++;
} }
function getBarTopPieceState(state, type) { export function hasWon(state, type) {
const bar = state.bar[type]; return state.outside[type].length === 15;
return bar.length > 0 ? bar[bar.length - 1] : null;
} }
function getPiecePosState(state, piece) { /** Points the winner takes for this game, before the cube. */
for (let i = 0; i < state.points.length; i++) { export function getGameScore(state, winner, variant = 'sheshbesh') {
if (state.points[i].some(p => p.id === piece.id)) return i; const rule = ruleOf(variant);
const loser = other(winner);
const lostOff = state.outside[loser].length;
if (rule.scoring === 'checkers') return 15 - lostOff;
if (lostOff > 0) return 1;
if (rule.scoring === 'backgammon') {
if (state.bar[loser].length) return 3;
for (let i = 0; i < 24; i++) {
if (norm(i, winner) < 6 && state.points[i].some(p => p.type === loser)) return 3;
} }
return null; }
return 2;
} }
function findPieceInState(state, id, type) { /** Pips left to bear everything off (the bar counts as 25). */
export function getPipCount(state, type) {
let pips = 0;
for (let i = 0; i < 24; i++) { for (let i = 0; i < 24; i++) {
for (const p of state.points[i]) { for (const p of state.points[i]) if (p.type === type) pips += norm(i, type) + 1;
if (p.id === id) return p;
} }
} return pips + state.bar[type].length * 25;
for (const p of state.bar[type]) {
if (p.id === id) return p;
}
return null;
} }
function getMoveActionsFromState(state, piece, steps, rule) { /** Checkers of `type` pinned under an opposing checker (mahbousa). */
return getMoveActions(state, piece, steps, rule); export function isPinnedAt(state, pos, index) {
const pt = state.points[pos];
return index < pt.length - 1 && pt[index].type !== pt[pt.length - 1].type;
} }
function applyActionsToState(state, actions, rule) { /**
applyActions(state, actions, rule); * Every distinct complete play for the turn: [{ moves: [{from, steps}], board }],
* one per resulting position. The bot chooses among these.
*/
export function getTurnPlays(game) {
if (!game.dice || !game.movesLeft.length) return [];
const capture = ruleOf(game.variant).capture;
const type = game.turn;
const b0 = compact(game.state);
const memo = new Map();
const best = bestUse(b0, type, game.movesLeft, capture, memo);
if (!best) return [];
const results = new Map();
const walk = (b, dice, used, moves) => {
if (used === best) {
const k = keyOf(b);
if (!results.has(k)) results.set(k, { moves, board: b });
return;
}
const tried = new Set();
for (let k = 0; k < dice.length; k++) {
const d = dice[k];
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);
if (!r) continue;
const nb = applyCompact(b, type, from, r);
if (used + d + bestUse(nb, type, rest, capture, memo) !== best) continue;
walk(nb, rest, used + d, moves.concat([{ from, steps: d, to: r.to, kind: r.kind }]));
}
}
};
walk(b0, game.movesLeft.slice(), 0, []);
return [...results.values()];
} }
// ===== SERIALIZATION (for multiplayer sync) ===== /** A compact copy of a position: { pts, bar, off } (used by the bot). */
export function boardOf(state) { return compact(state); }
// ── Serialization (multiplayer sync) ─────────────────────────────────────
export function serializeState(game) { export function serializeState(game) {
return { return {
...@@ -612,10 +469,12 @@ export function serializeState(game) { ...@@ -612,10 +469,12 @@ export function serializeState(game) {
turnNumber: game.turnNumber, turnNumber: game.turnNumber,
isOver: game.isOver, isOver: game.isOver,
winner: game.winner, winner: game.winner,
score: game.score score: game.score,
endReason: game.endReason || null,
}; };
} }
export function deserializeState(data) { export function deserializeState(data) {
return { ...data }; const g = createGame(data.variant);
return Object.assign(g, JSON.parse(JSON.stringify(data)));
} }
// The tawla table: the board, both players' plates, the dice, the cube and
// the controls. One state object (S) holds everything for the mounted game;
// every timer and frame checks it is still the live one, so nothing fires
// into a scene that has gone.
//
// Against the bot, both sides run here. Online, each client plays its own
// turns and publishes a numbered log of what happened (roll, move, end of
// turn, double, take, drop) with a full snapshot; the other client replays
// the new entries with the same animations and then checks it ended up on
// the snapshot, restoring from it if not. A refreshed tab restores from it.
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as matchSession from '../../../core/match-session.js'; import * as matchSession from '../../../core/match-session.js';
import * as matchUi from '../../../core/match-ui.js';
import * as modal from '../../../core/modal.js'; import * as modal from '../../../core/modal.js';
import * as juice from '../../../core/juice.js';
import * as reactions from '../../../core/reactions.js';
import { haptic } from '../../../core/feel.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { icon, gicon } from '../../../core/icons.js';
import { createGame, rollDice, getValidMoves, applyMove, nextTurn, hasWon, getPipCount, WHITE, BLACK, serializeState } from '../logic/rules.js'; import { avatar, esc } from '../../../core/ui.js';
import { getBotMove } from '../logic/bot.js'; import {
import { createMatch, startNewGame, endGame, canUseCube, getMatchState } from '../logic/match.js'; WHITE, BLACK, rollDice, getValidMoves, applyMoveFrom, nextTurn, turnIsDone, getPipCount,
serializeState, deserializeState, setPosition, variantInfo,
} from '../logic/rules.js';
import { getBotPlay } from '../logic/bot.js';
import { createMatch, startNewGame, endGame, canUseCube, getMatchState, applyMatchState } from '../logic/match.js';
import { createCube, canDouble, offerDouble, acceptDouble, declineDouble, shouldBotDouble, shouldBotAccept } from '../logic/doubling.js'; import { createCube, canDouble, offerDouble, acceptDouble, declineDouble, shouldBotDouble, shouldBotAccept } from '../logic/doubling.js';
import { drawBoard, hitTest } from '../canvas/board-renderer.js'; import { computeLayout, drawBoard, drawChecker, drawSlice, hitTest, slotPos, barPos, trayPos, diceCenter, palette } from '../canvas/board-renderer.js';
import { drawDice, createRollAnimation } from '../canvas/dice-renderer.js'; import { drawDie, diceSpots, createThrow, skinFor } from '../canvas/dice-renderer.js';
import { createMoveAnimation } from '../canvas/move-animator.js'; import { createFlight } from '../canvas/move-animator.js';
import * as reactions from '../../../core/reactions.js'; import { drawCube, cubeSpot } from '../ui/doubling-cube.js';
import { showResult } from './result.js';
const TEST = (() => { try { return localStorage.getItem('el3ab_test') === '1'; } catch (e) { return false; } })();
const reduced = () => !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const other = (c) => (c === WHITE ? BLACK : WHITE);
const themeNum = (key, fallback) => { const v = Number(window.__EL3AB_THEME?.[key]); return Number.isFinite(v) && v > 0 ? v : fallback; };
let S = null;
let canvas, ctx, layout; // ── Mount ──────────────────────────────────────────────────────────────────
let game, match;
let selectedPiece = null;
let highlights = [];
let isRolling = false;
let isAnimating = false;
let myColor = WHITE;
let params = {};
let rafId = null;
let diceRollFrame = 0;
let diceRollTotal = 24;
let particles = [];
let animatingChecker = null;
let lastFrameTime = 0;
let container = null;
let logicalW = 360, logicalH = 400;
export function mountGame(el, p) { export function mountGame(el, p) {
params = p || {}; if (S) teardown();
const { mode = 'bot', variant = 'sheshbesh', matchLength = 3, difficulty = 'medium', useCube = true } = params; const params = { mode: 'bot', variant: 'sheshbesh', difficulty: 'medium', ...(p || {}) };
if (!variantInfo(params.variant) || !['sheshbesh', 'mahbousa', 'thirtyone'].includes(params.variant)) params.variant = 'sheshbesh';
params.matchLength = Math.max(1, Number(params.matchLength) || variantInfo(params.variant).defaultLength);
params.useCube = params.useCube !== false && params.useCube !== 'false';
const live = params.mode === 'live';
const myColor = live ? (params.color === 'black' ? BLACK : WHITE) : WHITE;
S = {
el, params, live, myColor, oppColor: other(myColor),
myId: store.get('auth.userId'),
oppId: live && Array.isArray(params.players) ? params.players.find(id => id !== store.get('auth.userId')) || null : null,
match: createMatch(params.matchLength, params.variant, params.useCube),
game: null,
alive: true, timers: new Set(), raf: 0, lastT: 0,
canvas: null, ctx: null, L: null, ro: null,
sel: null, busy: false, phase: 'boot',
flights: [], hidden: new Set(), pulses: new Map(), particles: [],
dice: null, cubeAnim: null, botPlan: [],
waitingAnswer: false, offerShown: false, over: false, pendingStart: null,
seq: 0, queuedSeq: 0, log: [], postChain: Promise.resolve(), replay: [], replaying: false,
lastSnap: null, configAdopted: !live || myColor === WHITE,
nextDice: [], speed: 1,
};
render(el);
S.canvas = el.querySelector('#bg-canvas');
S.ctx = S.canvas.getContext('2d');
resize();
S.ro = new ResizeObserver(() => resize());
S.ro.observe(el.querySelector('.bgg-table'));
window.addEventListener('resize', resize);
S.canvas.addEventListener('pointerdown', onPointer);
el.querySelector('#btn-roll').addEventListener('click', onRollClick);
el.querySelector('#btn-double').addEventListener('click', onDoubleClick);
el.querySelector('#btn-accept-double').addEventListener('click', onAcceptDouble);
el.querySelector('#btn-decline-double').addEventListener('click', onDeclineDouble);
el.querySelector('#btn-quit').addEventListener('click', onQuit);
setupReactions(el);
installTestHook();
startLoop();
myColor = mode === 'live' ? (params.color === 'black' ? BLACK : WHITE) : WHITE; if (live) initLive();
match = createMatch(matchLength, variant); else { startGame(); openBotMatch(); }
if (!useCube) match.cube = null; }
const player = store.get('player') || {}; /** A bot game gets a server match row, so its win can be paid (once). */
const myName = player.display_name || player.username || t('common.you'); async function openBotMatch() {
const myAvatar = player.avatar_url const s = S;
? `<img src="${player.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` try {
: emoji('person', '👤', 18); const res = await net.post('backgammon-match.php', {
const myLevel = player.level || 1; action: 'start', bot_level: s.params.difficulty, variant: s.match.variant, match_length: s.match.length,
});
if (s.alive && res?.match_id) s.botMatchId = res.match_id;
} catch (e) { /* offline: the game is played, nothing is paid */ }
}
const rawOppName = mode === 'bot' ? t('game.bot') : (params.opponentName || t('common.opponent')); export function unmountGame() { teardown(); }
const oppName = rawOppName.replace(/[<>"&]/g, c => ({'<':'&lt;','>':'&gt;','"':'&quot;','&':'&amp;'}[c]));
const oppAvatarUrl = params.opponentAvatar ? String(params.opponentAvatar).replace(/[<>"]/g, '') : '';
const oppAvatar = oppAvatarUrl
? `<img src="${oppAvatarUrl}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">`
: (mode === 'bot' ? emoji('robot', '🤖', 18) : emoji('person', '👤', 18));
const oppLevel = params.opponentLevel || (mode === 'bot' ? '—' : 1);
const cubeVal = match.cube ? match.cube.value : 1; function teardown() {
if (!S) return;
S.alive = false;
for (const id of S.timers) clearTimeout(id);
S.timers.clear();
if (S.raf) cancelAnimationFrame(S.raf);
S.ro?.disconnect();
window.removeEventListener('resize', resize);
reactions.stop();
if (S.live) { matchSession.destroy(); matchUi.cleanup(); }
if (TEST && window.__bgTest?._s === S) delete window.__bgTest;
S = null;
}
el.innerHTML = ` /** setTimeout that dies with the scene. ms is scaled by the test speed. */
<div class="bgg-container"> function later(fn, ms) {
<div class="bgg-top-bar"> const s = S;
<div class="bgg-player-card bgg-opp-card" id="opp-card"> if (!s) return 0;
<div class="bgg-avatar bgg-avatar-opp">${oppAvatar}</div> const id = setTimeout(() => {
<div class="bgg-player-info"> s.timers.delete(id);
<div class="bgg-name" id="name-top">${oppName}</div> if (s.alive && S === s) fn();
<div class="bgg-meta"><span class="bgg-level">Lv.${oppLevel}</span><span class="bgg-pips" id="pips-top">167</span></div> }, Math.max(0, ms / s.speed));
s.timers.add(id);
return id;
}
function cancel(id) { if (id && S) { clearTimeout(id); S.timers.delete(id); } }
// ── Markup ─────────────────────────────────────────────────────────────────
function render(el) {
const { params, myColor, live } = S;
const player = store.get('player') || {};
const myName = player.display_name || player.username || t('common.you');
const oppRaw = params.mode === 'bot' ? t('game.bot') : (params.opponentName || t('common.opponent'));
const oppAvatarUrl = params.opponentAvatar ? String(params.opponentAvatar).replace(/[<>"]/g, '') : '';
const oppFace = params.mode === 'bot' && !oppAvatarUrl
? `<span class="bgg-bot-face">${gicon('bot', 34)}</span>`
: avatar({ display_name: oppRaw, avatar_url: oppAvatarUrl }, 40);
const lvl = (n) => t('common.level', { n: `<span class="num">${Number(n) || 1}</span>` });
const of = t('backgammon.from_match', { n: `<span class="num" data-len>${S.match.length}</span>` });
const pipsLabel = t('backgammon.game_pips_label');
const plate = (id, side, face, tone, name, meta, pipsId, scoreId) => `
<div class="bgg-plate bgg-${side}" id="${id}">
<span class="bgg-seat">
<span class="bgg-avatar">${face}</span>
<span class="bgg-token ${tone}" aria-hidden="true"></span>
</span>
<div class="bgg-plate-text">
<div class="bgg-name-row">
<span class="bgg-name" dir="auto">${name}</span>
<span class="bgg-chip">${side === 'me' ? t('game.your_turn') : t('game.opponent_turn')}</span>
${side === 'opp' ? '<span class="bgg-thinking" aria-hidden="true"><i></i><i></i><i></i></span>' : ''}
</div> </div>
<div class="bgg-score" id="score-top">0</div> <div class="bgg-meta">
${meta ? `<span class="bgg-level">${meta}</span>` : ''}
<span class="bgg-pips">${pipsLabel} <span class="num" id="${pipsId}">167</span></span>
</div> </div>
<div class="bgg-match-info">
<span class="bgg-cube-display" id="cube-display">×${cubeVal}</span>
<span class="bgg-match-length">${matchLength} ${t('game.points', { n: '' }).trim()}</span>
</div> </div>
<div class="bgg-score" aria-label="${t('backgammon.game_score')}">
<span class="num bgg-score-n" id="${scoreId}">0</span>
<span class="bgg-of">${of}</span>
</div> </div>
</div>`;
el.innerHTML = `
<div class="bgg">
<div class="bgg-table">
${plate('opp-card', 'opp', oppFace, myColor === WHITE ? 'is-black' : 'is-white', esc(oppRaw), params.mode === 'bot' ? t('ludo.' + (params.difficulty || 'medium')) : lvl(params.opponentLevel), 'pips-top', 'score-top')}
<div class="bgg-board-area"> <div class="bgg-board-area">
<canvas id="bg-canvas"></canvas> <canvas id="bg-canvas" aria-label="${t('game.backgammon')}"></canvas>
<div class="bgg-turn-badge" id="turn-indicator">${t('game.your_turn')}</div> <div class="bgg-banner" id="bgg-banner" role="status" aria-live="polite" hidden></div>
</div> </div>
${plate('my-card', 'me', avatar(player, 40), myColor === WHITE ? 'is-white' : 'is-black', esc(myName), lvl(player.level), 'pips-bottom', 'score-bottom')}
<div class="bgg-controls">
<button class="bgg-action-btn bgg-quit" id="btn-quit">✕</button>
<button class="bgg-action-btn bgg-emote-btn" id="btn-emote">${emoji('sparkles', '✨', 18)}</button>
<button class="bgg-roll-btn" id="btn-roll">${emoji('game_die', '🎲', 20)} ${t('game.roll_dice')}</button>
<button class="bgg-cube-btn" id="btn-double" style="display:none;">×2</button>
</div> </div>
<div class="bgg-double-offer" id="double-offer" style="display:none;"> <div class="bgg-controls">
<div class="bgg-double-icon">×<span id="double-val">2</span></div> <button class="icon-btn" id="btn-quit" aria-label="${t('game.leave')}">${icon('logout', 22)}</button>
<p>${t('backgammon.opponent_doubles')}</p> <button class="icon-btn" id="btn-emote" aria-label="${t('reaction.emotes')}">${icon('smile', 22)}</button>
<div class="bgg-double-actions"> <div class="bgg-actions">
<button class="bgg-decline-btn" id="btn-decline-double">${t('backgammon.decline')}</button> <button class="btn btn-blue btn-lg bgg-double-btn" id="btn-double" hidden>${gicon('up', 26)}<span>${t('backgammon.game_double')}</span></button>
<button class="bgg-accept-btn" id="btn-accept-double">${t('backgammon.accept')}</button> <button class="btn btn-primary btn-xl bgg-roll-btn" id="btn-roll" hidden>${gicon('dice', 36)}<span>${t('game.roll_dice')}</span></button>
<div class="bgg-turn-badge" id="turn-indicator" role="status" aria-live="polite"></div>
</div> </div>
</div> </div>
<div class="bgg-bottom-bar"> <div class="bgg-offer" id="double-offer" hidden>
<div class="bgg-player-card bgg-my-card" id="my-card"> <div class="deck-card bgg-offer-card" role="alertdialog" aria-labelledby="double-title" aria-describedby="double-hint">
<div class="bgg-avatar bgg-avatar-me">${myAvatar}</div> <div class="bgg-offer-cube" aria-hidden="true"><span id="double-val">2</span></div>
<div class="bgg-player-info"> <p class="bgg-offer-title" id="double-title">${live ? t('backgammon.opponent_doubles') : t('backgammon.bot_doubles')}</p>
<div class="bgg-name" id="name-bottom">${myName}</div> <p class="bgg-offer-hint" id="double-hint"></p>
<div class="bgg-meta"><span class="bgg-level">Lv.${myLevel}</span><span class="bgg-pips" id="pips-bottom">167</span></div> <div class="bgg-offer-actions">
<button class="btn btn-red btn-lg" id="btn-decline-double">${t('backgammon.decline')}</button>
<button class="btn btn-primary btn-lg" id="btn-accept-double">${t('backgammon.accept')}</button>
</div> </div>
<div class="bgg-score bgg-my-score" id="score-bottom">0</div>
</div> </div>
</div> </div>
</div> </div>
${getStyles()} ${styles()}
`; `;
}
container = el.querySelector('.bgg-container'); // ── Layout ─────────────────────────────────────────────────────────────────
canvas = el.querySelector('#bg-canvas');
ctx = canvas.getContext('2d'); function resize() {
if (!S?.canvas) return;
resizeCanvas(); const area = S.canvas.parentElement;
startNewGameRound(); const table = S.el.querySelector('.bgg-table');
const plates = [...S.el.querySelectorAll('.bgg-plate')].reduce((n, p) => n + p.offsetHeight, 0);
canvas.addEventListener('click', onCanvasClick); const aw = area.clientWidth - 8;
canvas.addEventListener('touchstart', onCanvasTouch, { passive: false }); // The table's height less the two plates and their gaps: the board takes all of it it can use.
el.querySelector('#btn-roll')?.addEventListener('click', onRollClick); const ah = table.clientHeight - plates - 16;
el.querySelector('#btn-double')?.addEventListener('click', onDoubleClick); if (aw <= 0) return;
el.querySelector('#btn-accept-double')?.addEventListener('click', onAcceptDouble); let w = Math.min(aw, 560);
el.querySelector('#btn-decline-double')?.addEventListener('click', onDeclineDouble); let h = Math.round(w * 1.42);
el.querySelector('#btn-quit')?.addEventListener('click', onQuit); if (ah > 0 && h > ah) {
window.addEventListener('resize', resizeCanvas); h = ah;
if (h < w * 1.02) w = Math.floor(h / 1.02);
if (mode === 'live') {
matchSession.create(params.matchId, 'backgammon', {
onOpponentMove: handleServerState,
onOpponentDisconnect: () => bus.emit('toast', { text: t('game.opponent_disconnected') }),
onOpponentAbandon: () => endMatchWithWin()
});
} }
w = Math.floor(w); h = Math.floor(h);
setupEmotePanel(el); if (S.L && S.L.w === w && S.L.h === h) return;
startLoop(); const k = Math.min(3, window.devicePixelRatio || 1);
S.canvas.width = Math.round(w * k);
S.canvas.height = Math.round(h * k);
S.canvas.style.width = w + 'px';
S.canvas.style.height = h + 'px';
S.ctx.setTransform(k, 0, 0, k, 0, 0);
S.L = computeLayout(w, h, S.myColor === BLACK);
} }
export function unmountGame() { // ── Frame loop ─────────────────────────────────────────────────────────────
reactions.stop();
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
window.removeEventListener('resize', resizeCanvas);
matchSession.destroy();
container = null; canvas = null; ctx = null;
}
// ── Resize ──
function resizeCanvas() {
if (!canvas) return;
const area = canvas.parentElement;
const w = Math.min(area.clientWidth - 8, 400);
const h = Math.round(w * 1.1);
logicalW = w;
logicalH = h;
canvas.width = w;
canvas.height = h;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
}
// ── Game Loop ──
function startLoop() { function startLoop() {
lastFrameTime = performance.now(); const s = S;
function loop(now) { s.lastT = performance.now();
const dt = Math.min((now - lastFrameTime) / 1000, 0.05); const loop = (now) => {
lastFrameTime = now; if (!s.alive) return;
updateParticles(dt); s.raf = requestAnimationFrame(loop);
draw(); const dt = Math.min(0.05, (now - s.lastT) / 1000);
rafId = requestAnimationFrame(loop); s.lastT = now;
} stepFlights(now);
rafId = requestAnimationFrame(loop); stepParticles(dt);
draw(now);
};
s.raf = requestAnimationFrame(loop);
} }
function updateParticles(dt) { function legalNow() { return S.game && myTurnToMove() ? getValidMoves(S.game) : []; }
for (let i = particles.length - 1; i >= 0; i--) { function myTurnToMove() {
const p = particles[i]; const g = S.game;
p.x += p.vx * dt; return !!g && !g.isOver && g.turn === S.myColor && !!g.dice && !S.busy && !S.over && S.phase === 'moving';
p.y += p.vy * dt;
p.vy += 250 * dt;
p.alpha -= 2 * dt;
if (p.alpha <= 0) particles.splice(i, 1);
}
} }
function draw() { function draw(now) {
if (!ctx || !game) return; const { ctx, L, game } = S;
if (!ctx || !L || !game) return;
layout = drawBoard(ctx, logicalW, logicalH, game.state, { const legal = legalNow();
highlights, const movable = S.sel === null ? [...new Set(legal.map(m => m.from))] : [];
selectedPoint: selectedPiece?.from ?? null, const highlights = S.sel !== null ? [...new Set(legal.filter(m => m.from === S.sel).map(m => m.to))] : [];
particles drawBoard(ctx, L, game.state, {
}); highlights, selected: S.sel, movable, hidden: S.hidden, pulses: S.pulses, time: now, turn: game.turn,
// Dice on board
if (game.dice && !isRolling) {
const allDice = game.dice[0] === game.dice[1]
? [game.dice[0], game.dice[0], game.dice[0], game.dice[0]]
: game.dice;
const totalW = allDice.length * 38 + (allDice.length - 1) * 10;
const diceX = logicalW / 2 - totalW / 2;
const diceY = logicalH / 2 - 19;
drawDice(ctx, diceX, diceY, allDice, { used: getUsedDiceIndices() });
}
if (isRolling) {
const fake = [Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6)];
drawDice(ctx, logicalW / 2 - 44, logicalH / 2 - 19, fake, {
rolling: true, frame: diceRollFrame, totalFrames: diceRollTotal
}); });
drawCubeLayer(now);
drawDiceLayer(now);
for (const f of S.flights) {
const ps = f.flight.pose(now);
if (f.kind === 'bear' && ps.p > 0.82) {
drawSlice(ctx, L, f.type, f.slot, palette(), { scale: 0.9 + (1 - ps.p) * 0.6 });
} else {
drawChecker(ctx, ps.x, ps.y, f.type, L.r, { lift: ps.lift, scale: ps.scale });
} }
}
drawParticles();
}
// Animated checker in flight function drawDiceLayer(now) {
if (animatingChecker) { const d = S.dice;
const { x, y, type } = animatingChecker; if (!d) return;
const r = layout?.checkerR || 14; const { L, ctx } = S;
ctx.save(); const size = Math.max(20, Math.min(34, L.pw * 1.3));
// shadow const doubles = d.values[0] === d.values[1];
ctx.beginPath(); if (d.thr && !d.thr.done(now)) {
ctx.arc(x + 1, y + 3, r, 0, Math.PI * 2); for (const pose of d.thr.frame(now)) {
ctx.fillStyle = 'rgba(0,0,0,0.5)'; drawDie(ctx, pose.x, pose.y, size, pose.value, { rot: pose.rot, lift: pose.lift, roll: pose.roll, scale: pose.scale, skin: skinFor(d.type) });
ctx.fill(); }
// checker return;
ctx.beginPath(); }
ctx.arc(x, y, r, 0, Math.PI * 2); const n = doubles ? 4 : 2;
ctx.fillStyle = type === WHITE ? '#F5E6D3' : '#2A2A2A'; const sz = doubles ? size * 0.82 : size;
ctx.fill(); const spots = diceSpots(diceCenter(L, d.type), n, sz);
ctx.strokeStyle = type === WHITE ? '#A08060' : '#555'; const values = doubles ? [d.values[0], d.values[0], d.values[0], d.values[0]] : d.values;
ctx.lineWidth = 2; const used = usedDice(values);
ctx.stroke(); const blocked = S.game && S.game.dice && turnIsDone(S.game) && !S.game.isOver;
// glow for (let i = 0; i < n; i++) {
ctx.beginPath(); let scale = 1;
ctx.arc(x, y, r + 3, 0, Math.PI * 2); if (doubles && i >= 2 && d.landedAt) {
ctx.strokeStyle = 'rgba(255,215,0,0.6)'; const e = (now - d.landedAt) / (260 / S.speed);
ctx.lineWidth = 2; if (e < 1) scale = Math.max(0.01, e < 0.7 ? e / 0.7 * 1.15 : 1.15 - (e - 0.7) / 0.3 * 0.15);
ctx.stroke(); }
ctx.restore(); drawDie(ctx, spots[i].x, spots[i].y, sz, values[i], { used: used.includes(i) || blocked, skin: skinFor(d.type), scale });
}
}
// ── Game Logic ──
function startNewGameRound() {
game = startNewGame(match);
selectedPiece = null;
highlights = [];
isRolling = false;
isAnimating = false;
animatingChecker = null;
particles = [];
updateUI();
if (game.turn !== myColor && params.mode === 'bot') {
setTimeout(doBotTurn, 800);
} }
} }
function getUsedDiceIndices() { /** Which dice (by index) the moves so far have used. */
if (!game.dice) return []; function usedDice(values) {
const used = []; const used = [];
const allDice = game.dice[0] === game.dice[1] for (const played of S.game?.movesPlayed || []) {
? [game.dice[0], game.dice[0], game.dice[0], game.dice[0]] for (let j = 0; j < values.length; j++) {
: [...game.dice]; if (!used.includes(j) && values[j] === played) { used.push(j); break; }
for (const played of (game.movesPlayed || [])) {
for (let j = 0; j < allDice.length; j++) {
if (!used.includes(j) && allDice[j] === played) { used.push(j); break; }
} }
} }
return used; return used;
} }
function updateUI() { function drawCubeLayer(now) {
if (!game || !container) return; const cube = S.match.cube;
const topColor = myColor === WHITE ? BLACK : WHITE; if (!cube || !S.L) return;
const isMyTurn = game.turn === myColor; const where = cube.owner === null ? 'center' : cube.owner === S.myColor ? 'me' : 'opp';
const to = cubeSpot(S.L, where);
setText('#pips-top', getPipCount(game.state, topColor, game.variant)); const a = S.cubeAnim;
setText('#pips-bottom', getPipCount(game.state, myColor, game.variant)); if (a) {
setText('#score-top', String(match.scores[topColor])); const e = Math.min(1, (now - a.start) / (720 / S.speed));
setText('#score-bottom', String(match.scores[myColor])); const from = cubeSpot(S.L, a.from);
const ease = 1 - Math.pow(1 - e, 3);
// Cube display const x = from.x + (to.x - from.x) * ease, y = from.y + (to.y - from.y) * ease;
const cubeEl = container.querySelector('#cube-display'); drawCube(S.ctx, x, y, to.size * (1 + Math.sin(e * Math.PI) * 0.35), cube.value, { flip: e, prev: a.prev, lift: Math.sin(e * Math.PI) * 18 });
if (cubeEl && match.cube) cubeEl.textContent = `×${match.cube.value}`; if (e >= 1) S.cubeAnim = null;
// Active turn highlight on cards
const oppCard = container.querySelector('#opp-card');
const myCard = container.querySelector('#my-card');
if (oppCard) oppCard.classList.toggle('bgg-active-turn', !isMyTurn);
if (myCard) myCard.classList.toggle('bgg-active-turn', isMyTurn);
// Roll button
const rollBtn = container.querySelector('#btn-roll');
rollBtn.style.display = (isMyTurn && !game.dice && !isAnimating && !isRolling) ? '' : 'none';
// Double button
const dblBtn = container.querySelector('#btn-double');
if (match.cube && canUseCube(match) && isMyTurn && !game.dice && canDouble(match.cube, myColor)) {
dblBtn.style.display = '';
} else { } else {
dblBtn.style.display = 'none'; drawCube(S.ctx, to.x, to.y, to.size, cube.value);
} }
}
// Turn indicator // ── Particles ──────────────────────────────────────────────────────────────
const ti = container.querySelector('#turn-indicator');
if (ti) { function burst(x, y, colors, count = 14, { speed = 120, star = false } = {}) {
ti.textContent = isMyTurn ? t('game.your_turn') : t('game.opponent_turn'); if (reduced()) return;
ti.className = `bgg-turn-badge ${isMyTurn ? 'bgg-turn-mine' : 'bgg-turn-opp'}`; for (let i = 0; i < count; i++) {
const a = (Math.PI * 2 * i) / count + Math.random() * 0.4;
const v = speed * (0.5 + Math.random() * 0.8);
S.particles.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 60, alpha: 1, size: 2 + Math.random() * 3, color: colors[i % colors.length], star: star && i % 2 === 0, rot: Math.random() * 6 });
} }
} }
function stepParticles(dt) {
function setText(sel, text) { const ps = S.particles;
const el = container?.querySelector(sel); for (let i = ps.length - 1; i >= 0; i--) {
if (el) el.textContent = text; const p = ps[i];
p.x += p.vx * dt; p.y += p.vy * dt; p.vy += 260 * dt; p.alpha -= 1.6 * dt; p.rot += dt * 6;
if (p.alpha <= 0) ps.splice(i, 1);
}
} }
function drawParticles() {
// ── Input ── const ctx = S.ctx;
function onCanvasClick(e) { for (const p of S.particles) {
if (isAnimating || isRolling) return; ctx.globalAlpha = Math.max(0, p.alpha);
if (game.turn !== myColor) return; ctx.fillStyle = p.color;
if (!game.dice) return; if (p.star) {
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
const rect = canvas.getBoundingClientRect(); ctx.beginPath();
const x = (e.clientX - rect.left) * (logicalW / rect.width); for (let i = 0; i < 5; i++) { const a = (i * 4 * Math.PI) / 5 - Math.PI / 2; ctx[i ? 'lineTo' : 'moveTo'](Math.cos(a) * p.size * 1.8, Math.sin(a) * p.size * 1.8); }
const y = (e.clientY - rect.top) * (logicalH / rect.height); ctx.closePath(); ctx.fill(); ctx.restore();
handleTap(x, y); } else {
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
}
}
ctx.globalAlpha = 1;
} }
function onCanvasTouch(e) { // ── Flights ────────────────────────────────────────────────────────────────
if (isAnimating || isRolling) return;
if (game.turn !== myColor) return;
if (!game.dice) return;
e.preventDefault(); function stepFlights(now) {
const t = e.touches[0]; if (!S.flights.length) return;
const rect = canvas.getBoundingClientRect(); const done = S.flights.filter(f => f.flight.done(now));
const x = (t.clientX - rect.left) * (logicalW / rect.width); if (!done.length) return;
const y = (t.clientY - rect.top) * (logicalH / rect.height); S.flights = S.flights.filter(f => !done.includes(f));
handleTap(x, y); for (const f of done) {
S.hidden.delete(f.id);
f.onLand?.(now);
}
} }
function handleTap(x, y) { /** Where the top checker of a stack sits now (before it moves). */
const hit = hitTest(x, y, layout, game.state); function sourcePos(from, type) {
if (!hit) { clearSelection(); return; } const st = S.game.state;
if (from === 'bar') return barPos(S.L, type, st.bar[type].length - 1);
return slotPos(S.L, from, st.points[from].length - 1);
}
// Tap destination /**
if (selectedPiece && hit.type === 'point' && highlights.includes(hit.index)) { * Play one move with its flight. actor: 'me' | 'bot' | 'remote' | 'auto'.
executeMove(hit.index); return; * The game state changes at once; the board hides the checker until it lands.
*/
function performMove(m, actor, done) {
const { game, L } = S;
const type = game.turn;
const from = sourcePos(m.from, type);
const actions = applyMoveFrom(game, m.from, m.steps);
if (!actions) { done?.(false); return; }
if (S.live && (actor === 'me' || actor === 'auto')) emit({ kind: 'move', from: m.from, steps: m.steps });
const main = actions.find(a => a.type !== 'hit');
const hit = actions.find(a => a.type === 'hit');
let to, slot = 0;
if (m.to === 'off') {
slot = game.state.outside[type].length - 1;
to = trayPos(L, type, slot);
} else {
to = slotPos(L, m.to, game.state.points[m.to].length - 1);
} }
if (selectedPiece && hit.type === 'bearoff' && highlights.includes('off')) { S.sel = null;
executeMove('off'); return; S.busy = true;
S.hidden.add(main.piece.id);
const now = performance.now();
if (hit) {
// Until the mover lands, the blot still stands on its point.
S.hidden.add(hit.piece.id);
S.flights.push({
id: '_ghost' + hit.piece.id, type: hit.piece.type, kind: 'ghost',
flight: { pose: () => ({ x: to.x, y: to.y, lift: 0, scale: 1, p: 0 }), done: () => !S.flights.some(f => f.id === main.piece.id) },
});
}
const flight = createFlight({ from, to, kind: m.to === 'off' ? 'bear' : 'move', speed: S.speed, now });
S.flights.push({
id: main.piece.id, type, kind: m.to === 'off' ? 'bear' : 'move', slot, flight,
onLand: (t) => {
S.pulses.set(main.piece.id, t);
audio.play('sfx_bg_piece_move', 'game');
haptic('light');
const P = palette();
if (main.pinned) {
audio.play('sfx_bg_piece_hit', 'game', { volume: 0.6 });
burst(to.x, to.y, [P.gold, P.goldSoft], 10, { speed: 80, star: true });
haptic('medium');
} }
if (m.to === 'off') {
audio.play('sfx_bg_bear_off', 'game');
burst(to.x, to.y, [P.gold, P.goldSoft, P.card], 12, { speed: 90, star: true });
}
if (!hit) { finish(); return; }
// The blot is knocked off its point and tumbles to the bar.
audio.play('sfx_bg_piece_hit', 'game');
haptic('heavy');
juice.shake(S.canvas, actor === 'me' || actor === 'auto' ? 5 : 7, 260);
burst(to.x, to.y, [P.redInk, P.gold, P.card], 16, { speed: 150 });
const bar = barPos(L, hit.piece.type, game.state.bar[hit.piece.type].length - 1);
S.flights.push({
id: hit.piece.id, type: hit.piece.type, kind: 'hit',
flight: createFlight({ from: to, to: bar, kind: 'hit', speed: S.speed, now: t }),
onLand: (t2) => { S.pulses.set(hit.piece.id, t2); audio.play('place', 'game', { volume: 0.7 }); finish(); },
});
},
});
updateUI();
// Select piece function finish() {
if (hit.type === 'point') selectPoint(hit.index); S.busy = false;
else if (hit.type === 'bar' && hit.player === game.turn) selectBar(); updateUI();
else clearSelection(); done?.(true);
}
} }
function selectPoint(idx) { // ── Turn flow ──────────────────────────────────────────────────────────────
const pieces = game.state.points[idx];
if (!pieces.length || pieces[pieces.length - 1].type !== game.turn) { clearSelection(); return; }
const piece = pieces[pieces.length - 1]; function startGame() {
const moves = getValidMoves(game).filter(m => m.piece.id === piece.id); if (!S) return;
if (!moves.length) { clearSelection(); return; } cancel(S.pendingStart);
S.pendingStart = null;
selectedPiece = { id: piece.id, from: idx, moves }; S.game = startNewGame(S.match);
highlights = moves.map(m => m.to === 'off' ? 'off' : m.to); S.sel = null; S.dice = null; S.flights = []; S.hidden.clear(); S.botPlan = [];
audio.play('click'); S.over = false; S.busy = false; S.waitingAnswer = false;
hideOffer();
updateUI();
if (S.match.gameNumber > 1) banner(t('backgammon.game_n', { n: `<span class="num">${S.match.gameNumber}</span>` }), 'gold', 1100);
audio.play('deal');
beginTurn(S.match.gameNumber > 1 ? 900 : 400);
} }
function selectBar() { function beginTurn(delay = 0) {
const moves = getValidMoves(game).filter(m => m.from === 'bar' || m.from === null); if (!S || S.over) return;
if (!moves.length) return; const g = S.game;
selectedPiece = { id: moves[0].piece.id, from: 'bar', moves }; S.sel = null;
highlights = moves.map(m => m.to); if (g.turn === S.myColor) {
audio.play('click'); S.phase = 'idle';
updateUI();
const btn = S.el.querySelector('#btn-roll');
btn?.classList.remove('is-ready'); void btn?.offsetWidth; btn?.classList.add('is-ready');
audio.play('sfx_turn_start', 'game', { volume: 0.6 });
return;
}
S.phase = 'opp';
updateUI();
if (!S.live) later(botTurn, delay + themeNum('bg_bot_delay', 700));
} }
function clearSelection() { selectedPiece = null; highlights = []; } function canRollNow() {
const g = S?.game;
function executeMove(dest) { return !!g && !S.over && !S.busy && !g.isOver && g.turn === S.myColor && !g.dice && S.phase === 'idle' && !S.waitingAnswer && !S.offerShown;
if (!selectedPiece) return; }
const move = selectedPiece.moves.find(m => m.to === dest);
if (!move) return;
isAnimating = true;
const fromPt = selectedPiece.from;
const pieceType = game.turn;
audio.play('sfx_bg_piece_move', 'game');
createMoveAnimation(fromPt, dest, layout, function onRollClick() {
(frame) => { animatingChecker = { x: frame.x, y: frame.y, type: pieceType }; }, if (!canRollNow()) return;
() => { doRoll(S.myColor, null, () => afterRoll(S.myColor));
animatingChecker = null; }
const actions = applyMove(game, move.piece.id, move.steps);
if (actions?.some(a => a.type === 'hit')) { /** Throw the dice for `type`, then apply the roll. values: a replayed roll. */
audio.play('sfx_bg_piece_hit', 'game'); function doRoll(type, values, done) {
spawnParticles(logicalW / 2, logicalH / 2, ['#EF4444', '#F97316', '#FBBF24']); const vals = values || S.nextDice.shift() || [1 + Math.floor(Math.random() * 6), 1 + Math.floor(Math.random() * 6)];
S.busy = true;
S.phase = 'rolling';
updateUI();
if (S.live && type === S.myColor) {
// Published before the throw lands, so the opponent sees it sooner.
rollDice(S.game, vals);
emit({ kind: 'roll', values: vals });
} }
if (actions?.some(a => a.type === 'bear')) { audio.play('sfx_dice_shake', 'game');
audio.play('sfx_bg_bear_off', 'game'); const L = S.L;
spawnParticles(logicalW - 20, logicalH / 2, ['#10B981', '#34D399', '#FFD700']); const center = diceCenter(L, type);
const mine = type === S.myColor;
const size = Math.max(20, Math.min(34, L.pw * 1.3));
const spots = diceSpots(center, 2, size);
const thr = createThrow({
values: vals,
from: { x: center.x + (mine ? 30 : -30), y: mine ? L.h + 24 : -24 },
spots,
duration: Math.max(420, themeNum('bg_dice_duration', 760)) / S.speed,
onBounce: (i, k) => { if (k === 0) audio.play(i === 0 ? 'dice' : 'die', 'game', { volume: 0.8 }); else if (k === 2) audio.play('die', 'game', { volume: 0.45 }); },
});
S.dice = { type, values: vals, thr, landedAt: 0 };
later(() => {
if (!(S.live && type === S.myColor)) rollDice(S.game, vals);
S.dice.thr = null;
S.dice.landedAt = performance.now();
S.busy = false;
if (vals[0] === vals[1]) {
// Doubles: four moves. The windfall gets a moment.
const P = palette();
burst(center.x, center.y, [P.gold, P.goldSoft, P.card], 18, { speed: 140, star: true });
audio.play('sfx_boost', 'game');
haptic('success');
} else {
haptic('light');
} }
done?.();
}, thr.duration * S.speed);
}
clearSelection(); function afterRoll(type) {
isAnimating = false; if (!S || S.over) return;
const legal = getValidMoves(S.game);
if (!legal.length) {
S.phase = 'blocked';
updateUI(); updateUI();
const mine = type === S.myColor;
if (game.isOver) { handleGameOver(); return; } const onBar = S.game.state.bar[type].length > 0;
if (game.movesLeft.length === 0) { endTurn(); return; } banner(mine ? (onBar ? t('backgammon.bar_blocked') : t('backgammon.no_moves')) : t('backgammon.opp_no_moves'), mine ? 'red' : 'blue', 1300);
const remaining = getValidMoves(game); audio.play('error');
if (remaining.length === 0) endTurn(); if (mine) haptic('error');
if (!S.live || mine) later(() => endTurn(), 1400);
return;
}
if (type === S.myColor) {
S.phase = 'moving';
updateUI();
autoSelect();
maybeForced();
} else {
S.phase = 'opp';
updateUI();
if (!S.live) botPlay();
} }
);
} }
function endTurn() { /** One legal move only: play it for the player. */
nextTurn(game); function maybeForced() {
updateUI(); const legal = getValidMoves(S.game);
if (params.mode === 'bot' && game.turn !== myColor) setTimeout(doBotTurn, 600); if (legal.length !== 1) return;
else if (params.mode === 'live') syncToServer(); const m = legal[0];
later(() => {
if (!myTurnToMove()) return;
const again = getValidMoves(S.game);
if (again.length === 1 && again[0].from === m.from && again[0].steps === m.steps) performMove(m, 'auto', afterMyMove);
}, 520);
} }
// ── Dice ── /** Only one checker can move (often the one on the bar): pick it up for the player. */
function onRollClick() { function autoSelect() {
if (game.dice || isRolling || isAnimating) return; const froms = [...new Set(getValidMoves(S.game).map(m => m.from))];
if (game.turn !== myColor) return; S.sel = froms.length === 1 ? froms[0] : null;
}
isRolling = true; function afterMyMove() {
diceRollFrame = 0; if (!S || S.over) return;
audio.play('sfx_bg_dice_roll', 'game'); if (S.game.isOver) { onGameOver(); return; }
if (turnIsDone(S.game)) { S.phase = 'done'; updateUI(); later(() => endTurn(), 420); return; }
S.phase = 'moving';
autoSelect();
updateUI(); updateUI();
maybeForced();
}
createRollAnimation( function endTurn(fromRemote = false) {
(frame, total) => { diceRollFrame = frame; diceRollTotal = total; }, if (!S || S.over || S.game.isOver) return;
() => { const wasMine = S.game.turn === S.myColor;
isRolling = false; nextTurn(S.game);
rollDice(game); S.dice = null;
S.sel = null;
if (S.live && wasMine && !fromRemote) emit({ kind: 'end' });
beginTurn();
}
if (game.movesLeft.length === 0) { // ── Input ──────────────────────────────────────────────────────────────────
bus.emit('toast', { text: t('backgammon.no_moves') });
setTimeout(endTurn, 1000); function onPointer(e) {
return; if (!S || !S.L) return;
e.preventDefault();
const rect = S.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * (S.L.w / rect.width);
const y = (e.clientY - rect.top) * (S.L.h / rect.height);
if (canRollNow() && !canDoubleNow()) {
// A tap on the board rolls too (not while the cube could be offered:
// that choice must not be lost to a stray tap).
const hit = hitTest(x, y, S.L);
if (hit?.type === 'point' || hit?.type === 'bar') { onRollClick(); return; }
} }
updateUI(); if (!myTurnToMove()) return;
handleTap(x, y);
}
// Auto-play single forced move function handleTap(x, y) {
const moves = getValidMoves(game); const hit = hitTest(x, y, S.L);
if (moves.length === 1 && game.movesLeft.length === 1) { const legal = getValidMoves(S.game);
setTimeout(() => { if (!hit) { S.sel = null; return; }
selectedPiece = { id: moves[0].piece.id, from: moves[0].from, moves }; const target = hit.type === 'point' ? hit.index : hit.type === 'tray' ? 'off' : null;
executeMove(moves[0].to); if (S.sel !== null && target !== null) {
}, 400); const opts = legal.filter(m => m.from === S.sel && m.to === target).sort((a, b) => a.steps - b.steps);
if (opts.length) { performMove(opts[0], 'me', afterMyMove); return; }
}
const from = hit.type === 'bar' ? 'bar' : hit.type === 'point' ? hit.index : null;
if (from !== null && legal.some(m => m.from === from)) {
if (S.sel === from) { S.sel = null; audio.play('tap'); return; }
S.sel = from;
audio.play('tap');
haptic('light');
return;
} }
// A destination tapped directly, reachable from one checker only: go.
if (target !== null) {
const opts = legal.filter(m => m.to === target);
const froms = new Set(opts.map(m => m.from));
if (froms.size === 1) { performMove(opts.sort((a, b) => a.steps - b.steps)[0], 'me', afterMyMove); return; }
} }
); if (S.sel !== null) { S.sel = null; return; }
audio.play('error', 'ui', { volume: 0.4 });
} }
// ── Doubling ── // ── Bot ────────────────────────────────────────────────────────────────────
function onDoubleClick() {
if (!match.cube || !canDouble(match.cube, myColor) || game.dice) return; function botTurn() {
const g = S.game;
if (S.over || g.isOver || g.turn === S.myColor || g.dice) return;
const bot = g.turn;
const cube = S.match.cube;
if (cube && canUseCube(S.match) && canDouble(cube, bot)) {
const botPips = getPipCount(g.state, bot), myPips = getPipCount(g.state, S.myColor);
if (shouldBotDouble(cube, botPips, myPips, S.params.difficulty, bot)) {
offerDouble(cube, bot);
audio.play('sfx_bg_double', 'game'); audio.play('sfx_bg_double', 'game');
haptic('heavy');
if (params.mode === 'bot') { showOffer();
offerDouble(match.cube, myColor);
const myPips = getPipCount(game.state, myColor, game.variant);
const oppPips = getPipCount(game.state, myColor === WHITE ? BLACK : WHITE, game.variant);
if (shouldBotAccept(match.cube, oppPips, myPips, params.difficulty)) {
acceptDouble(match.cube);
bus.emit('toast', { text: t('backgammon.bot_accepted', { n: match.cube.value }) });
} else {
declineDouble(match.cube);
bus.emit('toast', { text: t('backgammon.bot_declined') });
showGameResult(endGame(match, myColor, 1));
return; return;
} }
updateUI();
} else if (params.mode === 'live') {
offerDouble(match.cube, myColor);
syncToServer();
} }
doRoll(bot, null, () => afterRoll(bot));
} }
function onAcceptDouble() { function botPlay() {
if (!match.cube) return; const play = getBotPlay(S.game, S.params.difficulty);
acceptDouble(match.cube); S.botPlan = play ? play.moves.slice() : [];
audio.play('click'); later(botStep, 380);
container.querySelector('#double-offer').style.display = 'none';
updateUI();
if (params.mode === 'live') syncToServer();
} }
function onDeclineDouble() { function botStep() {
if (!match.cube) return; if (S.over || S.game.isOver) return;
declineDouble(match.cube); if (turnIsDone(S.game)) { later(() => endTurn(), 520); return; }
container.querySelector('#double-offer').style.display = 'none'; const legal = getValidMoves(S.game);
showGameResult(endGame(match, game.turn, 1)); const next = S.botPlan.shift();
} const m = (next && legal.find(x => x.from === next.from && x.steps === next.steps)) || legal[0];
performMove(m, 'bot', () => {
// ── Bot ── if (S.game.isOver) { onGameOver(); return; }
function doBotTurn() { later(botStep, 300);
if (game.turn === myColor || game.isOver) return; });
}
// Doubling consideration
if (match.cube && canUseCube(match) && canDouble(match.cube, game.turn) && !game.dice) { // ── Doubling ───────────────────────────────────────────────────────────────
const botPips = getPipCount(game.state, game.turn, game.variant);
const myPips = getPipCount(game.state, myColor, game.variant); function canDoubleNow() {
if (shouldBotDouble(match.cube, botPips, myPips, params.difficulty)) { const cube = S.match.cube;
offerDouble(match.cube, game.turn); return canRollNow() && !!cube && canUseCube(S.match) && canDouble(cube, S.myColor);
}
function onDoubleClick() {
if (!canDoubleNow()) return;
const cube = S.match.cube;
offerDouble(cube, S.myColor);
audio.play('sfx_bg_double', 'game'); audio.play('sfx_bg_double', 'game');
container.querySelector('#double-offer').style.display = ''; haptic('medium');
container.querySelector('#double-val').textContent = match.cube.value * 2; if (S.live) {
S.waitingAnswer = true;
emit({ kind: 'double' });
updateUI();
banner(t('backgammon.waiting_answer'), 'blue', 1600);
return; return;
} }
S.waitingAnswer = true;
updateUI();
banner(t('game.bot_thinking', { name: t('game.bot') }), 'blue', 900);
later(() => {
const g = S.game;
const botPips = getPipCount(g.state, S.oppColor), myPips = getPipCount(g.state, S.myColor);
S.waitingAnswer = false;
if (shouldBotAccept(cube, botPips, myPips, S.params.difficulty)) {
takeDouble(S.oppColor);
banner(t('backgammon.bot_accepted', { n: `<span class="num">${cube.value}</span>` }), 'green', 1300);
updateUI();
} else {
declineDouble(cube);
banner(t('backgammon.bot_declined'), 'green', 1300);
finishGame(S.myColor, 1, 'drop');
} }
}, 1000);
}
// Roll /** The cube is taken: it doubles, flips and moves to the taker's end of the bar. */
isRolling = true; function takeDouble() {
diceRollFrame = 0; const cube = S.match.cube;
audio.play('sfx_bg_dice_roll', 'game'); const prev = cube.value;
const from = cube.owner === null ? 'center' : cube.owner === S.myColor ? 'me' : 'opp';
acceptDouble(cube);
S.cubeAnim = { start: performance.now(), prev, from };
audio.play('flip');
later(() => audio.play('sfx_bg_double', 'game', { volume: 0.7 }), 600);
const P = palette();
const spot = cubeSpot(S.L, cube.owner === S.myColor ? 'me' : 'opp');
later(() => burst(spot.x, spot.y, [P.gold, P.redInk, P.card], 12, { speed: 100, star: true }), 700);
}
createRollAnimation( function showOffer() {
(frame, total) => { diceRollFrame = frame; diceRollTotal = total; }, const cube = S.match.cube;
() => { S.offerShown = true;
isRolling = false; const box = S.el.querySelector('#double-offer');
rollDice(game); S.el.querySelector('#double-val').textContent = String(cube.value * 2);
if (game.movesLeft.length === 0) { setTimeout(endTurn, 500); return; } S.el.querySelector('#double-hint').innerHTML = t('backgammon.offer_hint', { n: `<span class="num">${cube.value * 2}</span>`, m: `<span class="num">${cube.value}</span>` });
botPlayMoves(); box.hidden = false;
} const card = box.querySelector('.bgg-offer-card');
); card.classList.remove('is-in'); void card.offsetWidth; card.classList.add('is-in');
updateUI();
} }
function botPlayMoves() { function hideOffer() {
if (game.movesLeft.length === 0 || game.isOver) { if (!S) return;
if (!game.isOver) endTurn(); else handleGameOver(); S.offerShown = false;
return; const box = S.el.querySelector('#double-offer');
} if (box) box.hidden = true;
const move = getBotMove(game, params.difficulty); }
if (!move) { endTurn(); return; }
isAnimating = true; function onAcceptDouble() {
createMoveAnimation(move.from, move.to, layout, if (!S.offerShown || !S.match.cube?.offered) return;
(frame) => { animatingChecker = { x: frame.x, y: frame.y, type: game.turn }; }, hideOffer();
() => { takeDouble();
animatingChecker = null; haptic('medium');
audio.play('sfx_bg_piece_move', 'game'); if (S.live) { emit({ kind: 'take' }); updateUI(); return; }
const actions = applyMove(game, move.piece.id, move.steps);
if (actions?.some(a => a.type === 'hit')) {
audio.play('sfx_bg_piece_hit', 'game');
spawnParticles(logicalW / 2, logicalH / 2, ['#EF4444', '#F97316']);
}
isAnimating = false;
updateUI(); updateUI();
if (game.isOver) { handleGameOver(); return; } // The bot offered before its roll: now it rolls.
setTimeout(botPlayMoves, 300); later(() => doRoll(S.oppColor, null, () => afterRoll(S.oppColor)), 900);
} }
);
function onDeclineDouble() {
if (!S.offerShown || !S.match.cube?.offered) return;
hideOffer();
const offerer = S.match.cube.offeredBy;
declineDouble(S.match.cube);
if (S.live) emit({ kind: 'drop' });
finishGame(offerer, 1, 'drop');
} }
// ── Game Over ── // ── Game and match end ───────────────────────────────────────────────────
function handleGameOver() {
const isMyWin = game.winner === myColor; function onGameOver() {
audio.play(isMyWin ? 'sfx_bg_win_game' : 'lose', 'game'); const g = S.game;
if (isMyWin) spawnParticles(logicalW / 2, logicalH * 0.3, ['#FFD700', '#10B981', '#8B5CF6'], 20); finishGame(g.winner, g.score || 0, g.endReason || 'normal');
showGameResult(endGame(match, game.winner, game.score || 1));
} }
function showGameResult(result) { function finishGame(winner, gameScore, reason) {
if (S.over) return;
S.over = true;
S.phase = 'over';
S.sel = null;
updateUI(); updateUI();
if (result.matchOver) { const P = palette();
audio.play(result.winner === myColor ? 'sfx_bg_win_match' : 'lose', 'game'); const L = S.L;
setTimeout(() => {
if (params.mode === 'live') syncComplete(result); if (winner === null) {
scene.exitGameMode(); // Mahbousa: both mothers pinned. Nobody scores; the game is played again.
scene.replace('backgammon-result', { banner(t('backgammon.tie'), 'blue', 1800);
winner: result.winner === myColor ? 'you' : 'opponent', audio.play('flip');
scores: result.scores, matchLength: match.length, mode: params.mode S.pendingStart = later(startGame, 2300);
}); return;
}, 1800); }
const res = endGame(S.match, winner, gameScore);
const mine = winner === S.myColor;
updateUI();
if (mine) {
audio.play('sfx_bg_win_game', 'game');
haptic('success');
burst(L.w / 2, L.h * 0.45, [P.gold, P.goldSoft, P.redInk, P.card], 28, { speed: 200, star: true });
} else { } else {
const who = result.winner === myColor ? t('game.round_won') : t('game.round_lost'); audio.play('lose', 'game');
bus.emit('toast', { text: `${who} (+${result.stake})` }); haptic('error');
setTimeout(startNewGameRound, 2000); juice.shake(S.canvas, 6, 320);
} }
const extra = reason === 'drop' ? t('backgammon.dropped')
: reason === 'mother' ? t('backgammon.mother_pinned')
: S.game.variant !== 'thirtyone' && gameScore >= 2 ? t('backgammon.gammon')
: '';
const pts = `<span class="bgg-banner-pts num">${mine ? '+' : '-'}${res.stake}</span>`;
banner(`${mine ? t('game.round_won') : t('game.round_lost')} ${pts}${extra ? `<span class="bgg-banner-sub">${extra}</span>` : ''}`, mine ? 'green' : 'red', res.matchOver ? 1700 : 2100);
bumpScore(winner);
if (res.matchOver) later(() => showMatchResult(''), 1700);
else S.pendingStart = later(startGame, 2400);
} }
function endMatchWithWin() { function bumpScore(winner) {
const el = S.el.querySelector(winner === S.myColor ? '#score-bottom' : '#score-top');
if (!el) return;
el.classList.remove('is-bumped'); void el.offsetWidth; el.classList.add('is-bumped');
}
function showMatchResult(reason) {
if (!S || S.resultShown) return;
S.resultShown = true;
S.over = true;
if (S.live) { matchSession.destroy(); matchUi.cleanup(); }
const won = reason === 'abandon' ? true : reason === 'resign' ? false : S.match.winner === S.myColor;
scene.exitGameMode(); scene.exitGameMode();
scene.replace('backgammon-result', { const player = store.get('player') || {};
winner: 'you', scores: match.scores, matchLength: match.length, showResult(S.el.querySelector('.bgg'), {
mode: params.mode, reason: 'abandon' won,
myScore: S.match.scores[S.myColor],
oppScore: S.match.scores[S.oppColor],
matchLength: S.match.length,
mode: S.params.mode,
variant: S.match.variant,
difficulty: S.params.difficulty,
useCube: S.match.useCube,
reason,
matchId: S.live ? S.params.matchId : S.botMatchId,
myName: player.display_name || player.username || '',
oppName: S.params.mode === 'bot' ? t('game.bot') : (S.params.opponentName || ''),
}); });
} }
// ── Particles ── async function onQuit() {
function spawnParticles(x, y, colors, count = 10) { if (!S || S.resultShown) return;
for (let i = 0; i < count; i++) { audio.play('click');
const angle = (Math.PI * 2 * i) / count; const confirmed = await modal.confirm(t('game.leave_confirm'), {
const speed = 60 + Math.random() * 100; title: t('game.leave_title'), confirmText: t('game.leave'), cancelText: t('game.stay'), danger: true,
particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 80,
alpha: 1,
size: 2 + Math.random() * 3,
color: colors[i % colors.length]
}); });
if (!confirmed || !S) return;
if (S.live) {
try { await net.post('backgammon-match.php', { action: 'leave', match_id: S.params.matchId }); } catch (e) {}
showMatchResult('resign');
return;
} }
scene.exitGameMode();
scene.replace('backgammon-room', { mode: 'menu' });
} }
// ── Multiplayer ── // ── UI ─────────────────────────────────────────────────────────────────────
function syncToServer() {
if (params.mode !== 'live') return;
net.post('backgammon-match.php', {
action: 'move', match_id: params.matchId,
game_state: serializeState(game), match_state: getMatchState(match), cube: match.cube
}).catch(() => {});
}
function syncComplete(result) { function updateUI() {
net.post('backgammon-match.php', { if (!S || !S.el || !S.game) return;
action: 'complete', match_id: params.matchId, const g = S.game, el = S.el;
winner: result.winner === myColor ? store.get('auth.userId') : 'opponent', reason: 'normal' const set = (sel, v) => { const n = el.querySelector(sel); if (n && n.textContent !== String(v)) n.textContent = String(v); };
}).catch(() => {}); set('#pips-top', getPipCount(g.state, S.oppColor));
set('#pips-bottom', getPipCount(g.state, S.myColor));
set('#score-top', S.match.scores[S.oppColor]);
set('#score-bottom', S.match.scores[S.myColor]);
el.querySelectorAll('[data-len]').forEach(n => { n.textContent = String(S.match.length); });
const myTurn = g.turn === S.myColor && !S.over;
el.querySelector('#my-card')?.classList.toggle('is-active', myTurn);
el.querySelector('#opp-card')?.classList.toggle('is-active', !myTurn && !S.over);
el.querySelector('#opp-card')?.classList.toggle('is-thinking', !myTurn && !S.over && !g.dice && !S.offerShown);
const roll = el.querySelector('#btn-roll');
const dbl = el.querySelector('#btn-double');
const badge = el.querySelector('#turn-indicator');
const canRoll = canRollNow();
roll.hidden = !canRoll;
dbl.hidden = !canDoubleNow();
let text = '';
if (!canRoll) {
if (S.over) text = '';
else if (S.waitingAnswer) text = t('backgammon.waiting_answer');
else if (S.offerShown) text = t('backgammon.opponent_doubles');
else if (myTurn && S.phase === 'moving') text = t('backgammon.your_move');
else if (myTurn) text = t('game.your_turn');
else text = S.live ? t('game.opponent_turn') : t('game.bot_thinking', { name: t('game.bot') });
}
badge.hidden = canRoll || !text;
if (badge.textContent !== text) badge.textContent = text;
badge.classList.toggle('is-mine', myTurn);
} }
function handleServerState(data) { let bannerTimer = 0;
if (!data || data.error) return; function banner(html, tone = 'gold', ms = 1400) {
matchSession.markOpponentActive(); const b = S?.el.querySelector('#bgg-banner');
if (data.game_state) { if (!b) return;
const gs = data.game_state; b.className = `bgg-banner ribbon ${tone === 'red' ? '' : tone}`;
// Only merge authoritative server fields — preserve local-only state b.innerHTML = html;
// (selectedPiece, highlights, isRolling, isAnimating, animatingChecker, particles) b.hidden = false;
if (gs.state) game.state = gs.state; b.classList.remove('is-in'); void b.offsetWidth; b.classList.add('is-in');
if (gs.dice) game.dice = gs.dice; cancel(bannerTimer);
if (gs.movesLeft) game.movesLeft = gs.movesLeft; bannerTimer = later(() => { b.hidden = true; }, ms);
if (gs.movesPlayed) game.movesPlayed = gs.movesPlayed;
if (gs.turn !== undefined) game.turn = gs.turn;
if (gs.turnNumber !== undefined) game.turnNumber = gs.turnNumber;
if (gs.isOver !== undefined) game.isOver = gs.isOver;
if (gs.winner !== undefined) game.winner = gs.winner;
if (gs.score !== undefined) game.score = gs.score;
if (data.match_state) match.scores = data.match_state.scores || match.scores;
if (data.cube) match.cube = data.cube;
clearSelection();
updateUI();
if (game.isOver) { handleGameOver(); return; }
if (game.turn === myColor && game.movesLeft && game.movesLeft.length === 0 && game.dice) {
// Opponent finished their turn, now it's ours
game.dice = null;
}
}
} }
// ── Emotes ── // ── Reactions ──────────────────────────────────────────────────────────────
function setupEmotePanel(el) {
// Backgammon used to post reactions to `backgammon-match.php action:'emote'`, function setupReactions(el) {
// which wrote a `last_emote` column that NO client ever read — so the opponent reactions.start({ matchId: S.params.matchId, gameKey: 'backgammon', myId: S.myId, live: S.live });
// never saw anything. (That table does not exist in the database either.) reactions.seat(S.myId, el.querySelector('#my-card'), {
// Reactions now use the shared module and the shared channel.
reactions.start({
matchId: params.matchId,
gameKey: 'backgammon',
myId: store.get('auth.userId'),
live: params.mode === 'live',
});
reactions.seat(store.get('auth.userId'), el.querySelector('#my-card'), {
name: store.get('player.display_name') || store.get('player.username') || t('common.you'), name: store.get('player.display_name') || store.get('player.username') || t('common.you'),
avatarUrl: store.get('player.avatar_url'), avatarUrl: store.get('player.avatar_url'),
side: 'bottom', side: 'bottom',
}); });
reactions.seat('bgg-opponent', el.querySelector('#opp-card'), { reactions.seat(S.oppId || 'bgg-opponent', el.querySelector('#opp-card'), {
name: params.mode === 'live' ? t('common.opponent') : t('game.bot'), name: S.live ? (S.params.opponentName || t('common.opponent')) : t('game.bot'),
side: 'top', side: 'top',
}); });
reactions.mountPanel(document.body, el.querySelector('#btn-emote')); reactions.mountPanel(document.body, el.querySelector('#btn-emote'));
} }
function showBubble(text) { // ── Online ─────────────────────────────────────────────────────────────────
if (!container) return;
const b = document.createElement('div'); const parseGs = (x) => { if (!x) return null; if (typeof x === 'string') { try { return JSON.parse(x); } catch (e) { return null; } } return x; };
b.className = 'bgg-bubble';
b.textContent = text; async function initLive() {
container.appendChild(b); const s = S;
setTimeout(() => b.remove(), 2200); S.phase = 'boot';
const saved = matchSession.getRecoverableMatch();
const restoring = !!(saved && saved.matchId === S.params.matchId) || !!S.params.recovered;
if (restoring) matchUi.showReconnecting();
let row = null;
try { row = await net.post('backgammon-match.php', { action: 'get', match_id: S.params.matchId }); } catch (e) {}
if (!s.alive) return;
if (row && !row.error) {
// The match row decides what is played (both players may have asked for
// different lengths) and which seat is mine: a refreshed tab comes back
// with nothing but the match id.
const variant = ['sheshbesh', 'mahbousa', 'thirtyone'].includes(row.variant) ? row.variant : S.match.variant;
const len = Number(row.match_length) || S.match.length;
if (variant !== S.match.variant || len !== S.match.length) S.match = createMatch(len, variant, S.match.useCube);
const players = Array.isArray(row.players) ? row.players : [];
const colors = Array.isArray(row.colors) ? row.colors : [];
let seat = players.indexOf(S.myId);
let color = seat >= 0 ? colors[seat] : null;
if (!color && row.white_player_id) color = row.white_player_id === S.myId ? 'white' : row.black_player_id === S.myId ? 'black' : null;
if (color === 'white' || color === 'black') setMyColor(color === 'black' ? BLACK : WHITE);
if (!S.oppId) S.oppId = players.find(id => id && id !== S.myId) || (row.white_player_id === S.myId ? row.black_player_id : row.white_player_id) || null;
}
matchSession.create(S.params.matchId, 'backgammon', {
onOpponentMove: onServer,
// The shared live overlays: a 60 second ring when the opponent goes
// quiet (the win is claimed at zero), a banner when they come back.
onOpponentDisconnect: () => {
if (!S || S.resultShown) return;
matchUi.showOpponentDisconnect({ seconds: 60, onClaim: () => { if (S && !S.resultShown) { matchUi.cleanup(); showMatchResult('abandon'); } } });
},
onOpponentReconnect: () => { if (matchUi.isShowing() === 'disconnect') matchUi.showOpponentReconnect(); },
// Fired for an explicit leave (the row says abandoned) and by the session's
// own silence timer; only the first ends the match, the ring decides the other.
onOpponentAbandon: async () => {
if (!S || S.resultShown) return;
const s2 = S;
let r = null;
try { r = await net.post('backgammon-match.php', { action: 'get', match_id: s2.params.matchId }); } catch (e) {}
if (s2.alive && !s2.resultShown && r?.status === 'abandoned') { matchUi.cleanup(); showMatchResult('abandon'); }
},
onConnectionLost: () => matchUi.showConnectionLost(),
onConnectionRestored: () => matchUi.showConnectionRestored(),
});
if (restoring) matchUi.hideReconnecting();
const gs = parseGs(row?.game_state);
if (gs && gs.proto === 2 && gs.game) snap(gs);
else startGame();
} }
async function onQuit() { /** Take a seat: colours, plates and the board's side follow. */
audio.play('click'); function setMyColor(color) {
if (params.mode === 'live') { if (color === S.myColor) return;
const confirmed = await modal.confirm(t('game.leave_confirm'), { S.myColor = color;
title: t('game.leave_title'), confirmText: t('game.leave'), cancelText: t('game.stay'), danger: true S.oppColor = other(color);
S.configAdopted = color === WHITE || S.configAdopted;
const tone = (c) => (c === WHITE ? 'is-white' : 'is-black');
const me = S.el.querySelector('#my-card .bgg-token'), op = S.el.querySelector('#opp-card .bgg-token');
if (me) me.className = `bgg-token ${tone(color)}`;
if (op) op.className = `bgg-token ${tone(other(color))}`;
S.L = null;
resize();
}
function snapshot() {
return {
proto: 2,
seq: S.seq,
by: S.myId,
config: { variant: S.match.variant, length: S.match.length, useCube: S.match.useCube },
match: getMatchState(S.match),
game: serializeState(S.game),
log: S.log.slice(-40),
};
}
/** Record one of my actions and publish the new state. */
function emit(ev) {
if (!S.live) return;
S.seq++;
S.queuedSeq = S.seq;
S.log.push({ ...ev, seq: S.seq, g: S.match.gameNumber });
if (S.log.length > 60) S.log.splice(0, S.log.length - 60);
const body = {
action: 'move', match_id: S.params.matchId,
game_state: snapshot(), match_state: getMatchState(S.match), cube: S.match.cube,
};
const s = S;
S.postChain = S.postChain.then(() => net.post('backgammon-match.php', body)).catch(() => {
// One retry with the newest state, so a dropped request does not strand the opponent.
if (s.alive) return net.post('backgammon-match.php', { ...body, game_state: snapshot() }).catch(() => {});
}); });
if (!confirmed) return; }
net.post('backgammon-match.php', { action: 'leave', match_id: params.matchId }).catch(() => {});
matchSession.destroy(); function onServer(row) {
if (!S || !row || row.error || S.resultShown) return;
const gs = parseGs(row.game_state);
// Presence. Backgammon has no heartbeat presence on the server, so the
// opponent's own actions are the evidence they are here. While the game is
// waiting on me (my turn, or their double to answer) their silence is
// expected and must not read as a disconnect; on their turn it is measured.
const waitingOnMe = S.game && !S.over && ((S.game.turn === S.myColor && !S.waitingAnswer) || S.offerShown);
if (waitingOnMe) matchSession.markOpponentActive();
if (!gs || gs.proto !== 2 || gs.by === S.myId) return;
if (gs.seq <= S.queuedSeq) return;
matchSession.markOpponentActive();
S.lastSnap = gs;
if (!S.configAdopted && gs.config) {
S.configAdopted = true;
S.match.useCube = !!gs.config.useCube;
if (!S.match.useCube) S.match.cube = null;
else if (!S.match.cube && !S.match.crawford) S.match.cube = createCube();
} }
scene.exitGameMode(); const fresh = (gs.log || []).filter(e => e.seq > S.queuedSeq).sort((a, b) => a.seq - b.seq);
scene.replace('backgammon-room', { mode: 'menu' }); const contiguous = fresh.length && fresh[0].seq === S.queuedSeq + 1 && fresh[fresh.length - 1].seq === gs.seq
&& fresh.every((e, i) => e.seq === fresh[0].seq + i);
if (!contiguous) { snap(gs); return; }
S.replay.push(...fresh);
S.queuedSeq = gs.seq;
pumpReplay();
}
function pumpReplay() {
if (!S || S.replaying || !S.replay.length) return;
if (S.busy) { later(pumpReplay, 120); return; }
S.replaying = true;
const e = S.replay.shift();
const s = S;
applyRemote(e, () => {
if (!s.alive) return;
s.seq = e.seq;
s.log.push(e);
if (s.log.length > 60) s.log.splice(0, s.log.length - 60);
s.replaying = false;
if (!s.replay.length) verifyAgainst(s.lastSnap);
pumpReplay();
});
} }
// ── Styles ── function applyRemote(e, done) {
function getStyles() { if (e.g > S.match.gameNumber) startGame();
const g = S.game;
const opp = S.oppColor;
switch (e.kind) {
case 'roll':
if (g.turn !== opp || g.dice) { done(); return; }
doRoll(opp, e.values, () => { afterRoll(opp); done(); });
return;
case 'move': {
const m = getValidMoves(g).find(x => x.from === e.from && x.steps === e.steps);
if (!m) { done(); return; }
S.phase = 'opp';
performMove(m, 'remote', () => { if (S.game.isOver) onGameOver(); done(); });
return;
}
case 'end':
if (g.turn === opp && !g.isOver) endTurn(true);
done();
return;
case 'double':
if (S.match.cube && canDouble(S.match.cube, opp)) {
offerDouble(S.match.cube, opp);
audio.play('sfx_bg_double', 'game');
haptic('heavy');
showOffer();
}
done();
return;
case 'take':
if (S.match.cube?.offered) {
S.waitingAnswer = false;
takeDouble();
banner(t('backgammon.opp_accepted', { n: `<span class="num">${S.match.cube.value}</span>` }), 'green', 1300);
updateUI();
}
done();
return;
case 'drop':
if (S.match.cube?.offered) {
S.waitingAnswer = false;
const offerer = S.match.cube.offeredBy;
declineDouble(S.match.cube);
banner(t('backgammon.opp_declined'), 'green', 1300);
finishGame(offerer, 1, 'drop');
}
done();
return;
default:
done();
}
}
/** After a replay, the board must match the opponent's snapshot. */
function verifyAgainst(gs) {
if (!gs || gs.seq !== S.seq) return;
const mine = JSON.stringify(S.game.state.points.map(p => p.map(c => c.type))) + JSON.stringify([S.game.state.bar.map(b => b.length), S.game.state.outside.map(o => o.length), S.match.scores, S.match.cube && S.match.cube.value]);
const theirs = JSON.stringify(gs.game.state.points.map(p => p.map(c => c.type))) + JSON.stringify([gs.game.state.bar.map(b => b.length), gs.game.state.outside.map(o => o.length), gs.match.scores, gs.match.cube && gs.match.cube.value]);
if (mine !== theirs) snap(gs);
}
/** Restore everything from a snapshot (rejoin, or a replay that went astray). */
function snap(gs) {
cancel(S.pendingStart);
S.pendingStart = null;
S.flights = []; S.hidden.clear(); S.replay = []; S.replaying = false; S.busy = false;
if (gs.config) {
S.match.useCube = !!gs.config.useCube;
S.configAdopted = true;
}
applyMatchState(S.match, gs.match);
S.game = deserializeState(gs.game);
S.match.currentGame = S.game;
S.seq = gs.seq; S.queuedSeq = gs.seq;
S.log = (gs.log || []).slice(-40);
S.dice = S.game.dice ? { type: S.game.turn, values: S.game.dice, thr: null, landedAt: 0 } : null;
S.sel = null;
S.over = false;
hideOffer();
S.waitingAnswer = false;
if (S.match.cube?.offered) {
if (S.match.cube.offeredBy === S.myColor) S.waitingAnswer = true; else showOffer();
}
if (S.match.isOver) { updateUI(); showMatchResult(''); return; }
if (S.game.isOver) { S.over = true; updateUI(); S.pendingStart = later(startGame, 1200); return; }
if (S.game.turn === S.myColor && S.game.dice) {
S.phase = turnIsDone(S.game) ? 'done' : 'moving';
updateUI();
if (S.phase === 'done') later(() => endTurn(), 500);
return;
}
S.phase = S.game.turn === S.myColor ? 'idle' : 'opp';
updateUI();
}
// ── Test hook (localStorage el3ab_test = '1' only) ─────────────────────────
function installTestHook() {
if (!TEST) return;
const s = S;
window.__bgTest = {
_s: s,
get speed() { return s.speed; },
set speed(v) { s.speed = Math.max(0.25, Number(v) || 1); },
/** Queue the next rolls: [[6, 5], [3, 3]] (whoever rolls next takes the first). */
setDice(...pairs) { s.nextDice.push(...pairs); },
/** Lay out a position for the side to move (between turns only). */
setPosition(spec) {
if (s.busy || s.game.dice) return false;
setPosition(s.game, spec);
if (spec.turn !== undefined) s.game.turn = spec.turn;
s.sel = null; s.dice = null;
beginTurn();
return true;
},
setScores(scores) { s.match.scores = [...scores]; updateUI(); },
state() {
const g = s.game;
return {
variant: g?.variant, turn: g?.turn, myColor: s.myColor, dice: g?.dice, movesLeft: g?.movesLeft, movesPlayed: g?.movesPlayed,
legal: g && g.dice ? getValidMoves(g).map(m => ({ from: m.from, to: m.to, steps: m.steps, kind: m.kind })) : [],
points: g?.state.points.map(p => p.map(c => (c.type === WHITE ? 'w' : 'b')).join('')),
bar: g?.state.bar.map(b => b.length), off: g?.state.outside.map(o => o.length),
pips: g ? [getPipCount(g.state, WHITE), getPipCount(g.state, BLACK)] : null,
isOver: g?.isOver, winner: g?.winner, score: g?.score, endReason: g?.endReason,
match: { scores: [...s.match.scores], length: s.match.length, gameNumber: s.match.gameNumber, crawford: s.match.crawford, isOver: s.match.isOver, winner: s.match.winner, cube: s.match.cube ? { ...s.match.cube } : null, useCube: s.match.useCube },
busy: s.busy || s.flights.length > 0 || !!s.dice?.thr, phase: s.phase, sel: s.sel, over: s.over,
canRoll: canRollNow(), canDouble: canDoubleNow(), offerShown: s.offerShown, waitingAnswer: s.waitingAnswer,
resultShown: !!s.resultShown, seq: s.seq, replaying: s.replaying || s.replay.length > 0,
highlights: s.sel !== null && g?.dice ? [...new Set(getValidMoves(g).filter(m => m.from === s.sel).map(m => m.to))] : [],
};
},
/** Client (CSS px) coordinates of every tap target on the board. */
geometry() {
const L = s.L, rect = s.canvas.getBoundingClientRect();
const sx = rect.width / L.w, sy = rect.height / L.h;
const at = (p) => ({ x: Math.round(rect.left + p.x * sx), y: Math.round(rect.top + p.y * sy) });
const points = [];
for (let i = 0; i < 24; i++) {
const n = s.game.state.points[i].length;
points.push({ top: at(slotPos(L, i, Math.max(0, n - 1))), mid: at(slotPos(L, i, 1)) });
}
return {
canvas: { x: rect.left, y: rect.top, w: rect.width, h: rect.height },
points,
bar: at({ x: L.barX + L.B / 2, y: L.midY + (L.r * 3) }),
tray: at({ x: L.trayX + L.T / 2, y: L.fieldBottom - L.fieldH * 0.2 }),
layout: { w: L.w, h: L.h, r: L.r, pw: L.pw, flip: L.flip },
};
},
};
}
// ── Styles ─────────────────────────────────────────────────────────────────
function styles() {
return `<style> return `<style>
.bgg-container { .bgg {
display:flex;flex-direction:column;height:100%; position: relative;
background:linear-gradient(180deg,#060a12 0%,#0c1424 40%,#0a1018 100%); display: flex;
color:var(--text-primary);overflow:hidden; flex-direction: column;
font-family:var(--font-ar,'IBM Plex Sans Arabic',sans-serif); height: 100%;
position:relative; min-height: 0;
} overflow: hidden;
padding-block: calc(var(--safe-top) + 6px) 0;
/* ── Player Bars ── */ color: var(--text-primary);
.bgg-top-bar { }
display:flex;align-items:center;padding:10px 14px;gap:8px; .bgg [hidden] { display: none !important; }
background:linear-gradient(180deg,rgba(0,0,0,0.6),rgba(0,0,0,0.3)); .bgg-table { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: center; }
border-bottom:1px solid rgba(255,255,255,0.04);
} /* ── Player plates: ink tiles on a lip; the side to move gets a gold ring ── */
.bgg-bottom-bar { .bgg-plate {
display:flex;align-items:center;padding:10px 14px;gap:8px; position: relative;
background:linear-gradient(0deg,rgba(0,0,0,0.6),rgba(0,0,0,0.3)); display: flex;
border-top:1px solid rgba(255,255,255,0.04); align-items: center;
padding-bottom:max(10px, env(safe-area-inset-bottom, 0px)); gap: 10px;
} margin-inline: 8px;
.bgg-player-card { min-height: 58px;
display:flex;align-items:center;gap:10px;flex:1; padding: 6px 12px;
padding:6px 10px;border-radius:12px; border-radius: var(--r-xl);
background:rgba(255,255,255,0.02); background: var(--back-800);
border:1.5px solid transparent; box-shadow: 0 4px 0 var(--back-950);
transition:border-color 0.3s,box-shadow 0.3s; transition: box-shadow var(--dur-normal) var(--ease-out);
} flex-shrink: 0;
.bgg-player-card.bgg-active-turn {
border-color:rgba(52,211,153,0.4);
box-shadow:0 0 12px rgba(52,211,153,0.1);
} }
.bgg-plate.is-active { box-shadow: 0 0 0 2.5px var(--gold), 0 4px 0 var(--back-950); }
.bgg-opp { margin-bottom: 10px; }
.bgg-me { margin-top: 8px; }
.bgg-seat { position: relative; flex-shrink: 0; width: 42px; height: 42px; }
.bgg-avatar { .bgg-avatar {
width:36px;height:36px;border-radius:50%; display: flex; align-items: center; justify-content: center;
display:flex;align-items:center;justify-content:center; width: 42px; height: 42px;
background:linear-gradient(135deg,#1a2540,#0e1830); border-radius: 50%;
font-size:16px;overflow:hidden;flex-shrink:0; overflow: hidden;
border:2px solid rgba(255,255,255,0.1); background: var(--back-600);
box-shadow:0 2px 8px rgba(0,0,0,0.3);
}
.bgg-avatar-me { border-color:rgba(228,172,56,0.5); }
.bgg-avatar-opp { border-color:rgba(139,92,246,0.4); }
.bgg-player-info { flex:1;min-width:0; }
.bgg-name { font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.bgg-meta { display:flex;align-items:center;gap:8px;margin-top:1px; }
.bgg-level { font-size:var(--fs-xs);color:var(--text-muted);font-weight:600; }
.bgg-pips { font-size:var(--fs-xs);color:var(--text-secondary);font-weight:500; }
.bgg-pips::before { content:'⬡ ';opacity:0.5; }
.bgg-score {
font-size:20px;font-weight:800;color:var(--text-dim);min-width:24px;text-align:center;
font-variant-numeric:tabular-nums;
}
.bgg-my-score { color:var(--gold,#E4AC38); }
.bgg-match-info {
display:flex;flex-direction:column;align-items:center;gap:2px;
}
.bgg-cube-display {
font-size:14px;font-weight:800;color:var(--purple-light);
background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.25);
border-radius:6px;padding:2px 8px;
}
.bgg-match-length { font-size:var(--fs-xs);color:var(--text-muted); }
/* ── Board ── */
.bgg-board-area {
flex:1;display:flex;align-items:center;justify-content:center;
padding:4px 6px;position:relative;min-height:0;
}
#bg-canvas {
border-radius:8px;touch-action:none;cursor:pointer;
box-shadow:0 4px 24px rgba(0,0,0,0.5),0 0 0 1px rgba(255,255,255,0.04);
} }
.bgg-turn-badge { .bgg-plate.is-active .bgg-avatar { box-shadow: 0 0 0 2.5px var(--gold); }
position:absolute;top:6px;left:50%;transform:translateX(-50%); .bgg-avatar .avatar { width: 42px; height: 42px; }
font-size:var(--fs-xs);font-weight:700;padding:3px 10px;border-radius:20px; .bgg-bot-face { display: flex; }
backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px); .bgg-token {
pointer-events:none;transition:all 0.3s;opacity:0.9; position: absolute; bottom: -3px; inset-inline-end: -5px;
width: 18px; height: 18px; border-radius: 50%;
box-shadow: 0 0 0 2.5px var(--back-800);
}
.bgg-token.is-white { background: var(--bg-checker-white); }
.bgg-token.is-black { background: var(--bg-checker-black); box-shadow: 0 0 0 2.5px var(--back-800), inset 0 0 0 2px var(--back-600); }
.bgg-plate-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.bgg-name-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.bgg-name {
min-width: 0;
font-family: var(--font-display);
font-size: var(--fs-md);
line-height: 1.25;
color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bgg-chip {
display: none;
flex-shrink: 0;
padding: 2px 9px 3px;
border-radius: var(--r-full);
background: var(--btn-green-2);
color: #fff;
font-family: var(--font-display);
font-size: var(--fs-xs);
} }
.bgg-turn-mine { background:rgba(16,185,129,0.25);color:var(--success);border:1px solid rgba(52,211,153,0.3); } .bgg-opp .bgg-chip { background: var(--backgammon-primary); }
.bgg-turn-opp { background:rgba(239,68,68,0.15);color:var(--red-light);border:1px solid rgba(239,68,68,0.2); } .bgg-plate.is-active .bgg-chip { display: inline-block; animation: popIn 360ms var(--ease-pop); }
.bgg-thinking { display: none; gap: 3px; align-items: center; }
.bgg-thinking i { width: 5px; height: 5px; border-radius: 50%; background: var(--gold); animation: bggDot 1s var(--ease-in-out) infinite; }
.bgg-thinking i:nth-child(2) { animation-delay: 0.15s; }
.bgg-thinking i:nth-child(3) { animation-delay: 0.3s; }
.bgg-plate.is-thinking .bgg-thinking { display: inline-flex; }
@keyframes bggDot { 0%, 100% { opacity: 0.3; translate: 0 0; } 50% { opacity: 1; translate: 0 -3px; } }
.bgg-meta { display: flex; align-items: center; gap: 10px; min-width: 0; font-size: var(--fs-xs); font-weight: 500; color: var(--text-muted); white-space: nowrap; }
.bgg-pips { overflow: hidden; text-overflow: ellipsis; }
.bgg-pips .num { color: var(--text-secondary); font-weight: 700; }
.bgg-score { flex-shrink: 0; display: flex; flex-direction: column; align-items: center; min-width: 46px; }
.bgg-score-n { font-family: var(--font-display); font-size: var(--fs-2xl); line-height: 1; color: var(--gold); }
.bgg-score-n.is-bumped { animation: numberPop 520ms var(--ease-pop); }
.bgg-of { font-size: var(--fs-xs); font-weight: 700; color: var(--text-muted); white-space: nowrap; }
/* ── The board ── */
.bgg-board-area { position: relative; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; padding-inline: 4px; }
#bg-canvas { display: block; touch-action: none; cursor: pointer; }
.bgg-banner {
position: absolute;
top: 50%; inset-inline: 0;
margin-inline: auto;
width: max-content;
max-width: 88%;
translate: 0 -50%;
z-index: 5;
pointer-events: none;
flex-direction: column;
gap: 2px;
text-align: center;
font-size: var(--fs-lg);
white-space: normal;
}
.bgg-banner.is-in { animation: slamIn 460ms var(--ease-pop) both; }
.bgg-banner-pts { font-size: var(--fs-xl); margin-inline-start: 6px; }
.bgg-banner-sub { display: block; font-size: var(--fs-sm); opacity: 0.92; }
/* ── Controls ── */ /* ── Controls ── */
.bgg-controls { .bgg-controls {
display:flex;align-items:center;justify-content:center;gap:10px; display: flex;
padding:10px 14px; align-items: center;
background:linear-gradient(180deg,rgba(0,0,0,0.2),rgba(0,0,0,0.4)); gap: 10px;
min-height:56px; min-height: 92px;
transition:min-height 0.2s; padding: 10px 12px calc(12px + var(--safe-bottom));
} flex-shrink: 0;
.bgg-roll-btn { }
display:flex;align-items:center;gap:6px; .bgg-actions { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
padding:13px 32px;border-radius:14px;border:none; .bgg-roll-btn { flex: 1 1 auto; min-width: 0; padding-inline: 14px; font-size: var(--fs-lg); }
background:linear-gradient(135deg,var(--gold),var(--gold-dark)); .bgg-roll-btn.is-ready { animation: bounceIn 520ms var(--ease-pop); }
color:#fff;font-size:15px;font-weight:800;cursor:pointer; .bgg-double-btn { flex: 0 1 auto; min-width: 0; padding-inline: 12px; font-size: var(--fs-base); }
box-shadow:0 4px 16px rgba(228,172,56,0.35),inset 0 1px 0 rgba(255,255,255,0.2); @media (max-width: 400px) { .bgg-double-btn .gi { display: none; } .bgg-roll-btn .gi { width: 30px; height: 30px; } }
transition:transform 0.12s var(--ease-spring,cubic-bezier(0.34,1.56,0.64,1)),box-shadow 0.15s; .bgg-roll-btn span, .bgg-double-btn span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
} .bgg-turn-badge {
.bgg-roll-btn:active { transform:scale(0.92);box-shadow:0 2px 8px rgba(228,172,56,0.3); } flex: 1;
.bgg-cube-btn { min-height: 58px;
padding:10px 16px;min-height:var(--tap-min);border-radius:10px;border:none; display: flex; align-items: center; justify-content: center;
background:rgba(139,92,246,0.12);border:1.5px solid rgba(139,92,246,0.35); padding: 0 14px;
color:var(--purple-light);font-size:15px;font-weight:800;cursor:pointer; border-radius: var(--r-xl);
transition:transform 0.12s; background: var(--back-800);
} box-shadow: 0 4px 0 var(--back-950);
.bgg-cube-btn:active { transform:scale(0.9); } font-family: var(--font-display);
.bgg-action-btn { font-size: var(--fs-md);
width:var(--tap-min);height:var(--tap-min);border-radius:50%;border:none; color: var(--text-secondary);
background:rgba(255,255,255,0.04);color:var(--text-secondary); text-align: center;
font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center; }
border:1px solid var(--border); .bgg-turn-badge.is-mine { color: var(--gold); }
transition:background 0.15s;
} /* ── Doubling offer: a white card dealt onto the table ── */
.bgg-action-btn:active { background:rgba(255,255,255,0.1); } .bgg-offer {
.bgg-quit { color:var(--error);border-color:rgba(239,68,68,0.15); } position: absolute; inset: 0; z-index: 30;
.bgg-emote-btn { color:var(--text-primary); } display: flex; align-items: center; justify-content: center;
padding: 16px;
/* ── Doubling Offer ── */ background: rgba(13, 12, 18, 0.72);
.bgg-double-offer { }
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); .bgg-offer-card { width: min(100%, 330px); padding: 26px 20px 20px; text-align: center; }
background:rgba(10,15,25,0.96);border:1.5px solid rgba(228,172,56,0.4); .bgg-offer-card.is-in { animation: flipIn 560ms var(--ease-pop) both; }
border-radius:20px;padding:24px 28px;text-align:center;z-index:20; .bgg-offer-cube {
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px); width: 72px; height: 72px;
box-shadow:0 16px 48px rgba(0,0,0,0.6); margin: 0 auto 14px;
} display: flex; align-items: center; justify-content: center;
.bgg-double-icon { border-radius: 18px;
font-size:28px;font-weight:900;color:var(--gold);margin-bottom:8px; background: var(--card);
} box-shadow: 0 0 0 2px var(--card-line), 0 6px 0 var(--card-edge);
.bgg-double-offer p { margin:0 0 16px;font-size:14px;color:var(--text-light); } font-family: var(--font-display);
.bgg-double-actions { display:flex;gap:10px;justify-content:center; } font-size: 38px;
.bgg-accept-btn,.bgg-decline-btn { color: var(--red-ink);
padding:11px 24px;border-radius:12px;border:none; animation: slamIn 560ms var(--ease-pop) 220ms both;
font-weight:700;font-size:14px;cursor:pointer; }
transition:transform 0.12s; .bgg-offer-title { font-family: var(--font-display); font-size: var(--fs-xl); color: var(--ink); }
} .bgg-offer-hint { margin-top: 4px; font-size: var(--fs-sm); font-weight: 500; color: var(--ink-2); }
.bgg-accept-btn { background:linear-gradient(135deg,var(--emerald),#059669);color:#fff;box-shadow:0 3px 12px rgba(16,185,129,0.3); } .bgg-offer-actions { display: flex; gap: 10px; margin-top: 18px; }
.bgg-decline-btn { background:rgba(239,68,68,0.15);color:var(--red-light);border:1.5px solid rgba(239,68,68,0.3); } .bgg-offer-actions .btn { flex: 1; min-width: 0; }
.bgg-accept-btn:active,.bgg-decline-btn:active { transform:scale(0.93); }
@media (max-height: 680px) {
/* ── Emotes ── */ .bgg-plate { min-height: 50px; padding-block: 4px; }
.bgg-emote-panel { .bgg-controls { min-height: 84px; padding-block: 8px calc(8px + var(--safe-bottom)); }
position:absolute;bottom:72px;left:50%;transform:translateX(-50%); .bgg-controls .btn-xl { min-height: 64px; }
background:rgba(10,15,25,0.96);border:1px solid rgba(255,255,255,0.08); }
border-radius:16px;padding:14px;z-index:20; @media (prefers-reduced-motion: reduce) {
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px); .bgg-banner.is-in, .bgg-offer-card.is-in, .bgg-offer-cube, .bgg-roll-btn.is-ready { animation: none; }
box-shadow:0 12px 40px rgba(0,0,0,0.5);
}
.bgg-emotes { display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px; }
.bgg-emote-btn {
width:40px;height:40px;border-radius:10px;border:none;
background:rgba(255,255,255,0.04);font-size:22px;cursor:pointer;
transition:transform 0.1s,background 0.15s;
}
.bgg-emote-btn:active { transform:scale(0.88);background:rgba(255,255,255,0.12); }
.bgg-phrases { display:flex;flex-direction:column;gap:4px; }
.bgg-phrase-btn {
padding:7px 12px;border-radius:8px;border:none;
background:rgba(255,255,255,0.03);color:var(--text-secondary);
font-size:12px;cursor:pointer;text-align:right;
transition:background 0.15s;
}
.bgg-phrase-btn:active { background:rgba(255,255,255,0.08); }
/* ── Bubble ── */
.bgg-bubble {
position:absolute;bottom:100px;left:50%;transform:translateX(-50%);
font-size:36px;pointer-events:none;
animation:bubblePop 2.2s var(--ease-out,cubic-bezier(0.16,1,0.3,1)) forwards;
}
@keyframes bubblePop {
0%{opacity:1;transform:translateX(-50%) translateY(0) scale(1)}
100%{opacity:0;transform:translateX(-50%) translateY(-50px) scale(1.3)}
} }
</style>`; </style>`;
} }
// Backgammon result. The table dims, an ivory card flips in with a ribbon on
// its top edge (green "you won", red "you lost"), a big sticker sits on it
// (a trophy and confetti on a win), the match score counts up, then what the
// match paid: coins and XP count up and, on a win, the coins fly to the purse.
//
// showResult() lays this over whatever is under it: the game scene calls it
// over its own board; the registered scene shows it over the table.
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import { emoji } from '../../../core/theme.js'; import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js';
import * as juice from '../../../core/juice.js';
import { countUp, haptic } from '../../../core/feel.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon, gicon } from '../../../core/icons.js';
import { esc } from '../../../core/ui.js';
let confettiRaf = null; const reducedMotion = () => !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
export function mountResult(el, params) { /**
const { winner = 'you', scores = [0, 0], matchLength = 3, mode = 'bot', reason = '' } = params || {}; * opts: won, myScore, oppScore, matchLength, mode ('bot' | 'live'), variant,
const didWin = winner === 'you'; * difficulty, useCube, reason ('' | 'abandon' | 'resign' | 'drop'), matchId,
* oppName, myName. Returns the overlay element.
audio.play(didWin ? 'sfx_bg_win_match' : 'lose', 'game'); */
export function showResult(host, opts) {
el.innerHTML = ` const {
<div class="bgr-wrap"> won = false, myScore = 0, oppScore = 0, matchLength = 1, mode = 'bot', variant = 'sheshbesh',
<canvas class="bgr-confetti" id="confetti-canvas"></canvas> reason = '', oppName = '', myName = '',
<div class="bgr-content"> } = opts || {};
<div class="bgr-hero"> host.querySelector('.bgr-overlay')?.remove();
<div class="bgr-trophy-ring ${didWin ? 'bgr-win-ring' : 'bgr-lose-ring'}">
<div class="bgr-trophy">${didWin ? emoji('trophy', '🏆', 56) : emoji('pensive', '😔', 56)}</div>
</div>
<h1 class="bgr-title">${didWin ? t('backgammon.win_title') : t('backgammon.lose_title')}</h1>
<p class="bgr-subtitle">${reason === 'abandon' ? t('backgammon.abandon_subtitle') : didWin ? t('backgammon.win_subtitle') : t('backgammon.lose_subtitle')}</p>
</div>
<div class="bgr-scoreboard"> const sub = reason === 'abandon' ? t('backgammon.abandon_subtitle')
<div class="bgr-score-col"> : reason === 'resign' ? t('backgammon.result_resigned')
<div class="bgr-score-val ${didWin ? 'bgr-val-win' : ''}">${scores[0]}</div> : won ? t('backgammon.win_subtitle') : t('backgammon.lose_subtitle');
<div class="bgr-score-label">${t('common.you')}</div> const you = myName || t('common.you');
</div> const them = oppName || (mode === 'bot' ? t('game.bot') : t('common.opponent'));
<div class="bgr-score-vs">
<div class="bgr-vs-line"></div> const ov = document.createElement('div');
<span class="bgr-vs-text">${t('backgammon.from_match', { n: matchLength })}</span> ov.className = `bgr-overlay ${won ? 'is-win' : 'is-loss'}`;
<div class="bgr-vs-line"></div> ov.innerHTML = `
<div class="bgr-dim" aria-hidden="true"></div>
<div class="bgr-stage">
<article class="deck-card bgr-card" role="dialog" aria-modal="true" aria-labelledby="bgr-word">
<h1 class="ribbon ${won ? 'green' : ''} bgr-ribbon" id="bgr-word">${won ? t('backgammon.result_win') : t('backgammon.result_loss')}</h1>
<div class="bgr-sticker" aria-hidden="true">${won ? gicon('trophy', 112) : gicon('backgammon', 96)}</div>
<p class="bgr-sub">${sub}</p>
<div class="bgr-score" aria-label="${t('backgammon.game_score')}">
<div class="bgr-side is-me">
<span class="bgr-n num" id="bgr-my">0</span>
<span class="bgr-who" dir="auto">${esc(you)}</span>
</div> </div>
<div class="bgr-score-col"> <span class="bgr-dash" aria-hidden="true"></span>
<div class="bgr-score-val ${!didWin ? 'bgr-val-win' : ''}">${scores[1]}</div> <div class="bgr-side">
<div class="bgr-score-label">${t('common.opponent')}</div> <span class="bgr-n num" id="bgr-opp">0</span>
<span class="bgr-who" dir="auto">${esc(them)}</span>
</div> </div>
</div> </div>
<p class="bgr-len">${t('backgammon.' + variant)} <span class="bgr-dot" aria-hidden="true"></span> ${t('backgammon.game_match_to', { n: `<span class="num">${Number(matchLength) || 1}</span>` })}</p>
<div class="bgr-rewards" style="display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;justify-content:center;"> <dl class="bgr-pay" id="bgr-pay" hidden>
<div style="display:flex;align-items:center;gap:4px;padding:8px 14px;background:rgba(251,191,36,0.1);border:1px solid rgba(251,191,36,0.2);border-radius:10px;"> <div class="bgr-line" id="bgr-coins-line" hidden>
<span style="font-size:16px;">${emoji('coin', '🪙', 16)}</span> <dt>${t('game.coins')}</dt>
<span style="font-size:14px;font-weight:700;color:var(--amber);">+${didWin ? 50 : 10}</span> <dd class="bgr-coins"><span class="bgr-coin-icon">${gicon('coin', 26)}</span><span class="num" id="bgr-coins">+0</span></dd>
</div> </div>
<div style="display:flex;align-items:center;gap:4px;padding:8px 14px;background:rgba(139,92,246,0.1);border:1px solid rgba(139,92,246,0.2);border-radius:10px;"> <div class="bgr-line" id="bgr-xp-line" hidden>
<span style="font-size:16px;">${emoji('star', '⭐', 16)}</span> <dt>${t('game.xp')}</dt>
<span style="font-size:14px;font-weight:700;color:var(--purple-light);">+15 XP</span> <dd class="bgr-xp"><span class="bgr-xp-icon">${gicon('star', 24)}</span><span class="num" id="bgr-xp">+0</span></dd>
</div> </div>
<div class="bgr-line" id="bgr-rating-line" hidden>
<dt>${t('game.rating')}</dt>
<dd class="num" id="bgr-rating">0</dd>
</div> </div>
</dl>
</article>
<div class="bgr-buttons"> <div class="bgr-buttons">
<button class="bgr-btn bgr-btn-primary" id="btn-rematch"> <button class="btn btn-primary btn-xl btn-block" id="btn-rematch">${gicon('refresh', 30)}<span>${t('game.play_again')}</span></button>
${emoji('fire', '🔥', 18)} ${t('game.play_again')} <button class="btn btn-secondary btn-lg btn-block" id="btn-exit">${icon('back', 20)}<span>${t('backgammon.result_home')}</span></button>
</button>
<button class="bgr-btn bgr-btn-secondary" id="btn-exit">
${t('game.back_to_menu')}
</button>
</div>
</div> </div>
</div> </div>
${getResultStyles()} ${resultStyles()}
`; `;
host.appendChild(ov);
el.querySelector('#btn-rematch').onclick = () => { // The moment: fanfare or sting, haptics, confetti.
audio.play('flip');
if (won) {
audio.play('sfx_bg_win_match', 'game');
haptic('success');
if (!reducedMotion()) {
setTimeout(() => {
const r = ov.querySelector('.bgr-sticker')?.getBoundingClientRect();
if (r) { juice.confetti(r.left + r.width / 2, r.top + r.height / 2, 46); juice.starBurst?.(r.left + r.width / 2, r.top + r.height / 2, 8); }
}, 520);
}
} else {
audio.play('lose', 'game');
haptic('error');
if (!reducedMotion()) setTimeout(() => juice.shake(ov.querySelector('.bgr-card'), 5, 320), 560);
}
// The score counts up once the card has turned.
setTimeout(() => {
if (!ov.isConnected) return;
countUp(ov.querySelector('#bgr-my'), 0, Number(myScore) || 0, { duration: 650 });
countUp(ov.querySelector('#bgr-opp'), 0, Number(oppScore) || 0, { duration: 650 });
}, reducedMotion() ? 0 : 640);
// What the match paid: only what the server says it paid.
payout(ov, opts);
ov.querySelector('#btn-rematch').addEventListener('click', () => {
audio.play('click'); audio.play('click');
stopConfetti(); playAgain(opts);
scene.replace('backgammon-room', { mode: 'menu' }); });
}; ov.querySelector('#btn-exit').addEventListener('click', () => {
el.querySelector('#btn-exit').onclick = () => {
audio.play('click'); audio.play('click');
stopConfetti();
scene.exitGameMode(); scene.exitGameMode();
bus.emit('navigate', { world: 'play', scene: 'play-table' }); bus.emit('navigate', { world: 'play', scene: 'play-table' });
}; });
return ov;
if (didWin) startConfetti(el.querySelector('#confetti-canvas')); }
bus.emit('coins:earned', { amount: didWin ? 50 : 10 }); function playAgain(opts) {
bus.emit('xp:earned', { amount: 15 }); if (opts.mode === 'live') {
scene.exitGameMode();
scene.replace('play-queue', { game: 'backgammon', variant: opts.variant, matchLength: opts.matchLength, useCube: opts.useCube });
return;
}
scene.enterGameMode();
scene.replace('backgammon-game', {
mode: 'bot', variant: opts.variant, matchLength: opts.matchLength,
difficulty: opts.difficulty || 'medium', useCube: opts.useCube !== false,
});
} }
function startConfetti(canvas) { async function payout(ov, opts) {
if (!canvas) return; if (!opts.won || opts.reason === 'resign') return;
const ctx = canvas.getContext('2d'); const myId = store.get('auth.userId');
const w = canvas.parentElement.clientWidth; if (!myId || !opts.matchId) return;
const h = canvas.parentElement.clientHeight; let rewards = null;
canvas.width = w; try {
canvas.height = h; const res = await net.post('backgammon-match.php', {
action: 'complete',
const colors = ['#FFD700', '#FF6347', '#10B981', '#8B5CF6', '#06B6D4', '#F59E0B', '#EC4899']; match_id: opts.matchId,
const confetti = []; winner: myId,
// 'abandon' would zero the winner's own reward in complete_match
for (let i = 0; i < 80; i++) { // (it zeroes the last-placed player, and the winner is the only one listed).
confetti.push({ reason: 'normal',
x: Math.random() * w,
y: -20 - Math.random() * h * 0.5,
vx: (Math.random() - 0.5) * 3,
vy: 1.5 + Math.random() * 3,
size: 4 + Math.random() * 6,
color: colors[Math.floor(Math.random() * colors.length)],
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.15,
shape: Math.random() > 0.5 ? 'rect' : 'circle'
}); });
} rewards = Array.isArray(res?.rewards) ? res.rewards.find(r => r.player_id === myId) : null;
if (!rewards && res && (res.coins_earned || res.xp_earned)) rewards = { coins: res.coins_earned, xp: res.xp_earned, rating_change: res.rating_change };
} catch (e) { rewards = null; }
if (!rewards || !ov.isConnected) return;
const coins = Number(rewards.coins) || 0;
const xp = Number(rewards.xp) || 0;
const rating = Number(rewards.rating_change) || 0;
ov.querySelector('#bgr-pay').hidden = !(coins || xp || rating);
const show = (id, on) => { ov.querySelector(id).hidden = !on; };
show('#bgr-coins-line', coins > 0);
show('#bgr-xp-line', xp > 0);
show('#bgr-rating-line', !!rating);
function draw() { const player = store.get('player');
ctx.clearRect(0, 0, w, h); if (player && (coins || xp)) store.set('player', { ...player, coins: (Number(player.coins) || 0) + coins, xp: (Number(player.xp) || 0) + xp });
for (const c of confetti) {
c.x += c.vx; const signed = (n) => `${n > 0 ? '+' : ''}${Math.round(n)}`;
c.y += c.vy; const coinsEl = ov.querySelector('#bgr-coins');
c.rot += c.vr; countUp(coinsEl, 0, coins, { duration: 800, format: signed });
c.vx += (Math.random() - 0.5) * 0.1; countUp(ov.querySelector('#bgr-xp'), 0, xp, { duration: 800, format: signed, tick: false });
if (c.y > h + 20) { c.y = -20; c.x = Math.random() * w; } if (rating) countUp(ov.querySelector('#bgr-rating'), 0, rating, { duration: 800, format: signed, tick: false });
ctx.save(); if (coins > 0 && !reducedMotion()) {
ctx.translate(c.x, c.y); setTimeout(() => {
ctx.rotate(c.rot); const from = ov.querySelector('.bgr-coin-icon')?.getBoundingClientRect();
ctx.fillStyle = c.color; if (from) juice.coinFlyTo(from.left + from.width / 2, from.top + from.height / 2, '.hud-coins', Math.min(10, 4 + Math.round(coins / 10)));
if (c.shape === 'rect') { bus.emit('coins:earned', { amount: coins });
ctx.fillRect(-c.size / 2, -c.size / 4, c.size, c.size / 2); bus.emit('xp:earned', { amount: xp });
}, 500);
} else { } else {
ctx.beginPath(); bus.emit('coins:earned', { amount: coins });
ctx.arc(0, 0, c.size / 2, 0, Math.PI * 2); bus.emit('xp:earned', { amount: xp });
ctx.fill();
}
ctx.restore();
}
confettiRaf = requestAnimationFrame(draw);
} }
draw();
} }
function stopConfetti() { /** The registered scene: the result over the table (or a picture of the board). */
if (confettiRaf) { cancelAnimationFrame(confettiRaf); confettiRaf = null; } export function mountResult(el, params) {
const p = params || {};
// Older callers passed winner: 'you' | 'opponent' and scores [mine, theirs].
const won = p.won ?? (p.winner === 'you');
const scores = p.scores || [0, 0];
el.innerHTML = `<div class="bgr-host">${p.snapshot ? `<img class="bgr-snap" src="${String(p.snapshot).replace(/"/g, '')}" alt="">` : ''}</div>`;
showResult(el.querySelector('.bgr-host'), {
...p, won,
myScore: p.myScore ?? scores[0], oppScore: p.oppScore ?? scores[1],
});
} }
function getResultStyles() { function resultStyles() {
return `<style> return `<style>
.bgr-wrap { .bgr-host { position: relative; flex: 1; min-height: 100%; display: flex; align-items: center; justify-content: center; }
position:relative;display:flex;align-items:center;justify-content:center; .bgr-snap { max-width: 100%; max-height: 100%; object-fit: contain; }
height:100%; .bgr-overlay { position: absolute; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; }
background:linear-gradient(180deg,#060A10 0%,#0A1020 50%,#101828 100%); .bgr-dim {
overflow:hidden; position: absolute; inset: 0;
} background: rgba(13, 12, 18, 0.8);
.bgr-confetti { animation: bgrDim var(--dur-slow) var(--ease-out) both;
position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;
}
.bgr-content {
position:relative;z-index:1;
display:flex;flex-direction:column;align-items:center;padding:24px;width:100%;
} }
.bgr-hero { text-align:center;margin-bottom:28px; } @keyframes bgrDim { from { opacity: 0; } }
.bgr-trophy-ring { .bgr-stage {
width:90px;height:90px;border-radius:50%;margin:0 auto 16px; position: relative;
display:flex;align-items:center;justify-content:center; width: min(92%, 360px);
animation:trophyFloat 3s ease-in-out infinite; max-height: 100%;
overflow-y: auto;
scrollbar-width: none;
padding: 34px 0 calc(var(--s-4) + var(--safe-bottom));
display: flex; flex-direction: column; align-items: stretch; gap: var(--s-4);
} }
.bgr-win-ring { .bgr-card {
background:conic-gradient(from 0deg,rgba(212,148,10,0.3),rgba(255,215,0,0.1),rgba(212,148,10,0.3)); padding: 40px var(--s-5) var(--s-5);
box-shadow:0 0 40px rgba(212,148,10,0.2); text-align: center;
animation: flipIn 640ms var(--ease-pop) 80ms both;
transform-origin: 50% 40%;
} }
.bgr-lose-ring { .bgr-ribbon {
background:conic-gradient(from 0deg,rgba(100,100,100,0.2),rgba(60,60,60,0.1),rgba(100,100,100,0.2)); position: absolute;
top: 0; inset-inline: 0;
margin-inline: auto;
width: max-content;
max-width: 86%;
translate: 0 -55%;
font-size: var(--fs-2xl);
padding: 6px 34px 10px;
animation: slamIn 520ms var(--ease-pop) 520ms both;
white-space: nowrap;
} }
.bgr-trophy { font-size:56px; } .bgr-sticker {
.bgr-title { font-size:22px;font-weight:800;color:var(--text-primary);margin:0; } display: flex; justify-content: center;
.bgr-subtitle { font-size:13px;color:var(--text-muted);margin:8px 0 0; } margin: 2px 0 var(--s-1);
@keyframes trophyFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} } animation: bounceIn 620ms var(--ease-pop) 700ms both;
.bgr-scoreboard {
display:flex;align-items:center;gap:20px;
padding:24px 36px;border-radius:20px;
background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);
backdrop-filter:blur(8px);margin-bottom:32px;
} }
.bgr-score-col { display:flex;flex-direction:column;align-items:center;gap:6px; } .is-loss .bgr-sticker { filter: grayscale(0.6); opacity: 0.85; }
.bgr-score-val { font-size:36px;font-weight:900;color:var(--text-muted);line-height:1; } .is-win .bgr-sticker .gi { animation: float 3.2s var(--ease-in-out) 1.4s infinite; }
.bgr-val-win { color:var(--gold-dark);text-shadow:0 0 20px rgba(212,148,10,0.4); } .bgr-sub { font-size: var(--fs-sm); font-weight: 500; color: var(--ink-2); margin-bottom: var(--s-3); }
.bgr-score-label { font-size:12px;color:var(--text-dim);font-weight:600; }
.bgr-score-vs { display:flex;align-items:center;gap:8px; } .bgr-score {
.bgr-vs-line { width:20px;height:1px;background:rgba(255,255,255,0.08); } display: flex; align-items: center; justify-content: center; gap: var(--s-5);
.bgr-vs-text { font-size:11px;color:var(--text-dim);white-space:nowrap; } padding: var(--s-3) var(--s-2);
border-radius: var(--r-lg);
.bgr-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:280px; } background: var(--card-2);
.bgr-btn {
padding:15px 24px;border-radius:14px;border:none;
font-size:15px;font-weight:700;cursor:pointer;text-align:center;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1);
}
.bgr-btn:active { transform:scale(0.93); }
.bgr-btn-primary {
background:linear-gradient(135deg,var(--gold-dark),#8B4513);color:#fff;
box-shadow:0 6px 20px rgba(212,148,10,0.35);
} }
.bgr-btn-secondary { .bgr-side { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; flex: 1; }
background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);color:var(--text-secondary); .bgr-n { font-family: var(--font-display); font-size: var(--fs-3xl); line-height: 1; color: var(--ink); }
.bgr-side.is-me .bgr-n { color: var(--red-ink); }
.is-win .bgr-side.is-me .bgr-n { color: var(--win-ink); }
.bgr-who { max-width: 100%; font-size: var(--fs-xs); font-weight: 700; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bgr-dash { width: 16px; height: 3px; border-radius: 2px; background: var(--card-line); flex-shrink: 0; }
.bgr-len { margin-top: var(--s-2); font-size: var(--fs-xs); font-weight: 600; color: var(--ink-3); display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
.bgr-dot { width: 4px; height: 4px; border-radius: 50%; background: var(--card-line); }
.bgr-pay { margin-top: var(--s-3); border-top: 2px solid var(--card-line); padding-top: var(--s-2); animation: popIn 420ms var(--ease-pop) both; }
.bgr-line { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); min-height: 44px; }
.bgr-line dt { font-size: var(--fs-sm); font-weight: 500; color: var(--ink-2); }
.bgr-line dd { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-display); font-size: var(--fs-xl); color: var(--ink); }
.bgr-coins { color: var(--gold-ink) !important; }
.bgr-coin-icon, .bgr-xp-icon { display: flex; }
.bgr-buttons { display: flex; flex-direction: column; gap: var(--s-4); animation: deal var(--dur-slow) var(--ease-deal) 900ms both; --deal-rot: 0deg; }
.bgr-buttons .gi { flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) {
.bgr-card, .bgr-ribbon, .bgr-sticker, .bgr-buttons, .bgr-dim { animation: none; }
} }
</style>`; </style>`;
} }
...@@ -5,6 +5,12 @@ import * as store from '../../../core/store.js'; ...@@ -5,6 +5,12 @@ import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js'; import { icon, GAME_MARK } from '../../../core/icons.js';
import { screenHead, modeButton, roomHero } from '../../../core/ui.js'; import { screenHead, modeButton, roomHero } from '../../../core/ui.js';
import { variantInfo } from '../logic/rules.js';
// The match lengths offered per variant (31 is played to 31), and whether the
// cube starts switched on: backgammon uses it, mahbousa and 31 traditionally do not.
const LENGTH_KEYS = ['short_match', 'medium_match', 'normal_match', 'long_match'];
const CUBE_DEFAULT = { sheshbesh: true, mahbousa: false, thirtyone: false };
export function mountRoom(el, params) { export function mountRoom(el, params) {
const { mode = 'menu' } = params || {}; const { mode = 'menu' } = params || {};
...@@ -98,15 +104,13 @@ function renderSetup(el, params) { ...@@ -98,15 +104,13 @@ function renderSetup(el, params) {
${chip('variant', 'mahbousa', false, `<span class="bg-chip-label">${t('backgammon.mahbousa')}</span>`)} ${chip('variant', 'mahbousa', false, `<span class="bg-chip-label">${t('backgammon.mahbousa')}</span>`)}
${chip('variant', 'thirtyone', false, `<span class="bg-chip-label">${t('backgammon.thirtyone')}</span>`)} ${chip('variant', 'thirtyone', false, `<span class="bg-chip-label">${t('backgammon.thirtyone')}</span>`)}
</div> </div>
<p class="bg-rule" id="variant-rule">${t('backgammon.sheshbesh_desc')}</p>
</section> </section>
<!-- Match Length --> <!-- Match Length -->
<section class="bg-section"> <section class="bg-section">
<h2 class="section-title bg-section-title">${t('backgammon.match_length')}</h2> <h2 class="section-title bg-section-title">${t('backgammon.match_length')}</h2>
<div class="chips bg-grid" id="length-grid"> <div class="chips bg-grid" id="length-grid">${lengthChips('sheshbesh')}</div>
${[[1, 'short_match'], [3, 'medium_match'], [5, 'normal_match'], [7, 'long_match']].map(([n, key]) =>
chip('len', n, n === 3, `<span class="num bg-chip-num">${n}</span><span class="bg-chip-label">${t('backgammon.' + key)}</span>`)).join('')}
</div>
</section> </section>
${!isOnline ? ` ${!isOnline ? `
...@@ -143,8 +147,23 @@ function renderSetup(el, params) { ...@@ -143,8 +147,23 @@ function renderSetup(el, params) {
let difficulty = 'medium'; let difficulty = 'medium';
let useCube = true; let useCube = true;
setupChipGroup(el, '#variant-grid', val => { variant = val; }, 'variant'); const pickVariant = (val) => {
setupChipGroup(el, '#length-grid', val => { matchLength = parseInt(val); }, 'len'); variant = val;
el.querySelector('#variant-rule').textContent = t('backgammon.' + val + '_desc');
// The lengths and the cube default follow the variant.
const grid = el.querySelector('#length-grid');
grid.innerHTML = lengthChips(val);
matchLength = variantInfo(val).defaultLength;
setupChipGroup(el, '#length-grid', v => { matchLength = parseInt(v, 10); }, 'len');
useCube = CUBE_DEFAULT[val];
el.querySelectorAll('#cube-grid .bg-chip').forEach(b => {
const on = (b.dataset.cube === 'on') === useCube;
b.classList.toggle('bg-chip-active', on);
b.setAttribute('aria-pressed', String(on));
});
};
setupChipGroup(el, '#variant-grid', pickVariant, 'variant');
setupChipGroup(el, '#length-grid', val => { matchLength = parseInt(val, 10); }, 'len');
if (!isOnline) setupChipGroup(el, '#difficulty-grid', val => { difficulty = val; }, 'diff'); if (!isOnline) setupChipGroup(el, '#difficulty-grid', val => { difficulty = val; }, 'diff');
setupChipGroup(el, '#cube-grid', val => { useCube = val === 'on'; }, 'cube'); setupChipGroup(el, '#cube-grid', val => { useCube = val === 'on'; }, 'cube');
...@@ -187,6 +206,12 @@ function renderSearching(el) { ...@@ -187,6 +206,12 @@ function renderSearching(el) {
el.querySelector('#btn-cancel').onclick = () => { audio.play('click'); scene.pop(); }; el.querySelector('#btn-cancel').onclick = () => { audio.play('click'); scene.pop(); };
} }
function lengthChips(variant) {
const info = variantInfo(variant);
return info.lengths.map((n, i) =>
chip('len', n, n === info.defaultLength, `<span class="num bg-chip-num">${n}</span><span class="bg-chip-label">${t('backgammon.' + LENGTH_KEYS[i])}</span>`)).join('');
}
function setupChipGroup(el, selector, onChange, dataKey) { function setupChipGroup(el, selector, onChange, dataKey) {
el.querySelectorAll(`${selector} .bg-chip`).forEach(btn => { el.querySelectorAll(`${selector} .bg-chip`).forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
...@@ -239,6 +264,7 @@ function getStyles() { ...@@ -239,6 +264,7 @@ function getStyles() {
.bg-chip-active .bgr-pips { opacity: 1; } .bg-chip-active .bgr-pips { opacity: 1; }
.bgr-start { margin-top: var(--s-8); } .bgr-start { margin-top: var(--s-8); }
.bg-rule { margin-top: var(--s-2); font-size: var(--fs-sm); font-weight: 600; line-height: 1.5; color: var(--text-secondary); text-shadow: var(--text-shadow); }
/* Searching: a short stack of cards shuffling. */ /* Searching: a short stack of cards shuffling. */
.bgr-searching { min-height: 100%; justify-content: center; text-align: center; gap: var(--s-6); } .bgr-searching { min-height: 100%; justify-content: center; text-align: center; gap: var(--s-6); }
......
// Doubling cube 3D display widget // The doubling cube, drawn on the bar: centred while nobody owns it, at the
export function renderCubeDisplay(container, cubeValue, cubeOwner, myColor) { // owner's end of the bar once taken. When the stakes double it flips over
let existing = container.querySelector('.bgg-cube-display'); // and lands showing the new value.
if (!existing) {
existing = document.createElement('div');
existing.className = 'bgg-cube-display';
container.appendChild(existing);
}
if (!cubeValue || cubeValue === 1) { import { palette, roundRect, FONT } from '../canvas/board-renderer.js';
existing.style.display = 'none';
return;
}
existing.style.display = ''; const TAU = Math.PI * 2;
const position = cubeOwner === null ? 'center' : cubeOwner === myColor ? 'bottom' : 'top';
existing.innerHTML = ` /** Where the cube rests: 'center', 'me' or 'opp' end of the bar. */
<div class="bgg-cube bgg-cube-${position}"> export function cubeSpot(L, where) {
<div class="bgg-cube-face"> const size = Math.min(L.B - 2, 28);
<span class="bgg-cube-val">${cubeValue}</span> const x = L.barX + L.B / 2;
</div> const y = where === 'center' ? L.midY : where === 'me' ? L.fieldBottom - size * 0.9 : L.fieldTop + size * 0.9;
</div> return { x, y, size };
`;
} }
export function createCubeStyles() { /**
return ` * Draw the cube: a flat white cube on its lip, the stake in red ink. flip:
.bgg-cube-display { position:absolute;z-index:4;pointer-events:none; } * 0..1 progress of a flip (the face squeezes to an edge and opens on the new
.bgg-cube-center { top:50%;right:8px;transform:translateY(-50%); } * value), lift: px above the bar.
.bgg-cube-top { top:8px;right:8px; } */
.bgg-cube-bottom { bottom:8px;right:8px; } export function drawCube(ctx, x, y, size, value, { flip = 1, lift = 0, prev = null } = {}) {
.bgg-cube-face { const P = palette();
width:32px;height:32px;border-radius:6px; const k = flip < 1 ? Math.abs(Math.cos(flip * Math.PI)) : 1;
background:linear-gradient(135deg,rgba(139,92,246,0.2),rgba(139,92,246,0.05)); const shown = flip < 0.5 && prev !== null ? prev : value;
border:1.5px solid rgba(139,92,246,0.4); const s = size;
display:flex;align-items:center;justify-content:center; ctx.save();
box-shadow:0 2px 8px rgba(139,92,246,0.2); if (lift > 0.5) {
ctx.beginPath();
ctx.ellipse(x, y + s * 0.45, s * 0.5, s * 0.16, 0, 0, TAU);
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.fill();
} }
.bgg-cube-val { font-size:14px;font-weight:900;color:var(--purple-light); } ctx.translate(x, y - lift);
`; ctx.scale(1, Math.max(0.06, k));
roundRect(ctx, -s / 2, -s / 2 + s * 0.12, s, s, s * 0.24);
ctx.fillStyle = P.cardEdge;
ctx.fill();
roundRect(ctx, -s / 2, -s / 2, s, s, s * 0.24);
ctx.fillStyle = P.card;
ctx.fill();
ctx.font = `800 ${Math.max(12, Math.round(s * (shown >= 10 ? 0.46 : 0.58)))}px ${FONT}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = P.redInk;
ctx.fillText(String(shown === 1 ? 64 : shown), 0, s * 0.04);
ctx.restore();
} }
// Move hints — computes valid destination list for selected piece // Move hints: what the board should light up for the current choice.
export function showHints(validMoves, selectedPieceId) {
if (!selectedPieceId) return []; /** Destinations ('off' or point indices) for the checker standing on `from`. */
return validMoves export function showHints(validMoves, from) {
.filter(m => m.piece.id === selectedPieceId) if (from === null || from === undefined) return [];
.map(m => m.to === 'off' ? 'off' : m.to); return [...new Set(validMoves.filter(m => m.from === from).map(m => m.to))];
} }
export function clearHints() { export function clearHints() {
return []; return [];
} }
/** Points (and 'bar') that have a checker that can move now. */
export function getMovablePieces(validMoves) { export function getMovablePieces(validMoves) {
const pieces = new Set(); return [...new Set(validMoves.map(m => m.from))];
for (const m of validMoves) {
pieces.add(m.piece.id);
}
return [...pieces];
} }
/**
* Backgammon rules engine check (no browser, no network).
*
* node tests/play/backgammon-rules.mjs
*
* 1. Hand-built positions for every rule the players will meet: doubles, a
* blot hit and the checker entering from the bar, closed boards, bearing
* off with a larger die, forced moves (play both dice, else the larger),
* mahbousa pins, 31 blocks, and the scoring of each variant.
* 2. Thousands of random games per variant, where every legal-move list the
* engine produces is compared with an independent brute-force reference
* written straight from the rules, and the position invariants are checked
* after every move.
*/
import {
WHITE, BLACK, createGame, setPosition, rollDice, getValidMoves, applyMoveFrom,
nextTurn, turnIsDone, getGameScore, getPipCount, getTurnPlays, variantInfo,
} from '../../public/js/modules/backgammon/logic/rules.js';
import { createMatch, startNewGame, endGame, canUseCube } from '../../public/js/modules/backgammon/logic/match.js';
import { getBotPlay } from '../../public/js/modules/backgammon/logic/bot.js';
let pass = 0, fail = 0;
function check(label, cond, detail = '') {
if (cond) { pass++; return true; }
fail++;
console.log(` FAIL ${label}${detail ? ' :: ' + detail : ''}`);
return false;
}
const movesOf = (g) => getValidMoves(g).map(m => `${m.from}>${m.to}/${m.steps}`).sort();
const has = (g, from, to, steps) => getValidMoves(g).some(m => m.from === from && m.to === to && (steps == null || m.steps === steps));
function game(variant, points, extra = {}) {
const g = createGame(variant);
setPosition(g, { points, ...extra });
return g;
}
// ── Reference move generator, written independently from the rules ─────────
function refBoard(state) {
return { pts: state.points.map(p => p.map(c => c.type)), bar: [state.bar[0].length, state.bar[1].length] };
}
function refCanLand(b, dest, type, capture) {
const pt = b.pts[dest];
const opp = pt.filter(t => t !== type).length;
if (capture === 'hit') { if (opp === 0) return 'move'; if (opp === 1 && pt.length === 1) return 'hit'; return null; }
if (capture === 'pin') { if (!pt.length || pt[pt.length - 1] === type) return 'move'; if (pt.length === 1) return 'pin'; return null; }
return opp === 0 ? 'move' : null;
}
function refStep(b, type, from, die, capture) {
if (b.bar[type] > 0 && from !== 'bar') return null;
if (from === 'bar') {
if (!b.bar[type]) return null;
const e = type === WHITE ? 24 - die : die - 1;
const k = refCanLand(b, e, type, capture);
return k ? { to: e, kind: k } : null;
}
const pt = b.pts[from];
if (!pt.length || pt[pt.length - 1] !== type) return null;
const dest = type === WHITE ? from - die : from + die;
if (dest >= 0 && dest <= 23) { const k = refCanLand(b, dest, type, capture); return k ? { to: dest, kind: k } : null; }
for (let i = 0; i < 24; i++) {
const inHome = type === WHITE ? i <= 5 : i >= 18;
if (!inHome && b.pts[i].includes(type)) return null;
}
const exact = type === WHITE ? from - die === -1 : from + die === 24;
if (exact) return { to: 'off', kind: 'bear' };
for (let i = 0; i < 24; i++) {
const higher = type === WHITE ? i > from : i < from;
if (higher && b.pts[i].includes(type)) return null;
}
return { to: 'off', kind: 'bear' };
}
function refApply(b, type, from, r) {
const nb = { pts: b.pts.map(p => p.slice()), bar: b.bar.slice() };
if (from === 'bar') nb.bar[type]--; else nb.pts[from].pop();
if (r.to !== 'off') { if (r.kind === 'hit') { nb.pts[r.to].pop(); nb.bar[1 - type]++; } nb.pts[r.to].push(type); }
return nb;
}
function refSequences(b, type, dice, capture) {
const out = [];
const go = (b, dice, seq) => {
let moved = false;
const seen = new Set();
dice.forEach((d, k) => {
if (seen.has(d)) return; seen.add(d);
const rest = dice.filter((_, j) => j !== k);
for (const from of ['bar', ...Array.from({ length: 24 }, (_, i) => i)]) {
const r = refStep(b, type, from, d, capture);
if (!r) continue;
moved = true;
go(refApply(b, type, from, r), rest, seq.concat([{ from, to: r.to, steps: d }]));
}
});
if (!moved) out.push(seq);
};
go(b, dice, []);
return out;
}
function refLegal(g) {
const capture = variantInfo(g.variant).capture;
const seqs = refSequences(refBoard(g.state), g.turn, g.movesLeft, capture);
const maxLen = Math.max(...seqs.map(s => s.length));
if (maxLen === 0) return [];
let keep = seqs.filter(s => s.length === maxLen);
const distinct = [...new Set(g.movesLeft)];
if (maxLen === 1 && distinct.length === 2) {
const hi = Math.max(...distinct);
if (keep.some(s => s[0].steps === hi)) keep = keep.filter(s => s[0].steps === hi);
}
return [...new Set(keep.map(s => `${s[0].from}>${s[0].to}/${s[0].steps}`))].sort();
}
// ── 1. Positions ──────────────────────────────────────────────────────────
console.log('positions');
{
const g = createGame('sheshbesh');
rollDice(g, [3, 1]);
check('opening 3-1 can make the 5 point', has(g, 7, 4, 3) && has(g, 5, 4, 1));
check('opening checker count', g.state.points.flat().length === 30);
check('pip count 167 each', getPipCount(g.state, WHITE) === 167 && getPipCount(g.state, BLACK) === 167);
}
{
const g = createGame('sheshbesh');
rollDice(g, [4, 4]);
check('doubles give four moves', g.movesLeft.length === 4);
let n = 0;
while (!turnIsDone(g)) { const m = getValidMoves(g)[0]; applyMoveFrom(g, m.from, m.steps); n++; }
check('doubles play four times from the opening', n === 4, String(n));
}
{
// Black blot on 3 (index 2), white on 6 rolls a 3: hit it.
const g = game('sheshbesh', { 5: 'w2', 2: 'b1', 20: 'b14' }, { turn: WHITE, off: [13, 0] });
rollDice(g, [3, 5]);
check('hit is offered', getValidMoves(g).some(m => m.from === 5 && m.to === 2 && m.kind === 'hit'));
const acts = applyMoveFrom(g, 5, 3);
check('hit sends the blot to the bar', acts.some(a => a.type === 'hit') && g.state.bar[BLACK].length === 1);
nextTurn(g);
rollDice(g, [3, 6]);
check('a checker on the bar must enter first', getValidMoves(g).every(m => m.from === 'bar'));
check('enters on the 3 (index 2 holds a white blot: hit back)', getValidMoves(g).some(m => m.from === 'bar' && m.to === 2 && m.kind === 'hit'));
}
{
// White on the bar, black has closed points 19..24 (indices 18..23): no entry.
const pts = { 18: 'b2', 19: 'b2', 20: 'b2', 21: 'b2', 22: 'b2', 23: 'b2', 5: 'w14' };
const g = game('sheshbesh', pts, { turn: WHITE, bar: [1, 0], off: [0, 3] });
rollDice(g, [6, 1]);
check('closed board: blocked on the bar, no moves', getValidMoves(g).length === 0 && turnIsDone(g));
}
{
// Bearing off with a larger die from the highest point only.
const g = game('sheshbesh', { 2: 'w1', 1: 'w1', 20: 'b5' }, { turn: WHITE, off: [13, 10] });
rollDice(g, [6, 5]);
check('larger die bears off the highest checker', has(g, 2, 'off', 6) && !has(g, 1, 'off', 6));
applyMoveFrom(g, 2, 6);
check('then the next one', has(g, 1, 'off', 5));
applyMoveFrom(g, 1, 5);
check('15 off wins', g.isOver && g.winner === WHITE);
}
{
// Must play both dice when possible: 13 -> 7 with 6 then 1 is blocked except one order.
// White checker on 13 (idx 12) only; black points at idx 6 (6 away) blocks the 6 first,
// idx 11 (1 away) open: playing 1 first (to 11) then 6 (to 5) works; 6 first is blocked.
const g = game('sheshbesh', { 12: 'w1', 6: 'b2', 5: 'w14', 20: 'b13' }, { turn: WHITE });
rollDice(g, [6, 1]);
const ms = movesOf(g);
check('forced: the only way to use both dice', JSON.stringify(ms) === '["12>11/1"]' && JSON.stringify(ms) === JSON.stringify(refLegal(g)), ms.join(' '));
}
{
// Only one die can be played: must be the larger.
const g = game('sheshbesh', { 12: 'w1', 0: 'w14', 6: 'b2', 7: 'b2', 9: 'b2', 23: 'b9' }, { turn: WHITE });
rollDice(g, [5, 2]);
const ms = movesOf(g);
check('only the larger die when only one can be played', JSON.stringify(ms) === JSON.stringify(refLegal(g)), ms.join(' ') + ' vs ' + refLegal(g).join(' '));
}
{
// Mahbousa: landing on a lone black checker pins it.
const g = game('mahbousa', { 23: 'w14', 20: 'w1', 0: 'b14', 17: 'b1' }, { turn: WHITE });
rollDice(g, [3, 6]);
const pin = getValidMoves(g).find(m => m.from === 20 && m.to === 17);
check('mahbousa offers a pin', pin && pin.kind === 'pin');
applyMoveFrom(g, 20, 3);
check('pinned checker stays under', g.state.points[17].map(p => p.type).join('') === '10');
nextTurn(g);
rollDice(g, [1, 2]);
check('a pinned checker cannot move', !getValidMoves(g).some(m => m.from === 17));
check('black cannot land on the pinned point', !getValidMoves(g).some(m => m.to === 17));
check('no hitting in mahbousa', g.state.bar[0].length + g.state.bar[1].length === 0);
}
{
// Mahbousa mother rule: pin the last checker on black's start, white has left its own start.
const g = game('mahbousa', { 3: 'b1', 0: 'b1', 2: 'w1', 5: 'w14' }, { turn: WHITE });
rollDice(g, [2, 1]);
const m = getValidMoves(g).find(x => x.from === 2 && x.to === 0);
check('white may pin black\'s mother', !!m);
applyMoveFrom(g, 2, 2);
check('pinned mother loses two points', g.isOver && g.winner === WHITE && g.score === 2 && g.endReason === 'mother', JSON.stringify([g.isOver, g.winner, g.score, g.endReason]));
// Exception: the pinner still has checkers on its own start.
const g2 = game('mahbousa', { 0: 'b1', 2: 'w1', 23: 'w2', 5: 'w12', 10: 'b14' }, { turn: WHITE });
rollDice(g2, [2, 1]);
applyMoveFrom(g2, 2, 2);
check('no loss while the pinner\'s own mother is at risk', !g2.isOver);
// Both mothers pinned: a tie.
const g3 = game('mahbousa', { 0: 'b1', 2: 'w1', 23: 'wb', 6: 'w13', 10: 'b13' }, { turn: WHITE });
rollDice(g3, [2, 1]);
applyMoveFrom(g3, 2, 2);
check('both mothers pinned is a tie', g3.isOver && g3.winner === null && g3.endReason === 'tie', JSON.stringify([g3.isOver, g3.winner, g3.endReason]));
}
{
// Mahbousa: white can add to its pinning point.
const g = game('mahbousa', { 23: 'w13', 20: 'w1', 17: 'bw', 0: 'b14' }, { turn: WHITE });
rollDice(g, [3, 3]);
check('pinner can stack on its own pin', has(g, 20, 17, 3));
}
{
// 31: a single checker owns its point.
const g = game('thirtyone', { 23: 'w14', 20: 'w1', 0: 'b14', 17: 'b1' }, { turn: WHITE });
rollDice(g, [3, 4]);
check('31: cannot land on a single opposing checker', !has(g, 20, 17));
check('31: legal list matches reference', JSON.stringify(movesOf(g)) === JSON.stringify(refLegal(g)));
}
{
// Scoring.
const g = game('sheshbesh', { 0: 'w1', 20: 'b15' }, { turn: WHITE, off: [14, 0] });
check('gammon is 2', getGameScore({ ...g.state, outside: [new Array(15).fill({}), []] }, WHITE, 'sheshbesh') === 2);
const g2 = game('sheshbesh', { 0: 'w1', 3: 'b1', 20: 'b14' }, { turn: WHITE, off: [14, 0] });
check('backgammon is 3 (a checker in the winner home)', getGameScore({ ...g2.state, outside: [new Array(15).fill({}), []] }, WHITE, 'sheshbesh') === 3);
const g3 = game('mahbousa', { 3: 'b1', 20: 'b14' }, { off: [15, 0] });
check('mahbousa gammon caps at 2', getGameScore(g3.state, WHITE, 'mahbousa') === 2);
const g4 = game('thirtyone', { 20: 'b9' }, { off: [15, 6] });
check('31 scores the checkers left', getGameScore(g4.state, WHITE, 'thirtyone') === 9);
check('31 match default is 31', variantInfo('thirtyone').defaultLength === 31);
}
{
// Match: Crawford, cube off stays off, stakes.
const m = createMatch(3, 'sheshbesh', true);
startNewGame(m);
check('cube in game 1 of a 3 point match', canUseCube(m));
m.cube.value = 2;
const r = endGame(m, WHITE, 1);
check('stake is cube times game', r.stake === 2 && m.scores[WHITE] === 2);
startNewGame(m);
check('Crawford game has no cube', !canUseCube(m) && m.crawford);
endGame(m, BLACK, 1);
startNewGame(m);
check('post-Crawford cube is back', canUseCube(m));
const m2 = createMatch(5, 'sheshbesh', false);
startNewGame(m2); endGame(m2, WHITE, 1); startNewGame(m2);
check('cube switched off stays off', !canUseCube(m2));
check('starts alternate', m2.currentGame.turn === BLACK);
}
// ── 2. Random games against the reference ───────────────────────────────────
for (const variant of ['sheshbesh', 'mahbousa', 'thirtyone']) {
const capture = variantInfo(variant).capture;
let games = 0, moves = 0, compared = 0, mismatches = 0, stuck = 0, hits = 0, pins = 0, bearLarge = 0, blockedBar = 0;
const scores = {};
const N = variant === 'sheshbesh' ? 300 : 220;
for (let n = 0; n < N; n++) {
const g = createGame(variant);
let turns = 0;
while (!g.isOver && turns < 2000) {
rollDice(g);
if (g.state.bar[g.turn].length && turnIsDone(g)) blockedBar++;
while (!turnIsDone(g)) {
const mine = movesOf(g);
if (compared < 6000 || Math.random() < 0.05) {
const ref = refLegal(g);
compared++;
if (JSON.stringify(mine) !== JSON.stringify(ref)) {
mismatches++;
if (mismatches <= 3) console.log(' mismatch', variant, JSON.stringify(g.movesLeft), mine.join(' '), '| ref', ref.join(' '));
}
}
const list = getValidMoves(g);
const m = list[Math.floor(Math.random() * list.length)];
if (m.to === 'off' && m.steps > (g.turn === WHITE ? m.from + 1 : 24 - m.from)) bearLarge++;
const acts = applyMoveFrom(g, m.from, m.steps);
if (acts.some(a => a.type === 'hit')) hits++;
if (acts.some(a => a.pinned)) pins++;
moves++;
// Invariants
const all = g.state.points.flat().length + g.state.bar.flat().length + g.state.outside.flat().length;
if (all !== 30) { check(`${variant}: 30 checkers`, false, String(all)); break; }
for (let i = 0; i < 24; i++) {
const pt = g.state.points[i].map(p => p.type);
const mixed = new Set(pt).size > 1;
if (capture !== 'pin' && mixed) check(`${variant}: no mixed point`, false, `${i} ${pt}`);
if (capture === 'pin' && mixed && !(pt.slice(1).every(t => t === pt[pt.length - 1]) && pt[0] !== pt[pt.length - 1])) check('mahbousa: a pin is one checker under', false, pt.join(''));
}
if (capture !== 'hit' && (g.state.bar[0].length || g.state.bar[1].length)) check(`${variant}: nobody on the bar`, false);
}
if (g.isOver) break;
nextTurn(g);
turns++;
}
if (!g.isOver) stuck++;
games++;
scores[g.score] = (scores[g.score] || 0) + 1;
if (g.isOver && g.winner !== null && g.endReason !== 'mother') {
const expect = getGameScore(g.state, g.winner, variant);
check(`${variant}: stored score`, expect === g.score);
}
}
check(`${variant}: engine == reference on every compared position`, mismatches === 0, `${mismatches}/${compared}`);
check(`${variant}: every game finishes`, stuck === 0, String(stuck));
if (capture === 'hit') check('sheshbesh: hits happen', hits > 0);
if (capture === 'pin') check('mahbousa: pins happen', pins > 0);
console.log(` ${variant}: ${games} games, ${moves} moves, ${compared} compared, hits ${hits}, pins ${pins}, large-die bear-offs ${bearLarge}, blocked on bar ${blockedBar}, scores ${JSON.stringify(scores)}`);
}
// ── 3. Bot plays are legal full turns ────────────────────────────────────────
for (const variant of ['sheshbesh', 'mahbousa', 'thirtyone']) {
for (const level of ['easy', 'medium', 'hard']) {
let bad = 0, finished = 0;
for (let n = 0; n < 12; n++) {
const g = createGame(variant);
let turns = 0;
while (!g.isOver && turns < 1500) {
rollDice(g);
const plays = getTurnPlays(g);
const play = getBotPlay(g, level);
if (plays.length && !play) bad++;
for (const mv of play?.moves || []) { if (g.isOver) break; if (!applyMoveFrom(g, mv.from, mv.steps)) { bad++; break; } }
if (!turnIsDone(g)) bad++;
if (g.isOver) break;
nextTurn(g);
turns++;
}
if (g.isOver) finished++;
}
check(`bot ${level} ${variant}: legal full turns`, bad === 0, String(bad));
check(`bot ${level} ${variant}: games finish`, finished === 12, String(finished));
}
}
{
// Hard bot beats easy bot most of the time.
let hardWins = 0;
const N = 40;
for (let n = 0; n < N; n++) {
const g = createGame('sheshbesh');
if (n % 2) g.turn = BLACK;
while (!g.isOver) {
rollDice(g);
const play = getBotPlay(g, g.turn === WHITE ? 'hard' : 'easy');
for (const mv of play?.moves || []) applyMoveFrom(g, mv.from, mv.steps);
if (g.isOver) break;
nextTurn(g);
}
if (g.winner === WHITE) hardWins++;
}
check('hard bot beats easy bot', hardWins >= N * 0.7, `${hardWins}/${N}`);
console.log(` hard vs easy: ${hardWins}/${N}`);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
/**
* Backgammon, played like a player, in Chrome against the local server.
*
* node tests/play/backgammon.mjs [--only bot,scenarios,cube,match,result,layout,online]
* [--speed 5] [--out <dir>] [--online auto|real|fake] [--base http://127.0.0.1:8080] [--headful]
*
* Throwaway accounts are made through the real auth endpoint and deleted at
* the end with every row they created (queue rows, matches, coin and rating
* rows), even when the run fails. Everything is driven through the real UI:
* the room's chips and start button, the roll button, taps on checkers and
* points computed from the board's own geometry, the cube buttons, the
* result card's buttons. The page exposes window.__bgTest only when
* localStorage el3ab_test is '1': it reports state, gives tap coordinates,
* queues dice for the scenarios that need a particular roll, lays out a
* position, and speeds the animations up.
*
* bot one 1-point game to the end in each variant
* scenarios doubles, no move, hit + enter from the bar, blocked on the bar,
* bearing off with a larger die, gammon / backgammon, mahbousa pin,
* 31 block and 31 scoring
* cube offer / accept / decline, both ways
* match a 3-point match with the cube to its end (Crawford included)
* result the result card, play again, back to the table
* layout 360x640, 390x844, 430x932 and a resize mid-game
* online two accounts in two pages: boards in sync, the cube in sync, a resign
*
* Zero console errors are asserted for every page.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const BASE = argOf('--base', 'http://127.0.0.1:8080');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const OUT = argOf('--out', path.join(process.env.TMPDIR || '/tmp', 'bg-shots'));
const ONLY = (argOf('--only', '') || '').split(',').filter(Boolean);
const SPEED = +argOf('--speed', 5);
const ONLINE = argOf('--online', 'auto');
const HEADFUL = args.includes('--headful');
const want = (k) => !ONLY.length || ONLY.includes(k);
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = process.env.SUPABASE_SERVICE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
const W = 0, B = 1;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
let pass = 0;
const failures = [];
function check(label, cond, detail = '') {
if (cond) { pass++; console.log(` ok ${label}`); return true; }
failures.push(`${label}${detail ? ' :: ' + detail : ''}`);
console.log(` FAIL ${label}${detail ? ' :: ' + String(detail).slice(0, 300) : ''}`);
return false;
}
fs.mkdirSync(OUT, { recursive: true });
const shots = [];
async function shot(page, name) {
const p = path.join(OUT, name + '.png');
await page.screenshot({ path: p });
shots.push(p);
return p;
}
// ── Accounts ───────────────────────────────────────────────────────────────
async function sb(p, { method = 'GET', body, prefer = 'return=minimal' } = {}) {
const r = await fetch(`${SB}/rest/v1/${p}`, {
method,
headers: { apikey: SK, Authorization: `Bearer ${SK}`, 'Content-Type': 'application/json', Prefer: prefer },
body: body ? JSON.stringify(body) : undefined,
});
if (method === 'GET') return r.ok ? r.json() : [];
return r.ok;
}
const accounts = [];
async function createAccount(name) {
const r = await fetch(`${BASE}/api/auth.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'guest' }),
}).then(x => x.json());
if (!r.access_token || !r.profile?.id) throw new Error('account creation failed: ' + JSON.stringify(r).slice(0, 160));
const acc = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
accounts.push(acc);
await sb(`profiles?id=eq.${acc.id}`, { method: 'PATCH', body: { display_name: name, level: 4, coins: 500 } });
acc.profile = (await sb(`profiles?id=eq.${acc.id}&select=*`))?.[0] || r.profile;
return acc;
}
async function removeAccounts() {
console.log('\n=== teardown ===');
for (const a of accounts) {
const id = a.id;
const matches = await sb(`backgammon_matches?select=id&players=cs.${encodeURIComponent(JSON.stringify([id]))}`);
for (const m of matches || []) await sb(`chat_messages?match_id=eq.${m.id}`, { method: 'DELETE' }).catch(() => {});
for (const p of [
`backgammon_queue?user_id=eq.${id}`,
`backgammon_matches?players=cs.${encodeURIComponent(JSON.stringify([id]))}`,
`backgammon_matches?created_by=eq.${id}`,
`chat_messages?sender_id=eq.${id}`,
`economy_transactions?player_id=eq.${id}`,
`player_game_ratings?player_id=eq.${id}`,
`player_achievements?player_id=eq.${id}`,
`mp_log?player_id=eq.${id}`,
`rating_history?player_id=eq.${id}`,
`profiles?id=eq.${id}`,
]) await sb(p, { method: 'DELETE' }).catch(() => {});
await fetch(`${SB}/auth/v1/admin/users/${id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
const left = await sb(`profiles?id=eq.${id}&select=id`);
const leftM = await sb(`backgammon_matches?select=id&players=cs.${encodeURIComponent(JSON.stringify([id]))}`);
const leftQ = await sb(`backgammon_queue?select=id&user_id=eq.${id}`);
console.log(` ${id}: profile ${left?.length ? 'STILL THERE' : 'deleted'}, matches left ${leftM?.length || 0}, queue left ${leftQ?.length || 0}`);
if (left?.length || leftM?.length || leftQ?.length) failures.push(`teardown left rows for ${id}`);
}
}
// ── Pages ──────────────────────────────────────────────────────────────────
const pages = [];
async function bootPage(browser, acc, { w = 390, h = 844, label = 'p' } = {}) {
const page = await browser.newPage();
page.__label = label;
page.__errors = [];
await page.setViewport({ width: w, height: h, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
page.on('console', m => {
const tx = m.text();
if (m.type() === 'error') page.__errors.push(tx.slice(0, 300));
if ((m.type() === 'warn' || m.type() === 'warning') && tx.includes('[i18n] missing')) page.__errors.push(tx);
});
page.on('pageerror', e => page.__errors.push('PAGEERROR ' + String(e.message || e).slice(0, 300)));
page.on('response', async r => {
if (r.status() < 400) return;
let body = '';
try { body = (await r.text()).slice(0, 160); } catch (e) {}
let post = '';
try { post = (r.request().postData() || '').slice(0, 120); } catch (e) {}
page.__errors.push(`HTTP ${r.status()} ${r.url().replace(BASE, '')} ${post} -> ${body}`);
});
page.on('requestfailed', r => { const u = r.url(); if (!/fonts\.g|\.mp3|branding|favicon|google/.test(u) && r.failure()?.errorText !== 'net::ERR_ABORTED') page.__errors.push('REQFAIL ' + u + ' ' + r.failure()?.errorText); });
await page.goto(BASE + '/public/css/tokens.css');
await page.evaluate((a) => {
localStorage.setItem('el3ab_state', JSON.stringify({
auth: { token: a.token, refreshToken: a.refresh, userId: a.id, isGuest: false, guestId: null },
player: a.profile, language: 'ar', activeWorld: 'play', audioEnabled: true,
}));
localStorage.setItem('el3ab_test', '1');
}, acc);
await page.goto(BASE + '/', { waitUntil: 'networkidle2', timeout: 60000 });
// Booted: the play table is dealt.
await page.waitForSelector('.hand-card[data-game="backgammon"]', { visible: true, timeout: 45000 });
await sleep(900);
pages.push(page);
return page;
}
const st = (page) => page.evaluate(() => window.__bgTest ? window.__bgTest.state() : null);
const geo = (page) => page.evaluate(() => window.__bgTest.geometry());
async function until(page, pred, { timeout = 20000, every = 60, what = 'condition' } = {}) {
const t0 = Date.now();
let s;
while (Date.now() - t0 < timeout) {
s = await st(page);
if (s && pred(s)) return s;
await sleep(every);
}
throw new Error(`timeout waiting for ${what}; last state ${JSON.stringify(s && { phase: s.phase, turn: s.turn, dice: s.dice, busy: s.busy, over: s.over, canRoll: s.canRoll, offer: s.offerShown })}`);
}
// With two players in one browser, the page being tapped must be in front:
// a background page does not render frames, so its input never completes.
async function front(page) { if (pages.filter(p => !p.isClosed() && p.browser() === page.browser()).length > 1) await page.bringToFront(); }
async function tapAt(page, p) { await front(page); await page.touchscreen.tap(p.x, p.y); }
async function tapSel(page, sel) {
await front(page);
await page.waitForSelector(sel, { visible: true, timeout: 15000 });
const el = await page.$(sel);
// Tap where the element is actually on top: cards in a hand overlap, and
// the tab bar covers the bottom of a scrolled screen.
const findPoint = () => el.evaluate(e => {
const r = e.getBoundingClientRect();
for (const fy of [0.5, 0.3, 0.7, 0.15, 0.85]) for (const fx of [0.5, 0.3, 0.7, 0.15, 0.85]) {
const x = r.left + r.width * fx, y = r.top + r.height * fy;
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) continue;
const hit = document.elementFromPoint(x, y);
if (hit && (hit === e || e.contains(hit))) return { x, y };
}
return null;
});
let pt = await findPoint();
if (!pt) {
await el.evaluate(e => e.scrollIntoView({ block: 'center' }));
await sleep(300);
pt = await findPoint();
}
if (!pt) throw new Error(`${sel} is covered or off screen`);
await page.touchscreen.tap(pt.x, pt.y);
}
async function setSpeed(page) { await page.evaluate((v) => { if (window.__bgTest) window.__bgTest.speed = v; }, SPEED); }
/** Raise the backgammon card in the hand (tapping a raised card would play it). */
async function pickBackgammonCard(page) {
await page.waitForSelector('.hand-card[data-game="backgammon"]', { visible: true, timeout: 15000 });
const raised = await page.$eval('.hand-card[data-game="backgammon"]', el => el.getAttribute('aria-checked') === 'true');
if (!raised) await tapSel(page, '.hand-card[data-game="backgammon"]');
await sleep(500);
}
/** From the play table, through the room, into a bot game with these settings. */
async function startBotGame(page, { variant = 'sheshbesh', length = 1, difficulty = 'easy', cube = null } = {}) {
await page.evaluate(async () => { const m = await import('/public/js/core/scene.js'); m.exitGameMode(); m.switchWorld('play', true); });
await sleep(700);
const dbg = async (n) => { if (process.env.BG_DEBUG) await shot(page, `dbg-${page.__label}-${n}`); };
await dbg('1-table');
await pickBackgammonCard(page);
await dbg('2-card');
await tapSel(page, '#btn-single');
await sleep(700);
await dbg('3-room');
await tapSel(page, '#btn-local');
await sleep(700);
await dbg('4-setup');
await tapSel(page, `#variant-grid [data-variant="${variant}"]`);
await sleep(150);
await tapSel(page, `#length-grid [data-len="${length}"]`);
await tapSel(page, `#difficulty-grid [data-diff="${difficulty}"]`);
if (cube !== null) await tapSel(page, `#cube-grid [data-cube="${cube ? 'on' : 'off'}"]`);
await sleep(150);
await tapSel(page, '#btn-start');
await page.waitForFunction(() => !!window.__bgTest && !!window.__bgTest.state().variant, { timeout: 15000, polling: 200 });
await setSpeed(page);
return st(page);
}
/** Pick a move: bear off, else hit, else move the rearmost checker. */
function chooseMove(s, style = 'race') {
const me = s.myColor;
const norm = (p) => (p === 'bar' ? 24 : me === W ? p : 23 - p);
const legal = s.legal;
if (style === 'random') return legal[Math.floor(Math.random() * legal.length)];
return legal.find(m => m.to === 'off')
|| (style === 'hitter' && legal.find(m => m.kind === 'hit'))
|| legal.slice().sort((a, b) => norm(b.from) - norm(a.from) || b.steps - a.steps)[0];
}
/** Play one move with real taps. Returns the state after it lands. */
async function tapMove(page, m) {
let s = await st(page);
const before = (s.movesPlayed || []).length;
const g = await geo(page);
const srcPt = (from) => (from === 'bar' ? g.bar : g.points[from].top);
if (s.legal.length === 1) {
// A forced move plays itself.
} else {
if (s.sel !== null && s.sel !== m.from) { await tapAt(page, srcPt(s.sel)); await until(page, x => x.sel === null, { what: 'deselect' }); }
s = await st(page);
if (s.sel !== m.from) {
await tapAt(page, srcPt(m.from));
await until(page, x => x.sel === m.from, { what: `select ${m.from}` });
}
s = await st(page);
if (!s.highlights.map(String).includes(String(m.to))) throw new Error(`destination ${m.to} not lit; lit ${s.highlights}`);
await tapAt(page, m.to === 'off' ? g.tray : g.points[m.to].mid);
}
return until(page, x => !x.busy && ((x.movesPlayed || []).length > before || x.turn !== s.turn || x.over || x.isOver), { what: 'move to land' });
}
/** My whole turn: roll with the button, then play every die by tapping. */
async function myTurn(page, { style = 'race', onRolled = null } = {}) {
let s = await until(page, x => x.canRoll || x.over || x.offerShown || x.resultShown, { timeout: 60000, what: 'my roll' });
if (!s.canRoll) return s;
await tapSel(page, '#btn-roll');
s = await until(page, x => !x.busy && x.phase !== 'rolling' && x.phase !== 'idle', { what: 'dice to land' });
if (onRolled) await onRolled(s);
let guard = 0;
while (s.phase === 'moving' && s.turn === s.myColor && !s.over && guard++ < 8) {
const m = chooseMove(s, style);
s = await tapMove(page, m);
}
return s;
}
/** Play until the match is over (the result card is up). */
async function playToEnd(page, { style = 'race', maxTurns = 400, onTurn = null } = {}) {
for (let i = 0; i < maxTurns; i++) {
const s = await until(page, x => x.canRoll || x.resultShown || x.offerShown, { timeout: 90000, what: 'my turn or the end' });
if (s.resultShown) return s;
if (s.offerShown) { await tapSel(page, '#btn-accept-double'); await sleep(200); continue; }
if (onTurn) await onTurn(s, i);
await myTurn(page, { style });
}
throw new Error('match did not finish');
}
function noErrors(page, label) {
const errs = [...new Set(page.__errors)];
check(`${label}: no console errors`, errs.length === 0, errs.slice(0, 6).join(' | '));
page.__errors.length = 0;
}
// ── Sections ───────────────────────────────────────────────────────────────
async function sectionBot(page) {
console.log('\n=== bot: a 1-point game to the end in each variant ===');
for (const variant of ['sheshbesh', 'mahbousa', 'thirtyone']) {
const s0 = await startBotGame(page, { variant, length: 1, difficulty: 'easy', cube: false });
check(`${variant}: game mounted from the room`, s0.variant === variant && s0.match.length === 1);
check(`${variant}: 15 + 15 checkers`, s0.points.join('').length === 30);
if (variant === 'sheshbesh') check('sheshbesh: standard layout', s0.points[23] === 'ww' && s0.points[5] === 'wwwww' && s0.points[0] === 'bb');
else check(`${variant}: all fifteen on the far point`, s0.points[23] === 'w'.repeat(15) && s0.points[0] === 'b'.repeat(15));
await shot(page, `bot-${variant}-start`);
let hits = 0, bars = 0, pins = 0, turns = 0, sawMid = false;
const t0 = Date.now();
const end = await playToEnd(page, {
style: 'race',
onTurn: async (s) => {
turns++;
if (s.bar[0] || s.bar[1]) bars++;
if (s.points.some(p => p.length > 1 && new Set(p).size > 1)) pins++;
if (!sawMid && turns === 6) { sawMid = true; await shot(page, `bot-${variant}-mid`); }
},
});
void hits;
check(`${variant}: played to the end (${turns} turns, ${Math.round((Date.now() - t0) / 1000)}s)`, end.resultShown && end.match.isOver);
check(`${variant}: winner scored`, Math.max(...end.match.scores) >= 1);
if (variant === 'thirtyone') {
const s = await st(page);
check('31: the win is worth the loser\'s checkers left', s.match.scores[s.match.winner] === 15 - s.off[1 - s.match.winner] || s.match.scores[s.match.winner] >= 1, JSON.stringify([s.match.scores, s.off]));
}
console.log(` ${variant}: ${turns} turns, positions with a checker on the bar ${bars}, with a pin ${pins}`);
await sleep(1200);
await shot(page, `bot-${variant}-result`);
noErrors(page, variant);
}
}
async function sectionScenarios(page) {
console.log('\n=== scenarios ===');
// Doubles: four moves.
await startBotGame(page, { variant: 'sheshbesh', length: 1, difficulty: 'easy', cube: false });
await until(page, s => s.canRoll, { what: 'first roll' });
await page.evaluate(() => window.__bgTest.setDice([4, 4]));
let s = await myTurn(page, { onRolled: async (x) => check('doubles: four dice to play', x.movesLeft.length === 4 && x.dice[0] === 4) });
s = await until(page, x => x.turn === B, { what: 'turn to pass' });
await shot(page, 'scenario-doubles-played');
check('doubles: pip count dropped by 16', s.pips[W] === 167 - 16, String(s.pips));
// No legal move: the turn passes by itself.
await startBotGame(page, { variant: 'sheshbesh', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 12: 'w1', 0: 'w14', 6: 'b2', 7: 'b2', 8: 'b2', 9: 'b2', 10: 'b2', 11: 'b2', 23: 'b3' }, turn: 0 }));
await page.evaluate(() => window.__bgTest.setDice([6, 5]));
await tapSel(page, '#btn-roll');
s = await until(page, x => !x.busy && x.phase === 'blocked', { what: 'blocked' });
check('no legal move: nothing to play', s.legal.length === 0);
const bannerText = await page.$eval('#bgg-banner', el => el.hidden ? '' : el.textContent);
check('no legal move: the banner says so', /حركات|حركة/.test(bannerText), bannerText);
await shot(page, 'scenario-no-move');
s = await until(page, x => x.turn === B, { what: 'turn to pass', timeout: 8000 });
check('no legal move: turn passed to the bot', s.turn === B);
// Hit a blot, the bot enters from the bar.
await startBotGame(page, { variant: 'sheshbesh', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 7: 'w2', 5: 'w13', 4: 'b1', 16: 'b7', 18: 'b7' }, turn: 0 }));
await page.evaluate(() => window.__bgTest.setDice([3, 1], [2, 6]));
await tapSel(page, '#btn-roll');
s = await until(page, x => x.phase === 'moving' && !x.busy);
const hitMove = s.legal.find(m => m.from === 7 && m.to === 4);
check('hit: the hit is offered', hitMove && hitMove.kind === 'hit');
s = await tapMove(page, hitMove);
check('hit: the blot is on the bar', s.bar[B] === 1 && s.points[4] === 'w', JSON.stringify([s.bar, s.points[4]]));
await shot(page, 'scenario-hit');
if (s.phase === 'moving') s = await tapMove(page, chooseMove(s));
s = await until(page, x => x.turn === B && x.bar[B] === 0, { what: 'bot to enter from the bar', timeout: 15000 });
check('bar: the bot entered from the bar', s.bar[B] === 0);
// I enter from the bar (the bar checker is picked up for me).
await startBotGame(page, { variant: 'sheshbesh', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 5: 'w14', 0: 'b5', 11: 'b5', 16: 'b5' }, bar: [1, 0], turn: 0 }));
await page.evaluate(() => window.__bgTest.setDice([3, 5]));
await tapSel(page, '#btn-roll');
s = await until(page, x => x.phase === 'moving' && !x.busy);
check('bar: only the bar checker may move', s.legal.every(m => m.from === 'bar') && s.sel === 'bar', JSON.stringify(s.legal));
await shot(page, 'scenario-enter-highlights');
s = await tapMove(page, s.legal.find(m => m.to === 21) || s.legal[0]);
check('bar: entered', s.bar[W] === 0);
// Blocked on the bar: a closed board.
await startBotGame(page, { variant: 'sheshbesh', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 18: 'b2', 19: 'b2', 20: 'b2', 21: 'b2', 22: 'b2', 23: 'b2', 5: 'w14' }, bar: [1, 0], off: [0, 3], turn: 0 }));
await tapSel(page, '#btn-roll');
s = await until(page, x => !x.busy && x.phase === 'blocked', { what: 'blocked on the bar' });
const bt = await page.$eval('#bgg-banner', el => el.textContent);
check('blocked on the bar: no move, banner', s.legal.length === 0 && /مقفولة/.test(bt), bt);
await shot(page, 'scenario-bar-blocked');
await until(page, x => x.turn === B, { timeout: 8000 });
// Bearing off with a larger die, and a gammon (2) / backgammon (3).
for (const [variant, black, expect] of [['sheshbesh', { 20: 'b15' }, 2], ['sheshbesh', { 3: 'b1', 20: 'b14' }, 3], ['mahbousa', { 20: 'b15' }, 2]]) {
await startBotGame(page, { variant, length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate((pts) => window.__bgTest.setPosition({ points: { 2: 'w1', 1: 'w1', ...pts }, off: [13, 0], turn: 0 }), black);
await page.evaluate(() => window.__bgTest.setDice([6, 5]));
await tapSel(page, '#btn-roll');
s = await until(page, x => x.phase === 'moving' && !x.busy);
check(`${variant}: a 6 bears off from the 3 point (larger die)`, s.legal.some(m => m.from === 2 && m.to === 'off' && m.steps === 6));
await shot(page, `scenario-bearoff-${variant}-${expect}`);
s = await tapMove(page, s.legal.find(m => m.from === 2 && m.to === 'off' && m.steps === 6));
s = await tapMove(page, s.legal.find(m => m.to === 'off'));
s = await until(page, x => x.resultShown, { what: 'result', timeout: 15000 });
check(`${variant}: ${expect === 3 ? 'backgammon is 3' : 'gammon is 2'}`, s.match.scores[W] === expect, String(s.match.scores));
}
// Mahbousa: pin a lone checker.
await startBotGame(page, { variant: 'mahbousa', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 23: 'w13', 10: 'w2', 7: 'b1', 0: 'b14' }, turn: 0 }));
await page.evaluate(() => window.__bgTest.setDice([3, 6]));
await tapSel(page, '#btn-roll');
s = await until(page, x => x.phase === 'moving' && !x.busy);
const pin = s.legal.find(m => m.from === 10 && m.to === 7);
check('mahbousa: landing on a lone checker is a pin', pin && pin.kind === 'pin');
s = await tapMove(page, pin);
check('mahbousa: black is pinned under white', s.points[7] === 'bw', s.points[7]);
await shot(page, 'scenario-mahbousa-pin');
// 31: a single checker closes its point.
await startBotGame(page, { variant: 'thirtyone', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 23: 'w13', 10: 'w2', 7: 'b1', 0: 'b14' }, turn: 0 }));
await page.evaluate(() => window.__bgTest.setDice([3, 6]));
await tapSel(page, '#btn-roll');
s = await until(page, x => x.phase === 'moving' && !x.busy);
check('31: cannot land on a single opposing checker', !s.legal.some(m => m.to === 7));
// 31 scoring: win with the bot holding 9 checkers.
await startBotGame(page, { variant: 'thirtyone', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 1: 'w1', 20: 'b9' }, off: [14, 6], turn: 0 }));
await page.evaluate(() => window.__bgTest.setDice([2, 1]));
await tapSel(page, '#btn-roll');
s = await until(page, x => x.phase === 'moving' && !x.busy);
s = await tapMove(page, s.legal.find(m => m.to === 'off'));
s = await until(page, x => x.resultShown, { timeout: 15000 });
check('31: the winner scores the 9 checkers left', s.match.scores[W] === 9, String(s.match.scores));
noErrors(page, 'scenarios');
}
async function sectionCube(page) {
console.log('\n=== doubling cube ===');
// I double, the bot takes.
await startBotGame(page, { variant: 'sheshbesh', length: 3, difficulty: 'medium', cube: true });
let s = await until(page, x => x.canRoll && x.canDouble, { what: 'double button' });
check('cube: double button offered before rolling', s.canDouble);
await shot(page, 'cube-offer-button');
await tapSel(page, '#btn-double');
s = await until(page, x => !x.waitingAnswer && x.match.cube && x.match.cube.value === 2, { what: 'bot to take' });
check('cube: bot took, cube at 2 on its side', s.match.cube.value === 2 && s.match.cube.owner === B);
await sleep(900);
await shot(page, 'cube-taken');
check('cube: I can no longer double', !s.canDouble);
// I double, the hard bot drops (it is far behind).
await startBotGame(page, { variant: 'sheshbesh', length: 3, difficulty: 'hard', cube: true });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 3: 'w5', 2: 'w5', 1: 'w5', 23: 'b5', 22: 'b5', 12: 'b5' }, turn: 0 }));
s = await until(page, x => x.canDouble);
await tapSel(page, '#btn-double');
s = await until(page, x => x.match.scores[W] === 1 || x.match.scores[B] > 0, { what: 'bot to drop', timeout: 10000 });
check('cube: hard bot dropped, I win the game for 1', s.match.scores[W] === 1, String(s.match.scores));
s = await until(page, x => x.match.gameNumber === 2 && x.turn !== undefined, { what: 'next game', timeout: 10000 });
check('cube: the match goes on to game 2', s.match.gameNumber === 2 && !s.match.isOver);
// The bot doubles me; I take.
await startBotGame(page, { variant: 'sheshbesh', length: 3, difficulty: 'medium', cube: true });
await until(page, x => x.canRoll);
await page.evaluate(() => { window.__bgTest.setPosition({ points: { 5: 'w5', 12: 'w5', 23: 'w5', 20: 'b5', 21: 'b5', 22: 'b5' }, turn: 0 }); window.__bgTest.setDice([2, 1]); });
await myTurn(page);
s = await until(page, x => x.offerShown, { what: 'bot to double', timeout: 15000 });
await sleep(900);
await shot(page, 'cube-bot-offers');
await tapSel(page, '#btn-accept-double');
s = await until(page, x => x.match.cube.value === 2 && !x.offerShown, { what: 'take' });
check('cube: I took, cube at 2 on my side', s.match.cube.owner === W);
s = await until(page, x => x.turn === W && x.canRoll, { what: 'bot to finish its turn', timeout: 20000 });
check('cube: the bot rolled and played after my take', s.turn === W);
check('cube: I own the cube, so I may redouble', s.canDouble);
// The bot doubles me; I drop.
await startBotGame(page, { variant: 'sheshbesh', length: 3, difficulty: 'medium', cube: true });
await until(page, x => x.canRoll);
await page.evaluate(() => { window.__bgTest.setPosition({ points: { 5: 'w5', 12: 'w5', 23: 'w5', 20: 'b5', 21: 'b5', 22: 'b5' }, turn: 0 }); window.__bgTest.setDice([2, 1]); });
await myTurn(page);
await until(page, x => x.offerShown, { timeout: 15000 });
await tapSel(page, '#btn-decline-double');
s = await until(page, x => x.match.scores[B] === 1, { what: 'drop', timeout: 8000 });
check('cube: I dropped, the bot wins the game for 1', s.match.scores[B] === 1 && s.match.scores[W] === 0);
noErrors(page, 'cube');
}
async function sectionMatch(page) {
console.log('\n=== a 3-point match with the cube ===');
await startBotGame(page, { variant: 'sheshbesh', length: 3, difficulty: 'medium', cube: true });
let games = new Set(), crawfordSeen = false, doubleOfferedInCrawford = false;
const end = await playToEnd(page, {
style: 'race',
onTurn: async (s) => {
games.add(s.match.gameNumber);
if (s.match.crawford) { crawfordSeen = true; if (s.canDouble) doubleOfferedInCrawford = true; }
},
});
check(`match: finished after ${games.size} game(s)`, end.match.isOver && Math.max(...end.match.scores) >= 3, String(end.match.scores));
if (crawfordSeen) check('match: no doubling in the Crawford game', !doubleOfferedInCrawford);
const sc = await page.$$eval('.bgr-n', ns => ns.map(n => n.textContent));
await sleep(2200);
const sc2 = await page.$$eval('.bgr-n', ns => ns.map(n => n.textContent));
check('match: result card shows the final score', Number(sc2[0]) === end.match.scores[W] && Number(sc2[1]) === end.match.scores[B], JSON.stringify([sc, sc2, end.match.scores]));
await shot(page, 'match-result');
noErrors(page, 'match');
}
async function sectionResult(page) {
console.log('\n=== result card: play again, back to the table ===');
await startBotGame(page, { variant: 'sheshbesh', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 0: 'w1', 20: 'b15' }, off: [14, 0], turn: 0 }));
await page.evaluate(() => window.__bgTest.setDice([1, 2]));
await tapSel(page, '#btn-roll');
let s = await until(page, x => x.phase === 'moving' && !x.busy);
s = await tapMove(page, s.legal.find(m => m.to === 'off'));
await page.waitForSelector('.bgr-overlay .bgr-card', { visible: true, timeout: 10000 });
await sleep(700);
await shot(page, 'result-win-flip');
await sleep(1600);
const ribbon = await page.$eval('.bgr-ribbon', el => ({ text: el.textContent.trim(), green: el.classList.contains('green') }));
check('result: green ribbon "فزت!"', ribbon.green && ribbon.text.includes('فزت'), JSON.stringify(ribbon));
const hasTrophy = await page.$('.bgr-sticker .gi-trophy');
check('result: trophy sticker on a win', !!hasTrophy);
const pay = await page.evaluate(() => ({ shown: !document.querySelector('#bgr-pay').hidden, coins: document.querySelector('#bgr-coins')?.textContent }));
console.log(` payout shown: ${pay.shown} coins ${pay.coins} (only what the server confirms)`);
const hud = await page.$('#hud-coins');
check('result: the HUD purse is on screen for the coins', !!hud && await hud.isIntersectingViewport());
await shot(page, 'result-win');
await tapSel(page, '#btn-rematch');
await page.waitForFunction(() => window.__bgTest && window.__bgTest.state().match.gameNumber === 1 && !window.__bgTest.state().resultShown, { timeout: 10000, polling: 200 });
await setSpeed(page);
s = await st(page);
check('result: play again starts a fresh game with the same settings', s.variant === 'sheshbesh' && s.match.length === 1 && s.points[23] === 'ww');
// A loss: drop the bot's double... simpler: let the bot bear its last checker off.
await until(page, x => x.canRoll);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 12: 'w15', 23: 'b1' }, off: [0, 14], turn: 1 }));
await page.waitForSelector('.bgr-overlay .bgr-card', { visible: true, timeout: 20000 });
await sleep(2200);
const lr = await page.$eval('.bgr-ribbon', el => ({ text: el.textContent.trim(), green: el.classList.contains('green') }));
check('result: red ribbon "خسرت" on a loss', !lr.green && lr.text.includes('خسرت'), JSON.stringify(lr));
await shot(page, 'result-loss');
await tapSel(page, '#btn-exit');
await page.waitForSelector('.hand-card[data-game]', { visible: true, timeout: 10000 });
check('result: back to the table', true);
noErrors(page, 'result');
}
async function sectionPayout(page) {
console.log('\n=== a bot win pays (after the 90 s guard) ===');
const coins0 = (await sb(`profiles?id=eq.${accounts[0].id}&select=coins`))?.[0]?.coins;
await startBotGame(page, { variant: 'sheshbesh', length: 1, difficulty: 'easy', cube: false });
await until(page, x => x.canRoll);
await sleep(93000);
await page.evaluate(() => window.__bgTest.setPosition({ points: { 0: 'w1', 20: 'b15' }, off: [14, 0], turn: 0 }));
await page.evaluate(() => window.__bgTest.setDice([1, 2]));
await tapSel(page, '#btn-roll');
let s = await until(page, x => x.phase === 'moving' && !x.busy);
await tapMove(page, s.legal.find(m => m.to === 'off'));
await page.waitForSelector('#bgr-coins-line:not([hidden])', { timeout: 15000 }).catch(() => {});
await sleep(2600);
const pay = await page.evaluate(() => ({ line: !!document.querySelector('#bgr-coins-line:not([hidden])'), coins: document.querySelector('#bgr-coins')?.textContent, xp: document.querySelector('#bgr-xp')?.textContent }));
const coins1 = (await sb(`profiles?id=eq.${accounts[0].id}&select=coins`))?.[0]?.coins;
check('payout: the coin line shows what the server paid', pay.line && Number(String(pay.coins).replace(/[^0-9]/g, '')) > 0, JSON.stringify(pay));
check('payout: the coins reached the profile', coins1 > coins0, `${coins0} -> ${coins1}`);
await shot(page, 'result-win-paid');
noErrors(page, 'payout');
}
async function sectionLayout(browser, acc) {
console.log('\n=== layout at three phone sizes ===');
for (const [w, h] of [[360, 640], [390, 844], [430, 932]]) {
const page = await bootPage(browser, acc, { w, h, label: `${w}x${h}` });
await startBotGame(page, { variant: 'sheshbesh', length: 3, difficulty: 'easy', cube: true });
await until(page, x => x.canRoll);
await sleep(1200); // let the scene and the roll button land
const box = await page.evaluate(() => {
const r = (sel) => { const e = document.querySelector(sel); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height, bottom: b.bottom, right: b.right }; };
return { canvas: r('#bg-canvas'), roll: r('#btn-roll'), dbl: r('#btn-double'), opp: r('#opp-card'), me: r('#my-card'), vw: innerWidth, vh: innerHeight, scrollW: document.documentElement.scrollWidth };
});
check(`${w}x${h}: board fills the width`, box.canvas.w >= w - 12, JSON.stringify(box.canvas));
check(`${w}x${h}: nothing overflows sideways`, box.scrollW <= w);
check(`${w}x${h}: roll button on screen and big`, box.roll && box.roll.bottom <= h && box.roll.h >= 56, JSON.stringify(box.roll));
check(`${w}x${h}: plates and board do not overlap`, box.opp.bottom <= box.canvas.y + 1 && box.canvas.bottom <= box.me.y + 1, JSON.stringify([box.opp, box.canvas, box.me]));
const g = await geo(page);
check(`${w}x${h}: checkers at least 9px radius`, g.layout.r >= 9, String(g.layout.r));
await shot(page, `layout-${w}x${h}-start`);
await page.evaluate(() => window.__bgTest.setDice([6, 4]));
await tapSel(page, '#btn-roll');
let s = await until(page, x => x.phase === 'moving' && !x.busy);
const g2 = await geo(page);
await tapAt(page, g2.points[12].top);
await until(page, x => x.sel === 12);
await shot(page, `layout-${w}x${h}-highlights`);
s = await st(page);
await tapMove(page, s.legal.find(m => m.from === 12));
if (w === 390) {
// Resize mid-game: the board re-lays itself out and taps still land.
await page.setViewport({ width: 360, height: 700, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
await sleep(500);
s = await st(page);
const g3 = await geo(page);
check('resize: board follows the new width', Math.abs(g3.canvas.w - 352) <= 12, String(g3.canvas.w));
if (s.phase === 'moving') {
s = await tapMove(page, chooseMove(s));
check('resize: taps still land on the right points', s.movesPlayed.length >= 2 || s.turn === B);
}
await shot(page, 'layout-resized-360x700');
}
noErrors(page, `layout ${w}x${h}`);
await page.close();
}
}
// ── Online ─────────────────────────────────────────────────────────────────
/** An in-test stand-in for api/backgammon-match.php, with its semantics. */
function makeFakeServer() {
const queue = [];
const matches = new Map();
const uuid = () => crypto.randomUUID();
return {
matches,
handle(userId, body) {
const a = body.action;
if (a === 'queue') {
const other = queue.find(q => q.user_id !== userId && !q.match_id && q.variant === body.variant);
if (other) {
const id = uuid();
const players = [other.user_id, userId];
const whiteIdx = Math.random() < 0.5 ? 0 : 1;
const colors = [whiteIdx === 0 ? 'white' : 'black', whiteIdx === 1 ? 'white' : 'black'];
matches.set(id, { id, status: 'in_progress', players, colors, variant: body.variant, match_length: body.match_length, game_state: { variant: body.variant }, scores: [0, 0] });
other.match_id = id;
return { match_id: id, player_index: 1, players, color: colors[1] };
}
queue.push({ user_id: userId, variant: body.variant, match_length: body.match_length, match_id: null });
return { queued: true };
}
if (a === 'status') {
const q = queue.find(x => x.user_id === userId && x.match_id);
if (!q) return { waiting: true };
queue.splice(queue.indexOf(q), 1);
const m = matches.get(q.match_id);
const idx = m.players.indexOf(userId);
return { match_id: m.id, player_index: idx, players: m.players, color: m.colors[idx] };
}
if (a === 'dequeue') { for (let i = queue.length - 1; i >= 0; i--) if (queue[i].user_id === userId) queue.splice(i, 1); return { success: true }; }
const m = matches.get(body.match_id);
if (!m) return { error: 'Not found' };
if (a === 'move') { if (body.game_state) m.game_state = JSON.parse(JSON.stringify(body.game_state)); if (body.cube) m.cube_state = body.cube; m.updated_at = new Date().toISOString(); return { success: true }; }
if (a === 'get') return JSON.parse(JSON.stringify(m));
if (a === 'leave') { m.status = 'abandoned'; m.game_state = { ...m.game_state, left_by: userId }; return { success: true }; }
if (a === 'complete') { m.status = 'completed'; m.winner_id = body.winner; return { success: true, rewards: [] }; }
if (a === 'heartbeat') return { status: 'ok' };
return { error: 'Invalid action' };
},
};
}
async function useFake(page, fake, acc) {
await page.setRequestInterception(true);
page.on('request', async req => {
if (req.url().includes('/api/backgammon-match.php') && req.method() === 'POST') {
let body = {};
try { body = JSON.parse(req.postData() || '{}'); } catch (e) {}
const res = fake.handle(acc.id, body);
return req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(res) });
}
// The fake's matches are not in the database, so the reactions channel
// (which checks seats there) is answered here too: no messages.
if (req.url().includes('/api/match-chat.php')) {
const mid = new URL(req.url()).searchParams.get('match_id') || '';
if (fake.matches.has(mid) || (req.postData() || '').includes('"match_id"')) {
return req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages: [], now: new Date().toISOString() }) });
}
}
req.continue();
});
}
async function goOnline(page, { variant = 'sheshbesh', length = 3, cube = true } = {}) {
await page.evaluate(async () => { const m = await import('/public/js/core/scene.js'); m.exitGameMode(); m.switchWorld('play', true); });
await sleep(700);
await pickBackgammonCard(page);
await tapSel(page, '#btn-multi');
await sleep(800);
await tapSel(page, `#variant-grid [data-variant="${variant}"]`);
await tapSel(page, `#length-grid [data-len="${length}"]`);
await tapSel(page, `#cube-grid [data-cube="${cube ? 'on' : 'off'}"]`);
await tapSel(page, '#btn-start');
}
const boardSig = (s) => JSON.stringify([s.points, s.bar, s.off, s.turn, s.match.scores, s.match.cube && [s.match.cube.value, s.match.cube.owner, s.match.cube.offered]]);
async function sectionOnline(browser) {
console.log(`\n=== online: two players (${ONLINE}) ===`);
const modes = ONLINE === 'auto' ? ['real', 'fake'] : [ONLINE];
for (const mode of modes) {
const A = await createAccount('لاعب أ تجربة');
const Bacc = await createAccount('لاعب ب تجربة');
// Each player in a browser of their own: a background tab renders no
// frames, and the board's animations run on frames.
const browserB = await launch();
const pa = await bootPage(browser, A, { label: 'A' });
const pb = await bootPage(browserB, Bacc, { label: 'B' });
const fake = mode === 'fake' ? makeFakeServer() : null;
if (fake) { await useFake(pa, fake, A); await useFake(pb, fake, Bacc); }
try {
await goOnline(pa);
await sleep(1500);
await goOnline(pb);
const okA = await pa.waitForFunction(() => window.__bgTest && window.__bgTest.state().variant, { timeout: 45000, polling: 200 }).then(() => true, () => false);
const okB = await pb.waitForFunction(() => window.__bgTest && window.__bgTest.state().variant, { timeout: 15000, polling: 200 }).then(() => true, () => false);
if (!check(`online ${mode}: both players matched into a game`, okA && okB)) {
await shot(pa, `online-${mode}-A-unmatched`); await shot(pb, `online-${mode}-B-unmatched`);
pa.__errors.length = 0; pb.__errors.length = 0;
continue;
}
await setSpeed(pa); await setSpeed(pb);
let sa = await st(pa), sb2 = await st(pb);
check(`online ${mode}: opposite colours`, sa.myColor !== sb2.myColor, `${sa.myColor} ${sb2.myColor}`);
check(`online ${mode}: same match settings`, sa.match.length === sb2.match.length && sa.variant === sb2.variant);
const pageOf = (color) => (sa.myColor === color ? pa : pb);
await shot(pa, `online-${mode}-A-start`); await shot(pb, `online-${mode}-B-start`);
// Play turns: the player to move rolls and plays by tapping; then both boards must agree.
let turnsChecked = 0, doubled = false;
for (let i = 0; i < 10; i++) {
sa = await st(pa);
const mover = pageOf(sa.turn);
const watcher = mover === pa ? pb : pa;
const ms = await until(mover, x => x.canRoll || x.offerShown || x.resultShown, { timeout: 30000, what: 'mover ready' });
if (ms.resultShown) break;
if (!doubled && ms.canDouble && i >= 2) {
doubled = true;
await tapSel(mover, '#btn-double');
await until(watcher, x => x.offerShown, { timeout: 15000, what: 'offer to arrive' });
await shot(watcher, `online-${mode}-offer`);
await tapSel(watcher, '#btn-accept-double');
const mv = await until(mover, x => x.match.cube && x.match.cube.value === 2 && !x.waitingAnswer, { timeout: 15000, what: 'take to arrive' });
const wv = await st(watcher);
check(`online ${mode}: cube agrees on both (value 2, taker owns it)`, mv.match.cube.value === 2 && wv.match.cube.value === 2 && mv.match.cube.owner === wv.match.cube.owner && wv.match.cube.owner === wv.myColor, JSON.stringify([mv.match.cube, wv.match.cube]));
}
await myTurn(mover);
// Wait for the turn to reach the watcher, then compare.
await until(watcher, x => !x.replaying && !x.busy && (x.turn === x.myColor || x.resultShown), { timeout: 30000, what: 'turn to arrive' });
const a = await st(pa), b = await st(pb);
if (check(`online ${mode}: boards agree after turn ${i + 1}`, boardSig(a) === boardSig(b), `${boardSig(a)}\n${boardSig(b)}`)) turnsChecked++;
if (a.resultShown || b.resultShown) break;
}
await shot(pa, `online-${mode}-A-mid`); await shot(pb, `online-${mode}-B-mid`);
check(`online ${mode}: several turns stayed in sync`, turnsChecked >= 4, String(turnsChecked));
// One resigns.
await tapSel(pb, '#btn-quit');
await tapSel(pb, '#modal-confirm');
await pb.waitForSelector('.bgr-overlay .bgr-ribbon', { visible: true, timeout: 10000 });
const rb = await pb.$eval('.bgr-ribbon', el => el.textContent.trim());
check(`online ${mode}: the resigner sees the loss`, rb.includes('خسرت'), rb);
const won = await pa.waitForSelector('.bgr-overlay .bgr-ribbon', { visible: true, timeout: 20000 }).then(() => true, () => false);
const ra = won ? await pa.$eval('.bgr-ribbon', el => el.textContent.trim()) : '';
check(`online ${mode}: the other player sees the win`, ra.includes('فزت'), ra);
await sleep(1500);
await shot(pa, `online-${mode}-A-won`); await shot(pb, `online-${mode}-B-resigned`);
if (mode === 'real') {
const rows = await sb(`backgammon_matches?select=id,status&players=cs.${encodeURIComponent(JSON.stringify([A.id]))}`);
check('online real: the match row is closed', rows.length === 1 && ['abandoned', 'completed'].includes(rows[0].status), JSON.stringify(rows));
}
noErrors(pa, `online ${mode} A`);
noErrors(pb, `online ${mode} B`);
} catch (e) {
check(`online ${mode}: ran without throwing`, false, e.message);
try { await shot(pa, `online-${mode}-A-error`); await shot(pb, `online-${mode}-B-error`); } catch (_) {}
} finally {
await pa.close().catch(() => {}); await browserB.close().catch(() => {});
}
}
}
// ── Run ────────────────────────────────────────────────────────────────────
function launch() {
return puppeteer.launch({
executablePath: CHROME, headless: HEADFUL ? false : 'new', protocolTimeout: 120000,
args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding'],
});
}
let browser;
const t0 = Date.now();
try {
browser = await launch();
const solo = await createAccount('ليلى تجربة');
const page = await bootPage(browser, solo, { label: 'solo' });
const run = async (key, fn) => {
if (!want(key)) return;
try { await fn(); } catch (e) { check(`${key}: ran without throwing`, false, e.stack); try { await shot(page, `${key}-error`); } catch (_) {} page.__errors.length = 0; }
};
await run('bot', () => sectionBot(page));
await run('scenarios', () => sectionScenarios(page));
await run('cube', () => sectionCube(page));
await run('result', () => sectionResult(page));
await run('match', () => sectionMatch(page));
await run('payout', () => sectionPayout(page));
if (want('layout')) await sectionLayout(browser, solo).catch(async e => {
check('layout: ran without throwing', false, e.stack);
for (const p of pages) if (!p.isClosed()) await shot(p, 'layout-error-' + p.__label).catch(() => {});
});
if (want('online')) await sectionOnline(browser).catch(e => check('online: ran without throwing', false, e.message));
} finally {
try { await browser?.close(); } catch (e) {}
await removeAccounts();
console.log(`\n${pass} passed, ${failures.length} failed in ${Math.round((Date.now() - t0) / 1000)}s`);
if (failures.length) console.log('failures:\n ' + failures.join('\n '));
console.log(`screenshots in ${OUT}`);
process.exit(failures.length ? 1 : 0);
}
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