Commit 4ce3d90f authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(ludo): online boards stay in sync; rules, bots, rewards; flat board

Online: at speed a client's writes trail its own board, and a stale-row
check read that lag as a stale row, reset the client and let it play again
from the older board: two writers with one sequence number. The check now
fires only with nothing in flight, every write is dropped while a resync
is pending, and a resync takes the row's sequence. The quiet-player notice
shows for each new silence, not once per match.

Rules: in 3-4 player games the first finisher kept a turn nobody could
take and the game stalled; three sixes is enforced and no longer carries
into the next turn. Seats come from the match, not the room. Exactly one
client settles a match, with real ids. Bots score their moves by
difficulty. A leaver is announced and in a duel the one who stays wins.
Names in the roll button are escaped.

Look: flat quadrants on white, pawns on a lip, a flat tumbling die.
tests/play/ludo.mjs 73/73 (local 4, duel, online), ludo-rules 60/60.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 95acecec
// The ludo board and its tokens, drawn on canvas.
//
// Night Arcade: the board is flat and bright: four quadrants in the players'
// inks on white, a ruled track, each home column laid in its owner's ink, the
// safe squares starred, entry arrows, the four inks meeting in the centre. The
// ink tile around it is CSS (game.js). Tokens are flat pawns (base, bell, ball
// head) standing on a darker lip of their own shape, rimmed in white.
//
// A view can be turned in quarter turns so a player online always sits at the
// bottom left; tokens are placed through the view but always stand upright.
import { SHARED_PATH, HOME_COLUMNS, START_SQUARES, SAFE_SQUARES, HOME_ENTRY, HOME_BASES } from '../logic/board-map.js';
const TAU = Math.PI * 2;
// ── Colour helpers ──────────────────────────────────────────────────────────
export function rgbOf(c) {
const s = String(c || '').trim();
let m = s.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
let h = m[1];
if (h.length < 6) h = h.split('').map(x => x + x).join('');
const n = parseInt(h.slice(0, 6), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
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];
}
/** `amount` of colour a laid over colour b. */
export function mix(a, b, amount) {
const x = rgbOf(a), y = rgbOf(b);
const ch = i => Math.round(y[i] + (x[i] - y[i]) * amount);
return `rgb(${ch(0)},${ch(1)},${ch(2)})`;
}
export function alpha(c, a) { const [r, g, b] = rgbOf(c); return `rgba(${r},${g},${b},${a})`; }
const lighten = (c, k) => mix('#ffffff', c, k);
const darken = (c, k) => mix('#000000', c, k);
// ── Geometry ────────────────────────────────────────────────────────────────
/**
* A view of the board `size` px square, turned `rot` quarter turns clockwise.
* Board coordinates are pixels on the unturned board; view coordinates are
* where they land on screen.
*/
export function makeView(size, rot = 0) {
const B = size, cs = size / 15, k = ((rot % 4) + 4) % 4;
const toView = (x, y) => {
for (let i = 0; i < k; i++) { const nx = B - y; y = x; x = nx; }
return { x, y };
};
return {
size: B, cs, rot: k, toView,
/** Centre of a grid cell [col,row] in view px. */
cell(col, row) { return toView(col * cs + cs / 2, row * cs + cs / 2); },
};
}
/** Grid cell of a token: local position for a board slot, or its nest in the yard. */
export function cellOf(slot, pos, pieceIdx) {
if (pos < 0) return HOME_BASES[slot][pieceIdx];
if (pos > 50) return HOME_COLUMNS[slot][Math.min(pos - 51, 5)];
return SHARED_PATH[(pos + START_SQUARES[slot]) % 52];
}
/** The cells a token passes through moving from `from` to `to` (excluding from). */
export function pathCells(slot, from, to, pieceIdx) {
const out = [];
if (from < 0) { out.push(cellOf(slot, 0, pieceIdx)); return out; }
for (let p = from + 1; p <= to; p++) out.push(cellOf(slot, p, pieceIdx));
return out;
}
// ── Shapes ──────────────────────────────────────────────────────────────────
function rrect(c, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
c.beginPath();
c.moveTo(x + r, y);
c.arcTo(x + w, y, x + w, y + h, r);
c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r);
c.arcTo(x, y, x + w, y, r);
c.closePath();
}
function starPath(c, x, y, R, inner = 0.46) {
c.beginPath();
for (let k = 0; k < 10; k++) {
const a = -Math.PI / 2 + (k * Math.PI) / 5;
const d = k % 2 ? R * inner : R;
const px = x + Math.cos(a) * d, py = y + Math.sin(a) * d;
if (k) c.lineTo(px, py); else c.moveTo(px, py);
}
c.closePath();
}
/** An arrow at (x,y) pointing at angle `a`, `s` long. */
function arrowPath(c, x, y, a, s) {
c.save();
c.translate(x, y); c.rotate(a);
c.beginPath();
c.moveTo(s * 0.5, 0);
c.lineTo(-s * 0.05, -s * 0.42);
c.lineTo(-s * 0.05, -s * 0.16);
c.lineTo(-s * 0.5, -s * 0.16);
c.lineTo(-s * 0.5, s * 0.16);
c.lineTo(-s * 0.05, s * 0.16);
c.lineTo(-s * 0.05, s * 0.42);
c.closePath();
c.restore();
}
// ── The board ───────────────────────────────────────────────────────────────
const YARDS = [[0, 9], [0, 0], [9, 0], [9, 9]]; // top-left cell of each slot's yard
/**
* Draw the static board into `c` (already scaled for the device) for `view`.
* Flat: bright quadrants on white, ruled track, home columns in each ink.
* opts: { colors: [red, green, yellow, blue], card, ink, line, active: Set of slots }
*/
export function drawBoard(c, view, opts) {
const B = view.size, cs = view.cs;
const { colors, card, ink } = opts;
const line = opts.line || mix(ink, card, 0.12);
const active = opts.active || new Set([0, 1, 2, 3]);
// An empty seat's colours are laid on faintly, as on a board nobody sat at.
const inkOf = p => (active.has(p) ? colors[p] : mix(colors[p], card, 0.22));
const lw = Math.max(1, Math.round(cs * 0.05));
c.save();
c.clearRect(0, 0, B, B);
c.translate(B / 2, B / 2); c.rotate(view.rot * Math.PI / 2); c.translate(-B / 2, -B / 2);
c.fillStyle = card;
c.fillRect(0, 0, B, B);
// Home columns and start squares: flat ink.
for (let p = 0; p < 4; p++) {
c.fillStyle = inkOf(p);
HOME_COLUMNS[p].slice(0, 5).forEach(([x, y]) => c.fillRect(x * cs, y * cs, cs, cs));
const [sx, sy] = SHARED_PATH[START_SQUARES[p]];
c.fillRect(sx * cs, sy * cs, cs, cs);
}
// The ruled track.
c.strokeStyle = line;
c.lineWidth = lw;
c.beginPath();
for (let i = 0; i <= 3; i++) {
const v = (6 + i) * cs;
c.moveTo(v, 0); c.lineTo(v, 6 * cs);
c.moveTo(v, 9 * cs); c.lineTo(v, B);
c.moveTo(0, v); c.lineTo(6 * cs, v);
c.moveTo(9 * cs, v); c.lineTo(B, v);
}
for (let i = 0; i <= 6; i++) {
c.moveTo(6 * cs, i * cs); c.lineTo(9 * cs, i * cs);
c.moveTo(6 * cs, (i + 9) * cs); c.lineTo(9 * cs, (i + 9) * cs);
c.moveTo(i * cs, 6 * cs); c.lineTo(i * cs, 9 * cs);
c.moveTo((i + 9) * cs, 6 * cs); c.lineTo((i + 9) * cs, 9 * cs);
}
c.stroke();
// Yards: a flat quadrant, a white court on a lip of the darker ink, four nests.
YARDS.forEach(([cx, cy], p) => {
const x = cx * cs, y = cy * cs, col = inkOf(p);
c.fillStyle = col;
c.fillRect(x, y, 6 * cs, 6 * cs);
const m = cs * 0.8, w = 6 * cs - 2 * m, rad = cs * 0.75;
rrect(c, x + m, y + m + cs * 0.14, w, w, rad);
c.fillStyle = darken(col, 0.22);
c.fill();
rrect(c, x + m, y + m, w, w, rad);
c.fillStyle = card;
c.fill();
HOME_BASES[p].forEach(([nx, ny]) => {
const px = nx * cs + cs / 2, py = ny * cs + cs / 2, R = cs * 0.66;
c.beginPath(); c.arc(px, py, R, 0, TAU);
c.fillStyle = mix(col, card, 0.22);
c.fill();
c.beginPath(); c.arc(px, py, R * 0.62, 0, TAU);
c.fillStyle = mix(col, card, 0.36);
c.fill();
});
});
// Safe squares: a flat star (white on a start square).
const starts = new Set(START_SQUARES);
SAFE_SQUARES.forEach(idx => {
const [x, y] = SHARED_PATH[idx];
starPath(c, x * cs + cs / 2, y * cs + cs / 2, cs * 0.34);
c.fillStyle = starts.has(idx) ? card : mix(ink, card, 0.2);
c.fill();
});
// Entry arrows where each colour turns off the track into its home column.
HOME_ENTRY.forEach((idx, p) => {
const [x, y] = SHARED_PATH[idx];
const [hx, hy] = HOME_COLUMNS[p][0];
arrowPath(c, x * cs + cs / 2, y * cs + cs / 2, Math.atan2(hy - y, hx - x), cs * 0.66);
c.fillStyle = inkOf(p);
c.fill();
});
// Centre: the four inks meet, split by thin white lines.
const cx = 7.5 * cs, cy = 7.5 * cs, hs = 1.5 * cs;
[
[0, [cx - hs, cy + hs], [cx + hs, cy + hs]],
[1, [cx - hs, cy - hs], [cx - hs, cy + hs]],
[2, [cx - hs, cy - hs], [cx + hs, cy - hs]],
[3, [cx + hs, cy - hs], [cx + hs, cy + hs]],
].forEach(([p, a, b]) => {
c.beginPath(); c.moveTo(a[0], a[1]); c.lineTo(b[0], b[1]); c.lineTo(cx, cy); c.closePath();
c.fillStyle = inkOf(p);
c.fill();
});
c.strokeStyle = card;
c.lineWidth = Math.max(1.5, cs * 0.08);
c.beginPath();
c.moveTo(cx - hs, cy - hs); c.lineTo(cx + hs, cy + hs);
c.moveTo(cx + hs, cy - hs); c.lineTo(cx - hs, cy + hs);
c.stroke();
c.restore();
}
// ── Tokens ──────────────────────────────────────────────────────────────────
// A pawn is flat colour standing on a darker lip of its own shape, with a
// white rim so it reads on a square of its own colour. Drawn once per colour
// and size into a sprite, then stamped.
const spriteCache = new Map();
/** The sprite for a pawn of base radius r: { canvas, w, h, ax, ay } (ax/ay = base centre). */
export function pawnSprite(color, r, dpr = 1, rim = '#FFFFFF') {
const key = `${color}|${Math.round(r * 10)}|${dpr}|${rim}`;
if (spriteCache.has(key)) return spriteCache.get(key);
const w = r * 2.4, h = r * 3.1;
const cv = document.createElement('canvas');
cv.width = Math.ceil(w * dpr); cv.height = Math.ceil(h * dpr);
const c = cv.getContext('2d');
c.scale(dpr, dpr);
const ax = w / 2, ay = h - r * 0.62; // base centre
drawPawnShape(c, ax, ay, r, color, rim);
const s = { canvas: cv, w, h, ax, ay };
spriteCache.set(key, s);
return s;
}
/** The pawn's silhouette: a base disc, a bell, a ball head. */
function pawnPath(c, x, y, r) {
c.beginPath();
c.ellipse(x, y, r * 0.96, r * 0.4, 0, 0, TAU);
c.moveTo(x - r * 0.8, y - r * 0.1);
c.bezierCurveTo(x - r * 0.76, y - r * 0.82, x - r * 0.34, y - r * 0.92, x - r * 0.32, y - r * 1.34);
c.lineTo(x + r * 0.32, y - r * 1.34);
c.bezierCurveTo(x + r * 0.34, y - r * 0.92, x + r * 0.76, y - r * 0.82, x + r * 0.8, y - r * 0.1);
c.closePath();
c.moveTo(x + r * 0.62, y - r * 1.78);
c.arc(x, y - r * 1.78, r * 0.62, 0, TAU);
}
function drawPawnShape(c, x, y, r, color, rim) {
const lip = r * 0.22;
c.lineJoin = 'round';
// The rim, around lip and body together.
c.lineWidth = r * 0.26;
c.strokeStyle = rim;
pawnPath(c, x, y + lip, r); c.stroke();
pawnPath(c, x, y, r); c.stroke();
// The lip: the same shape, a shade darker, just below.
pawnPath(c, x, y + lip, r);
c.fillStyle = darken(color, 0.28);
c.fill();
// The face.
pawnPath(c, x, y, r);
c.fillStyle = color;
c.fill();
// The base shows its top edge as a flat lighter band, the head a flat cap.
c.beginPath();
c.ellipse(x, y - r * 0.06, r * 0.7, r * 0.2, 0, Math.PI, TAU);
c.fillStyle = lighten(color, 0.16);
c.fill();
}
/**
* Stamp a pawn whose base centre stands at (x, y). `lift` raises it off the
* board (its shadow stays), `squash` (0..1) presses it flat on landing,
* `scale` shrinks it when tokens share a square.
*/
export function drawPawn(c, x, y, r, color, { lift = 0, squash = 0, scale = 1, dpr = 1, rim, shadow = true, alphaMul = 1 } = {}) {
const rr = r * scale;
if (shadow && lift > 0.5) {
const spread = 1 + Math.min(lift / (r * 3), 0.6);
c.save();
c.globalAlpha = (0.22 / spread) * alphaMul;
c.beginPath();
c.ellipse(x, y + rr * 0.3, rr * 0.95 * spread, rr * 0.36 * spread, 0, 0, TAU);
c.fillStyle = '#000000';
c.fill();
c.restore();
}
// Sprites are cached at half-pixel radii; scale the nearest one to fit.
const rs = Math.max(2, Math.round(r * 2) / 2);
const s = pawnSprite(color, rs, dpr, rim);
const k = rr / rs;
const sx = k * (1 + 0.2 * squash), sy = k * (1 - 0.24 * squash);
c.save();
if (alphaMul !== 1) c.globalAlpha = alphaMul;
c.translate(x, y - lift);
c.scale(sx, sy);
c.drawImage(s.canvas, -s.ax, -s.ay, s.w, s.h);
c.restore();
}
/** A pawn as inline SVG markup (plates, rows): the same flat pawn on its lip. */
export function pawnSVG(color, size = 22) {
const lip = darken(color, 0.28), band = lighten(color, 0.16);
const body = 'M10 46 C11 32 17.5 29 18.5 21.5 L31.5 21.5 C32.5 29 39 32 40 46 Z';
const shape = dy => `<g transform="translate(0 ${dy})"><ellipse cx="25" cy="48" rx="19" ry="8"/><path d="${body}"/><circle cx="25" cy="13" r="11.5"/></g>`;
return `<svg class="ld-pawn-svg" width="${size}" height="${Math.round(size * 1.24)}" viewBox="0 0 50 62" aria-hidden="true">
<g fill="#fff" stroke="#fff" stroke-width="5" stroke-linejoin="round">${shape(4)}${shape(0)}</g>
<g fill="${lip}">${shape(4)}</g>
<g fill="${color}">${shape(0)}</g>
<path d="M11.5 47 A13.5 4.2 0 0 1 38.5 47 Z" fill="${band}"/>
</svg>`;
}
// Ludo bot: scores every legal move the way a decent player thinks about it
// (get home, hit, come out, reach safety, don't stop in front of an enemy) and
// plays the best one. The level decides how carefully it looks.
import { getValidMoves, globalSquare, isSafeSquare, pieceIndexOf, HOME_ENTRY_POS, TRACK_LEN, FINISH_POS } from './rules.js';
/**
* How many opponent tokens could land on shared square `g` with one roll.
* A token that would turn into its own home column before reaching g is no threat.
*/
function threatsOn(game, playerIdx, g) {
if (g == null || isSafeSquare(g)) return 0;
let n = 0;
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx || game.players[i].finished) continue;
for (const pc of game.players[i].pieces) {
if (pc.finished || pc.pos < 0 || pc.pos > HOME_ENTRY_POS) continue;
const og = globalSquare(game, i, pc.pos);
const d = (g - og + TRACK_LEN) % TRACK_LEN;
if (d >= 1 && d <= 6 && pc.pos + d <= HOME_ENTRY_POS) n++;
}
}
return n;
}
/** Score one move. Higher is better. */
export function scoreMove(game, playerIdx, move) {
const pieces = game.players[playerIdx].pieces;
const piece = pieces[pieceIndexOf(move)];
let s = move.to; // progress
if (move.type === 'finish') s += 1000;
if (move.type === 'capture') {
const g = globalSquare(game, playerIdx, move.to);
let victims = 0;
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue;
for (const pc of game.players[i].pieces) {
if (!pc.finished && pc.pos >= 0 && globalSquare(game, i, pc.pos) === g) victims += 20 + pc.pos * 3;
}
}
s += 500 + victims;
}
if (move.type === 'enter') s += 360;
if (move.type === 'home') s += 260; // into the home column: out of reach for good
const fromG = piece.pos >= 0 ? globalSquare(game, playerIdx, piece.pos) : null;
const toG = move.to <= HOME_ENTRY_POS ? globalSquare(game, playerIdx, move.to) : null;
const fromThreat = fromG == null ? 0 : threatsOn(game, playerIdx, fromG);
const toThreat = toG == null ? 0 : threatsOn(game, playerIdx, toG);
s += fromThreat * 90; // escaping danger
s -= toThreat * 130; // stopping in front of an enemy
if (toG != null && isSafeSquare(toG)) s += 110;
// A token far along is worth more to protect.
if (fromThreat) s += piece.pos * 1.5;
// Leave the last stretch for exact rolls: prefer not to park just short of home.
if (move.to > HOME_ENTRY_POS && move.to < FINISH_POS) s += 10;
return s;
}
/**
* The bot's move for this roll, or null when nothing can move.
* level: 'easy' plays loosely, 'medium' sensibly with a little noise, 'hard' carefully.
*/
export function chooseMove(game, playerIdx, dice, level = 'medium') {
const moves = getValidMoves(game, playerIdx, dice);
if (!moves.length) return null;
if (moves.length === 1) return moves[0];
if (level === 'easy' && Math.random() < 0.45) return moves[Math.floor(Math.random() * moves.length)];
const noise = level === 'hard' ? 0 : level === 'easy' ? 220 : 70;
let best = null, bestScore = -Infinity;
for (const m of moves) {
const sc = scoreMove(game, playerIdx, m) + (noise ? (Math.random() - 0.5) * noise : 0);
if (sc > bestScore) { bestScore = sc; best = m; }
}
return best;
}
const SHARED_PATH_LENGTH = 52; // Ludo rules. Pure: no DOM, no timers, so they can be tested on their own
const HOME_ENTRY_POS = 50; // (tests/play/ludo-rules.mjs) and every client applies them the same way.
const HOME_STRETCH = 5; //
const FINISH_POS = HOME_ENTRY_POS + 1 + HOME_STRETCH; // 56 — the 6th home column square (outer middle of center 3x3) // A token's position is local to its owner:
// Must match board-map.js START_SQUARES exactly // -1 in the yard
// 0..50 on the shared track, 0 being the owner's start square
// 51..55 the owner's home column
// 56 home (the centre triangle): the token is finished
//
// Rules, as played in Ludo King and Yalla Ludo:
// - a token leaves the yard only on a six, onto its start square;
// - a six earns another roll; three sixes in a row forfeit the third roll
// and end the turn;
// - landing on an opponent outside a safe square sends every opponent token
// there back to its yard, and earns another roll;
// - safe squares (the starts and the starred squares) are never captured on,
// and tokens of any colour may share them;
// - a token needs the exact roll to get home; reaching home earns another roll;
// - a player whose four tokens are home has finished; the game ends when only
// one player is left on the board.
export const TRACK_LEN = 52;
export const HOME_ENTRY_POS = 50; // last track square before the home column
export const FINISH_POS = 56; // home
// Must match board-map.js START_SQUARES exactly.
export const START_POSITIONS = [0, 13, 26, 39]; export const START_POSITIONS = [0, 13, 26, 39];
const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47]; export const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47];
const SAFE = new Set(SAFE_SQUARES);
// Player order: 0=Red(BL), 1=Green(TL), 2=Yellow(TR), 3=Blue(BR) // Board slot order: 0 = Red (BL), 1 = Green (TL), 2 = Yellow (TR), 3 = Blue (BR).
export const COLORS = ['red', 'green', 'yellow', 'blue']; export const COLORS = ['red', 'green', 'yellow', 'blue'];
export const COLOR_CSS = ['#E53935', '#43A047', '#FDD835', '#1E88E5'];
/** The seats a table of n players uses: two sit across the board, three leave yellow empty. */
export function defaultSeats(n) {
if (n === 2) return [0, 2];
if (n === 3) return [0, 1, 3];
return [0, 1, 2, 3];
}
export function createGame(numPlayers = 4, seats = null) { export function createGame(numPlayers = 4, seats = null) {
// seats maps player index to board slot: e.g. [0,2] means player0→Red(BL), player1→Yellow(TR) const seatMap = (seats && seats.length >= numPlayers ? seats.slice(0, numPlayers) : defaultSeats(numPlayers));
const seatMap = seats || Array.from({ length: numPlayers }, (_, i) => i);
const players = []; const players = [];
for (let i = 0; i < numPlayers; i++) { for (let i = 0; i < numPlayers; i++) {
const boardSlot = seatMap[i]; const boardSlot = seatMap[i];
players.push({ players.push({
color: COLORS[boardSlot], color: COLORS[boardSlot],
boardSlot, boardSlot,
pieces: [ pieces: [0, 1, 2, 3].map(k => ({ id: `${i}-${k}`, pos: -1, finished: false })),
{ id: `${i}-0`, pos: -1, finished: false },
{ id: `${i}-1`, pos: -1, finished: false },
{ id: `${i}-2`, pos: -1, finished: false },
{ id: `${i}-3`, pos: -1, finished: false }
],
finished: false, finished: false,
consecutiveSixes: 0, rollsWithoutSix: 0,
rollsWithoutSix: 0
}); });
} }
return { return {
...@@ -39,268 +59,235 @@ export function createGame(numPlayers = 4, seats = null) { ...@@ -39,268 +59,235 @@ export function createGame(numPlayers = 4, seats = null) {
seatMap, seatMap,
winners: [], winners: [],
gameOver: false, gameOver: false,
extraTurn: false extraTurn: false,
sixesInRow: 0,
forcedRolls: null, // test hook: values the next rolls must come up
}; };
} }
/** The shared-track square (0..51) a token stands on, or null in the yard, home column or home. */
export function globalSquare(game, playerIdx, pos) {
if (pos < 0 || pos > HOME_ENTRY_POS) return null;
const slot = game.players[playerIdx].boardSlot ?? playerIdx;
return (pos + START_POSITIONS[slot]) % TRACK_LEN;
}
export function isSafeSquare(g) { return SAFE.has(g); }
/** Opponent tokens standing on a shared-track square: [{ playerIdx, pieceIdx }]. */
export function opponentsOn(game, playerIdx, g) {
const out = [];
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue;
game.players[i].pieces.forEach((pc, k) => {
if (pc.finished || pc.pos < 0) return;
if (globalSquare(game, i, pc.pos) === g) out.push({ playerIdx: i, pieceIdx: k });
});
}
return out;
}
// ── Dice ────────────────────────────────────────────────────────────────────
/**
* A roll for `playerIdx`. Weighted to keep games moving and close: a player
* stuck in the yard is helped toward a six, whoever trails gets a little
* lift, and late games speed up. Over a game every face still comes up.
*/
export function rollDice(game, playerIdx) { export function rollDice(game, playerIdx) {
if (!game || playerIdx == null) return Math.floor(Math.random() * 6) + 1; if (game?.forcedRolls?.length) {
const player = game.players[playerIdx]; const v = game.forcedRolls.shift();
const ctx = getDiceContext(game, playerIdx); if (v >= 1 && v <= 6) { noteRoll(game, playerIdx, v); return v; }
const weights = computeDiceWeights(ctx);
const result = weightedRoll(weights);
if (result === 6) {
player.rollsWithoutSix = 0;
} else {
player.rollsWithoutSix = (player.rollsWithoutSix || 0) + 1;
} }
if (!game || playerIdx == null) return Math.floor(Math.random() * 6) + 1;
const result = weightedRoll(computeDiceWeights(getDiceContext(game, playerIdx)));
noteRoll(game, playerIdx, result);
return result; return result;
} }
function noteRoll(game, playerIdx, v) {
const p = game?.players?.[playerIdx];
if (!p) return;
p.rollsWithoutSix = v === 6 ? 0 : (p.rollsWithoutSix || 0) + 1;
}
function getDiceContext(game, playerIdx) { function getDiceContext(game, playerIdx) {
const player = game.players[playerIdx]; const player = game.players[playerIdx];
const piecesHome = player.pieces.filter(p => p.pos === -1 && !p.finished).length; const piecesHome = player.pieces.filter(p => p.pos === -1 && !p.finished).length;
const piecesFinished = player.pieces.filter(p => p.finished).length; const piecesFinished = player.pieces.filter(p => p.finished).length;
const rollsWithoutSix = player.rollsWithoutSix || 0; const progressOf = pl => pl.pieces.reduce((s, p) => s + (p.finished ? FINISH_POS : p.pos < 0 ? 0 : p.pos), 0) / (FINISH_POS * 4) * 100;
const progress = progressOf(player);
const progress = player.pieces.reduce((sum, p) => { const others = game.players.filter((p, i) => i !== playerIdx && !p.finished).map(progressOf);
if (p.finished) return sum + FINISH_POS; const avgOthersProgress = others.length ? others.reduce((a, b) => a + b, 0) / others.length : 0;
if (p.pos === -1) return sum; const maxOthersProgress = others.length ? Math.max(...others) : 0;
return sum + p.pos;
}, 0) / (FINISH_POS * 4) * 100;
const othersProgress = game.players
.filter((_, i) => i !== playerIdx && !game.players[i].finished)
.map(op => op.pieces.reduce((s, p) => s + (p.finished ? FINISH_POS : p.pos === -1 ? 0 : p.pos), 0) / (FINISH_POS * 4) * 100);
const avgOthersProgress = othersProgress.length ? othersProgress.reduce((a, b) => a + b, 0) / othersProgress.length : 0;
const maxOthersProgress = othersProgress.length ? Math.max(...othersProgress) : 0;
const elapsed = game.startTime ? (Date.now() - game.startTime) / 60000 : 0; const elapsed = game.startTime ? (Date.now() - game.startTime) / 60000 : 0;
const isHuman = game.humanPlayers && game.humanPlayers.includes(playerIdx); const isHuman = !!game.humanPlayers?.includes(playerIdx);
const humanCount = game.humanPlayers ? game.humanPlayers.length : 1; return { piecesHome, piecesFinished, rollsWithoutSix: player.rollsWithoutSix || 0, progress, avgOthersProgress, maxOthersProgress, elapsed, isHuman, sixesInRow: game.sixesInRow || 0 };
return { playerIdx, piecesHome, piecesFinished, rollsWithoutSix, progress, avgOthersProgress, maxOthersProgress, elapsed, isHuman, humanCount };
} }
function computeDiceWeights(ctx) { function computeDiceWeights(ctx) {
const w = [1, 1, 1, 1, 1, 1]; const w = [1, 1, 1, 1, 1, 1];
const timeFactor = Math.min(1, ctx.elapsed / 15); const timeFactor = Math.min(1, ctx.elapsed / 15);
if (ctx.isHuman) { w[4] += 0.12; w[5] += 0.18; }
// --- HUMAN LUCK EDGE ---
// Humans get a subtle persistent advantage over bots (feels lucky without being obvious)
if (ctx.isHuman) {
w[4] += 0.12; // slight 5 boost (good moves)
w[5] += 0.18; // slight 6 boost (enter + extra turn)
}
// --- RUBBER BAND: whoever is behind gets help ---
const deficit = ctx.avgOthersProgress - ctx.progress; const deficit = ctx.avgOthersProgress - ctx.progress;
if (deficit > 10) { if (deficit > 10) { const rb = Math.min(0.6, deficit / 50); w[5] += rb; w[4] += rb * 0.5; }
const rubberBand = Math.min(0.6, deficit / 50); if (ctx.isHuman && ctx.maxOthersProgress - ctx.progress > 20) w[5] += 0.25;
w[5] += rubberBand; if (ctx.progress - ctx.avgOthersProgress > 18 && !ctx.isHuman) { w[5] = Math.max(0.7, w[5] - 0.3); w[4] = Math.max(0.8, w[4] - 0.15); }
w[4] += rubberBand * 0.5;
}
// If human is far behind the leader, stronger comeback boost
if (ctx.isHuman && ctx.maxOthersProgress - ctx.progress > 20) {
w[5] += 0.25;
}
// --- MOMENTUM DAMPER: leader gets slightly cooled ---
if (ctx.progress - ctx.avgOthersProgress > 18 && !ctx.isHuman) {
w[5] = Math.max(0.7, w[5] - 0.3);
w[4] = Math.max(0.8, w[4] - 0.15);
}
// --- STUCK RESCUE: escalates with each failed roll ---
// After 2 rolls without six, start boosting hard; by roll 4-5 it's nearly guaranteed
if (ctx.piecesHome === 4) { if (ctx.piecesHome === 4) {
const frustration = Math.min(ctx.rollsWithoutSix, 5); const frustration = Math.min(ctx.rollsWithoutSix, 5);
const base = ctx.isHuman ? 0.5 : 0.3; w[5] += (ctx.isHuman ? 0.5 : 0.3) + frustration * (ctx.isHuman ? 0.6 : 0.4);
const escalation = frustration * (ctx.isHuman ? 0.6 : 0.4);
w[5] += base + escalation;
} else if (ctx.piecesHome >= 3) { } else if (ctx.piecesHome >= 3) {
const frustration = Math.min(ctx.rollsWithoutSix, 4); w[5] += (ctx.isHuman ? 0.3 : 0.2) + Math.min(ctx.rollsWithoutSix, 4) * 0.25;
w[5] += (ctx.isHuman ? 0.3 : 0.2) + frustration * 0.25;
}
// --- FINISHING PUSH: accelerate endgame so it stays under 15 min ---
if (ctx.elapsed > 8) {
const push = 0.2 + timeFactor * 0.6;
w[5] += push;
// Everyone gets higher rolls late game to move pieces faster
w[3] += timeFactor * 0.2;
w[4] += timeFactor * 0.2;
} }
if (ctx.elapsed > 8) { w[5] += 0.2 + timeFactor * 0.6; w[3] += timeFactor * 0.2; w[4] += timeFactor * 0.2; }
// --- CLOSE-TO-FINISH excitement: if player has 2+ pieces finished, help them wrap up --- if (ctx.piecesFinished >= 2) { w[5] += 0.15; w[4] += 0.1; }
if (ctx.piecesFinished >= 2) { // The help is for getting a six, not for chaining them: after a six in this
w[5] += 0.15; // turn the next six comes up fairly, so three in a row stays rare.
w[4] += 0.1; if (ctx.sixesInRow >= 1) w[5] = 1;
}
return w; return w;
} }
function weightedRoll(weights) { function weightedRoll(weights) {
const total = weights.reduce((a, b) => a + b, 0); const total = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * total; let r = Math.random() * total;
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) { r -= weights[i]; if (r <= 0) return i + 1; }
r -= weights[i];
if (r <= 0) return i + 1;
}
return 6; return 6;
} }
/**
* Record a roll for the current player. Returns { forfeit } — true on the third
* six in a row, which is not played: the turn simply ends.
*/
export function registerRoll(game, dice) {
game.diceValue = dice;
game.rolled = true;
if (dice === 6) {
game.sixesInRow = (game.sixesInRow || 0) + 1;
if (game.sixesInRow >= 3) {
game.sixesInRow = 0;
game.extraTurn = false;
return { forfeit: true };
}
}
return { forfeit: false };
}
// ── Moves ───────────────────────────────────────────────────────────────────
export function getValidMoves(game, playerIdx, dice) { export function getValidMoves(game, playerIdx, dice) {
const player = game.players[playerIdx]; const player = game.players[playerIdx];
const moves = []; const moves = [];
if (!player || player.finished || !(dice >= 1 && dice <= 6)) return moves;
for (const piece of player.pieces) { for (const piece of player.pieces) {
if (piece.finished) continue; if (piece.finished) continue;
if (piece.pos === -1) { if (piece.pos === -1) {
if (dice === 6) { if (dice !== 6) continue;
// The start square is safe, so entering never captures.
moves.push({ pieceId: piece.id, from: -1, to: 0, type: 'enter' }); moves.push({ pieceId: piece.id, from: -1, to: 0, type: 'enter' });
continue;
} }
} else { const to = piece.pos + dice;
// Pawn turns into home column directly from position 50 (skipping the corner) if (to > FINISH_POS) continue; // needs the exact roll
const newPos = piece.pos + dice; if (to === FINISH_POS) { moves.push({ pieceId: piece.id, from: piece.pos, to, type: 'finish' }); continue; }
if (to > HOME_ENTRY_POS) { moves.push({ pieceId: piece.id, from: piece.pos, to, type: 'home' }); continue; }
if (newPos === FINISH_POS) { const g = globalSquare(game, playerIdx, to);
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'finish' }); const hit = !isSafeSquare(g) && opponentsOn(game, playerIdx, g).length > 0;
} else if (newPos < FINISH_POS) { moves.push({ pieceId: piece.id, from: piece.pos, to, type: hit ? 'capture' : 'move' });
if (newPos > HOME_ENTRY_POS) {
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'home' });
} else {
const boardSlot = game.players[playerIdx].boardSlot ?? playerIdx;
const globalPos = (newPos + START_POSITIONS[boardSlot]) % SHARED_PATH_LENGTH;
const captured = checkCapture(game, playerIdx, globalPos);
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: captured ? 'capture' : 'move' });
}
} }
// newPos > FINISH_POS = overshoot, invalid
}
}
return moves; return moves;
} }
/** Index of the piece a move refers to ("p-k" -> k). */
export function pieceIndexOf(move) { return parseInt(String(move.pieceId).split('-')[1], 10); }
/**
* Play a move for `playerIdx`. Mutates the game and returns what happened:
* { captured: [{ playerIdx, pieceIdx, from }], finishedPiece, playerFinished, extraTurn }.
* The captured list is also left on the move as move._captured (and the
* players hit as move._capturedPlayers, kept for older callers).
*/
export function applyMove(game, playerIdx, move) { export function applyMove(game, playerIdx, move) {
const player = game.players[playerIdx]; const player = game.players[playerIdx];
const piece = player.pieces.find(p => p.id === move.pieceId); const piece = player?.pieces.find(p => p.id === move.pieceId);
if (!piece) return game; const out = { captured: [], finishedPiece: false, playerFinished: false, extraTurn: false };
if (!piece) return out;
game.extraTurn = false;
if (move.type === 'enter') {
piece.pos = 0;
const boardSlot = game.players[playerIdx].boardSlot ?? playerIdx;
const globalPos = START_POSITIONS[boardSlot];
move._capturedPlayers = captureAt(game, playerIdx, globalPos);
game.extraTurn = true;
} else if (move.type === 'finish') {
piece.pos = move.to; piece.pos = move.to;
if (move.to === FINISH_POS) {
piece.finished = true; piece.finished = true;
game.extraTurn = true; out.finishedPiece = true;
if (player.pieces.every(p => p.finished)) { } else if (move.to <= HOME_ENTRY_POS) {
player.finished = true; const g = globalSquare(game, playerIdx, move.to);
game.winners.push(playerIdx); if (!isSafeSquare(g)) {
// Game ends when all but one player finish for (const hit of opponentsOn(game, playerIdx, g)) {
if (game.winners.length >= game.numPlayers - 1) { const victim = game.players[hit.playerIdx].pieces[hit.pieceIdx];
const lastPlayer = game.players.findIndex((p, i) => !p.finished); out.captured.push({ playerIdx: hit.playerIdx, pieceIdx: hit.pieceIdx, from: victim.pos });
if (lastPlayer !== -1) game.winners.push(lastPlayer); victim.pos = -1;
game.gameOver = true;
} }
} }
} else if (move.type === 'capture') {
piece.pos = move.to;
const boardSlot = game.players[playerIdx].boardSlot ?? playerIdx;
const globalPos = (move.to + START_POSITIONS[boardSlot]) % SHARED_PATH_LENGTH;
move._capturedPlayers = captureAt(game, playerIdx, globalPos);
game.extraTurn = true;
} else if (move.type === 'home') {
piece.pos = move.to;
} else {
piece.pos = move.to;
} }
if (game.diceValue === 6) { if (out.finishedPiece && player.pieces.every(p => p.finished)) {
game.extraTurn = true; player.finished = true;
player.consecutiveSixes = (player.consecutiveSixes || 0) + 1; out.playerFinished = true;
if (player.consecutiveSixes >= 3) { if (!game.winners.includes(playerIdx)) game.winners.push(playerIdx);
// Triple-six penalty: lose extra turn only, pawn stays in place checkGameOver(game);
player.consecutiveSixes = 0;
game.extraTurn = false;
}
} else {
player.consecutiveSixes = 0;
} }
return game; // Another roll for a six, a capture or a token reaching home, but never for
// a player who has just finished.
out.extraTurn = !player.finished && (game.diceValue === 6 || out.captured.length > 0 || out.finishedPiece);
game.extraTurn = out.extraTurn;
move._captured = out.captured;
move._capturedPlayers = [...new Set(out.captured.map(c => c.playerIdx))];
return out;
} }
export function nextTurn(game) { /** When only one player is still on the board the game is over; they take last place. */
if (game.extraTurn) { export function checkGameOver(game) {
game.extraTurn = false; const left = game.players.map((p, i) => i).filter(i => !game.players[i].finished && !game.winners.includes(i));
return; if (left.length <= 1) {
if (left.length === 1) game.winners.push(left[0]);
game.gameOver = true;
} }
do { return game.gameOver;
game.currentPlayer = (game.currentPlayer + 1) % game.numPlayers;
} while (game.players[game.currentPlayer].finished && !game.gameOver);
} }
function checkCapture(game, playerIdx, globalPos) { /**
if (SAFE_SQUARES.includes(globalPos)) return false; * End the current roll: the same player rolls again on an extra turn,
* otherwise play passes to the next player still on the board.
for (let i = 0; i < game.numPlayers; i++) { */
if (i === playerIdx) continue; export function nextTurn(game) {
const theirSlot = game.players[i].boardSlot ?? i; game.rolled = false;
for (const piece of game.players[i].pieces) { if (game.gameOver) return;
if (piece.pos === -1 || piece.finished) continue; const cur = game.players[game.currentPlayer];
if (piece.pos > HOME_ENTRY_POS) continue; if (game.extraTurn && cur && !cur.finished) {
const theirGlobal = (piece.pos + START_POSITIONS[theirSlot]) % SHARED_PATH_LENGTH; game.extraTurn = false;
if (theirGlobal === globalPos) return true; return;
} }
game.extraTurn = false;
game.sixesInRow = 0;
for (let k = 1; k <= game.numPlayers; k++) {
const i = (game.currentPlayer + k) % game.numPlayers;
if (!game.players[i].finished) { game.currentPlayer = i; return; }
} }
return false;
} }
function captureAt(game, playerIdx, globalPos) { /** A roll that could not be played: a six still earns the next roll. */
const captured = []; export function passNoMove(game, dice) {
if (SAFE_SQUARES.includes(globalPos)) return captured; game.extraTurn = dice === 6;
nextTurn(game);
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue;
const theirSlot = game.players[i].boardSlot ?? i;
for (const piece of game.players[i].pieces) {
if (piece.pos === -1 || piece.finished) continue;
if (piece.pos > HOME_ENTRY_POS) continue;
const theirGlobal = (piece.pos + START_POSITIONS[theirSlot]) % SHARED_PATH_LENGTH;
if (SAFE_SQUARES.includes(theirGlobal)) continue;
if (theirGlobal === globalPos) {
piece.pos = -1;
if (!captured.includes(i)) captured.push(i);
}
}
}
return captured;
} }
export function getBotMove(game, playerIdx, dice) { /** Overall progress of a player, 0..224, for ranking unfinished players. */
const moves = getValidMoves(game, playerIdx, dice); export function progressOf(game, playerIdx) {
if (moves.length === 0) return null; return game.players[playerIdx].pieces.reduce((s, p) => s + (p.finished ? FINISH_POS : p.pos < 0 ? 0 : p.pos + 1), 0);
}
// Priority: finish > capture > enter > advance furthest
const finishes = moves.filter(m => m.type === 'finish');
if (finishes.length > 0) return finishes[0];
const captures = moves.filter(m => m.type === 'capture');
if (captures.length > 0) return captures[0];
const enters = moves.filter(m => m.type === 'enter');
if (enters.length > 0) return enters[0];
// Advance the piece that's furthest along (closest to home) /** The full finishing order: finished players first, then the rest by progress. */
const sorted = moves.sort((a, b) => b.to - a.to); export function standings(game) {
return sorted[0]; const order = [...game.winners];
const rest = game.players.map((p, i) => i).filter(i => !order.includes(i));
rest.sort((a, b) => progressOf(game, b) - progressOf(game, a));
return order.concat(rest);
} }
This source diff could not be displayed because it is too large. You can view the blob instead.
// Ludo result. The table stays in view, dimmed (the last board, as it was left);
// a white result card flips in with a flat tag across its top edge: green
// "won", red "lost", gold for a place in between. Under a big sticker, the finishing
// order is printed one row per player with their pawn. What the server paid
// (online games) counts up and the coins fly to the purse. Then a big green
// "play again" and a wooden "back to the table".
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import * as store from '../../../core/store.js';
import { countUp, haptic } from '../../../core/feel.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { gicon } from '../../../core/icons.js';
import { esc } from '../../../core/ui.js';
import { getColor } from '../../../core/theme.js';
import { pawnSVG } from '../canvas/board.js';
const PLACE_EMOJI = [emoji('medal_1', '🥇', 18), emoji('medal_2', '🥈', 18), emoji('medal_3', '🥉', 18), '4️⃣']; const PLACE_KEYS = ['ludo.place_first', 'ludo.place_second', 'ludo.place_third', 'ludo.place_fourth'];
const PLACE_LABEL = [t('ludo.place_first'), t('ludo.place_second'), t('ludo.place_third'), t('ludo.place_fourth')]; const DEFAULT_COLORS = ['#FF4D5E', '#2BCF76', '#FFC531', '#3B86FF'];
const PLACE_COLOR = ['var(--gold-soft)', '#C0C0C0', '#CD7F32', 'var(--text-muted)']; const reducedMotion = () => !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
export function mountResult(el, params) { export function mountResult(el, params = {}) {
const { result, place, resigned, rewards, winners = [], playerNames = [], playerColors = [] } = params; const {
result, place, resigned, rewards = null, winners = [], playerNames = [], seats = [],
boardShot = null, reason = 'normal', left = [],
} = params;
const me = params.me ?? 0;
const numPlayers = params.numPlayers || params.playerCount || 4; const numPlayers = params.numPlayers || params.playerCount || 4;
const isWin = result === 'win'; const won = !resigned && (place === 1 || (result === 'win' && !place));
const lost = resigned || (!won && place >= numPlayers) || (!won && result === 'loss' && !place);
const colors = [
getColor('ludo_red', DEFAULT_COLORS[0]), getColor('ludo_green', DEFAULT_COLORS[1]),
getColor('ludo_yellow', DEFAULT_COLORS[2]), getColor('ludo_blue', DEFAULT_COLORS[3]),
];
const seatOf = i => (seats[i] != null ? seats[i] : i);
const coins = rewards?.coins_earned ?? (isWin ? 50 : place === 2 ? 25 : place === 3 ? 15 : 10); // Ribbon, sticker and the line under it. Several people on one device: the
const xp = rewards?.xp_earned ?? (isWin ? 30 : 15); // card names whoever won rather than saying "you".
const ratingChange = rewards?.rating_change ?? (isWin ? 12 : place === 2 ? 3 : place === 3 ? -3 : -8); const humans = params.humans || [me];
const hotSeat = params.mode === 'local-multi' && humans.length > 1 && !resigned && winners.length;
// Build leaderboard: winners array has player indices in finish order const tableWon = hotSeat && humans.includes(winners[0]);
const leaderboard = []; let word = resigned ? t('ludo.result_withdrew')
if (!resigned && winners.length > 0) { : won ? t('ludo.result_win')
for (let rank = 0; rank < winners.length; rank++) { : lost ? t('ludo.result_loss')
const pIdx = winners[rank]; : t('ludo.result_place', { place: t(PLACE_KEYS[place - 1] || PLACE_KEYS[3]) });
leaderboard.push({ let tone = won ? 'green' : lost ? 'red' : 'gold';
rank: rank + 1, let sticker = resigned ? 'flag' : won ? 'trophy' : lost ? 'ludo' : 'medal';
name: playerNames[pIdx] || `${t('common.player')} ${pIdx + 1}`, if (hotSeat) {
color: playerColors[pIdx] || 'var(--text-secondary)', word = t('ludo.result_winner', { name: playerNames[winners[0]] || '' });
isMe: pIdx === 0, tone = tableWon ? 'green' : 'red';
}); sticker = tableWon ? 'trophy' : 'ludo';
}
// Add any unfinished players (not in winners)
for (let i = 0; i < numPlayers; i++) {
if (!winners.includes(i)) {
leaderboard.push({
rank: leaderboard.length + 1,
name: playerNames[i] || `${t('common.player')} ${i + 1}`,
color: playerColors[i] || 'var(--text-secondary)',
isMe: i === 0,
});
}
} }
} const celebrate = won || tableWon;
const sub = resigned ? t('game.withdrew')
const heroIcon = resigned : reason === 'abandon' && won ? t('ludo.result_forfeit')
? emoji('skull', '💀', 64) : won && !hotSeat ? t('game.congrats') : '';
: place === 1 ? emoji('trophy', '🏆', 64)
: place === 2 ? emoji('medal_2', '🥈', 64)
: place === 3 ? emoji('medal_3', '🥉', 64)
: emoji('skull', '💀', 64);
const heroTitle = resigned ? t('game.withdrew') // The finishing order (winners holds every seat, first to last).
: place === 1 ? t('game.congrats') const order = winners.length ? winners : [];
: place === 2 ? t('game.second_place') const rows = order.map((i, rank) => {
: place === 3 ? t('game.third_place') const name = playerNames[i] || `${t('common.player')} ${i + 1}`;
: t('game.fourth_place'); const isMe = i === me;
const gone = left.includes(i);
return `
<li class="lr-row${isMe ? ' is-me' : ''}${gone ? ' is-gone' : ''}" style="--d:${rank};">
<span class="lr-rank r${rank + 1} num">${rank + 1}</span>
<span class="lr-pawn">${pawnSVG(colors[seatOf(i)], 20, 'lr' + i)}</span>
<span class="lr-name" dir="auto">${esc(name)}</span>
${isMe && name !== t('common.you') ? `<span class="lr-you">${t('common.you')}</span>` : ''}
${gone ? `<span class="lr-you">${t('ludo.left_tag')}</span>` : ''}
${rank === 0 ? `<span class="lr-crown">${gicon('trophy', 26)}</span>` : ''}
</li>`;
}).join('');
const heroColor = resigned ? 'var(--error)' const coins = Number(rewards?.coins) || 0;
: place === 1 ? 'var(--gold-soft)' const xp = Number(rewards?.xp) || 0;
: place === 2 ? '#C0C0C0' const pay = rewards ? `
: place === 3 ? '#CD7F32' <div class="lr-pay">
: 'var(--error)'; <div class="lr-purse" id="lr-purse">${gicon('coin', 34)}<span class="num" id="lr-coins">+0</span></div>
<div class="lr-purse lr-xp">${gicon('star', 30)}<span class="num" id="lr-xp">+0</span><span class="lr-unit">${t('game.xp')}</span></div>
</div>` : '';
el.innerHTML = ` el.innerHTML = `
<div class="lr-result-wrap"> <div class="lr">
<!-- Hero --> <div class="lr-tabletop" aria-hidden="true">
<div class="lr-hero-section"> ${boardShot ? `<img class="lr-shot" src="${boardShot}" alt="" draggable="false">` : ''}
<div class="lr-hero-icon">${heroIcon}</div>
<div class="lr-hero-title" style="color:${heroColor};">${heroTitle}</div>
</div>
<!-- Leaderboard -->
${leaderboard.length > 0 ? `
<div class="lr-leaderboard">
<div class="lr-lb-title">${t('game.player_ranking')}</div>
${leaderboard.map(p => `
<div class="lr-lb-row ${p.isMe ? 'lr-lb-me' : ''}" style="--pc:${p.color};">
<div class="lr-lb-rank" style="color:${PLACE_COLOR[p.rank - 1] || 'var(--text-muted)'};">
${p.rank <= 3 ? PLACE_EMOJI[p.rank - 1] : p.rank}
</div> </div>
<div class="lr-lb-color" style="background:${p.color};"></div> <div class="lr-stage">
<div class="lr-lb-name">${p.name}${p.isMe ? ` (${t('common.you')})` : ''}</div> <article class="deck-card lr-card flip-in" aria-labelledby="lr-word">
<div class="lr-lb-place">${PLACE_LABEL[p.rank - 1] || ''}</div> <div class="ribbon ${tone === 'red' ? '' : tone} lr-ribbon" id="lr-word">${word}</div>
<div class="lr-sticker${celebrate ? ' is-win' : ''}${lost && !celebrate ? ' is-loss' : ''}">
${celebrate ? '<span class="lr-rays" aria-hidden="true"></span>' : ''}
${gicon(sticker, celebrate ? 104 : 88)}
</div> </div>
`).join('')} ${sub ? `<p class="lr-sub">${sub}</p>` : ''}
</div>` : ''} ${rows ? `<ol class="lr-order" aria-label="${t('game.player_ranking')}">${rows}</ol>` : ''}
${pay}
<!-- Rewards --> </article>
<div class="lr-rewards">
<div class="lr-reward-pill lr-reward-coins">
<span>${emoji('coin', '🪙', 16)}</span>
<span>+${coins}</span>
</div>
<div class="lr-reward-pill lr-reward-xp">
<span>${emoji('star', '⭐', 16)}</span>
<span>+${xp} XP</span>
</div>
${ratingChange ? `
<div class="lr-reward-pill" style="color:${ratingChange > 0 ? 'var(--green-light)' : 'var(--red-soft)'};border-color:${ratingChange > 0 ? 'rgba(74,222,128,0.2)' : 'rgba(252,165,165,0.2)'};">
<span>${emoji('chart_up', '📈', 16)}</span>
<span>${ratingChange > 0 ? '+' : ''}${ratingChange}</span>
</div>` : ''}
</div>
<!-- Actions -->
<div class="lr-actions"> <div class="lr-actions">
<button class="btn btn-primary" id="btn-again">${t('game.rematch')}</button> <button class="btn btn-primary btn-xl btn-block" id="btn-again">${gicon('dice', 36)}<span>${t('game.play_again')}</span></button>
<button class="btn btn-secondary" id="btn-back">${t('game.back')}</button> <button class="btn btn-secondary btn-lg btn-block" id="btn-back">${gicon('home', 30)}<span>${t('ludo.result_home')}</span></button>
</div> </div>
</div> </div>
${getResultStyles()} </div>
<style>${STYLE}</style>
`; `;
if (isWin) { // The card turns over, the ribbon lands, then the sticker; on a win the
juice.hapticSuccess?.(); // table erupts.
setTimeout(() => juice.confetti?.(window.innerWidth / 2, window.innerHeight / 3, 40), 300); audio.play('flip', 'ui');
setTimeout(() => {
if (!el.isConnected) return;
if (celebrate) {
juice.confetti?.(window.innerWidth / 2, window.innerHeight * 0.3, 60);
setTimeout(() => juice.confetti?.(window.innerWidth * 0.25, window.innerHeight * 0.25, 26), 260);
setTimeout(() => juice.confetti?.(window.innerWidth * 0.75, window.innerHeight * 0.25, 26), 420);
audio.play('reward', 'reward');
haptic('success');
} else if (lost) {
const card = el.querySelector('.lr-card');
juice.shake?.(card, 6, 380);
haptic('error');
} else {
juice.starBurst?.(window.innerWidth / 2, window.innerHeight * 0.32, 10);
haptic('medium');
} }
}, reducedMotion() ? 0 : 560);
// The pay counts up, and the coins fly to the purse.
if (rewards) {
const coinsEl = el.querySelector('#lr-coins');
const xpEl = el.querySelector('#lr-xp');
setTimeout(() => {
if (!el.isConnected) return;
countUp(coinsEl, 0, coins, { duration: 900, format: n => `+${Math.round(n)}` });
countUp(xpEl, 0, xp, { duration: 900, format: n => `+${Math.round(n)}` });
if (coins > 0) {
audio.play('coins', 'reward');
const r = el.querySelector('#lr-purse .gi')?.getBoundingClientRect();
setTimeout(() => {
if (!el.isConnected) return;
if (r) juice.coinFlyTo(r.left + r.width / 2, r.top + r.height / 2, '.hud-coins', Math.min(10, Math.max(4, Math.round(coins / 6))));
// The server has paid: the purse shows the new balance.
const player = store.get('player');
if (player && typeof player.coins === 'number') {
setTimeout(() => {
store.set?.('player', { ...player, coins: player.coins + coins, xp: (player.xp || 0) + xp });
bus.emit('coins:earned', { amount: coins }); bus.emit('coins:earned', { amount: coins });
bus.emit('xp:earned', { amount: xp }); bus.emit('xp:earned', { amount: xp });
}, 900);
}
}, 650);
}
}, reducedMotion() ? 0 : 1000);
}
el.querySelector('#btn-again').addEventListener('click', () => { el.querySelector('#btn-again').addEventListener('click', () => {
audio.play('click');
if (params.mode === 'live') { if (params.mode === 'live') {
scene.replace('play-queue', { game: 'ludo', mode: 'human', playerCount: numPlayers, seats: params.seats }); scene.replace('play-queue', { game: 'ludo', mode: 'human', playerCount: numPlayers, seats: params.seats });
} else { } else {
scene.enterGameMode();
scene.replace('ludo-game', { scene.replace('ludo-game', {
mode: params.mode || 'bot', mode: params.mode || 'bot',
numPlayers: params.numPlayers, numPlayers,
playerCount: params.playerCount,
seats: params.seats, seats: params.seats,
humanCount: params.humanCount, humanCount: params.humanCount,
difficulty: params.difficulty, difficulty: params.difficulty,
...@@ -136,56 +170,178 @@ export function mountResult(el, params) { ...@@ -136,56 +170,178 @@ export function mountResult(el, params) {
} }
}); });
el.querySelector('#btn-back').addEventListener('click', () => { el.querySelector('#btn-back').addEventListener('click', () => {
audio.play('click');
bus.emit('navigate', { world: 'play', scene: 'play-table' }); bus.emit('navigate', { world: 'play', scene: 'play-table' });
}); });
} }
function getResultStyles() { const STYLE = `
return `<style> .lr { position: relative; min-height: 100%; display: flex; flex-direction: column; }
.lr-result-wrap { /* The table under the card: the last board, dimmed and pushed back. */
display:flex;flex-direction:column;align-items:center;justify-content:center; .lr-tabletop {
height:100%;gap:20px;padding:24px; position: absolute; inset: 0;
background:linear-gradient(180deg,#070d14 0%,var(--bg-inset) 40%,var(--bg-inset) 100%); display: grid; place-items: center;
} overflow: hidden;
.lr-hero-section { text-align:center; } pointer-events: none;
.lr-hero-icon { font-size:64px;animation:lrFloat 2.5s ease-in-out infinite; } }
.lr-hero-title { font-size:22px;font-weight:800;margin-top:8px; } .lr-shot {
@keyframes lrFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } width: min(92vw, 460px); height: auto;
border-radius: 22px;
filter: brightness(0.5) saturate(0.8) blur(1px);
transform: scale(1.04);
box-shadow: 0 0 0 9px var(--back-800), var(--shadow-lg);
animation: lrDim 700ms ease-out both;
}
@keyframes lrDim { from { filter: brightness(1) saturate(1) blur(0); transform: none; } }
.lr::before {
content: ''; position: absolute; inset: 0;
background: radial-gradient(80% 60% at 50% 45%, rgba(13, 12, 18, 0.35), rgba(13, 12, 18, 0.8));
pointer-events: none;
}
.lr-stage {
position: relative;
z-index: 1;
flex: 1;
width: 100%;
max-width: 420px;
margin-inline: auto;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 18px;
/* Room at the foot for the tab bar's middle button, which stands above the bar. */
padding: 34px var(--gutter) 44px;
}
.lr-card {
position: relative;
width: 100%;
padding: 42px 18px 16px;
text-align: center;
}
/* The outcome is a flat tag set across the card's top edge. */
.lr-ribbon {
position: absolute;
top: 0; left: 50%;
translate: -50% -50%;
min-width: 58%;
padding: 9px 30px 11px;
border-radius: 18px;
font-size: var(--fs-2xl, 30px);
white-space: nowrap;
z-index: 2;
animation: lrRibbon 620ms var(--ease-pop) 380ms both;
}
@keyframes lrRibbon {
0% { opacity: 0; scale: 2.2; rotate: -8deg; }
60% { opacity: 1; scale: 0.94; rotate: 1deg; }
100% { opacity: 1; scale: 1; rotate: 0deg; }
}
.lr-sticker {
position: relative;
display: grid; place-items: center;
height: 104px;
margin-bottom: 4px;
animation: lrSticker 700ms var(--ease-pop) 640ms both;
}
.lr-sticker .gi { position: relative; z-index: 1; }
.lr-sticker.is-win .gi { animation: lrFloat 2.6s ease-in-out 1.4s infinite; }
.lr-sticker.is-loss .gi { filter: grayscale(0.4); rotate: -10deg; }
@keyframes lrSticker { 0% { opacity: 0; scale: 0.2; rotate: -25deg; } 70% { opacity: 1; scale: 1.15; rotate: 6deg; } 100% { scale: 1; rotate: 0deg; } }
@keyframes lrFloat { 50% { translate: 0 -6px; } }
.lr-rays {
position: absolute;
width: 190px; height: 190px;
border-radius: 50%;
background: repeating-conic-gradient(from 0deg, color-mix(in srgb, var(--gold) 40%, transparent) 0 12deg, transparent 12deg 30deg);
-webkit-mask: radial-gradient(circle, #000 25%, transparent 70%);
mask: radial-gradient(circle, #000 25%, transparent 70%);
animation: lrSpin 9s linear infinite;
}
@keyframes lrSpin { to { rotate: 360deg; } }
.lr-sub { margin: 0 0 8px; font-size: var(--fs-sm); font-weight: 700; color: var(--ink-2); }
.lr-order { list-style: none; margin: 4px 0 0; padding: 0; text-align: start; }
.lr-row {
display: flex; align-items: center; gap: 10px;
min-height: 46px;
padding: 4px 10px;
border-radius: 14px;
background: var(--card-2);
font-size: var(--fs-base);
font-weight: 700;
color: var(--ink-2);
animation: lrRow 420ms var(--ease-pop) calc(900ms + var(--d) * 110ms) both;
}
.lr-row + .lr-row { margin-top: 6px; }
@keyframes lrRow { from { opacity: 0; translate: 0 -12px; scale: 0.94; } }
.lr-row.is-me { background: color-mix(in srgb, var(--gold) 26%, var(--card)); color: var(--ink); }
.lr-row.is-gone { opacity: 0.55; }
.lr-rank {
flex-shrink: 0;
width: 28px; height: 28px;
display: grid; place-items: center;
border-radius: 50%;
font-family: var(--font-display);
font-size: var(--fs-md);
color: #fff;
background: var(--ink-3);
box-shadow: 0 2px 0 var(--ink-2);
}
.lr-rank.r1 { background: var(--gold); color: var(--gold-ink); box-shadow: 0 2px 0 var(--gold-dark); }
.lr-rank.r2 { background: var(--card-3); color: var(--ink-2); box-shadow: 0 2px 0 var(--card-edge); }
.lr-rank.r3 { background: var(--orange); box-shadow: 0 2px 0 var(--ludo-secondary); }
.lr-pawn { display: flex; flex-shrink: 0; }
.lr-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lr-you {
flex-shrink: 0;
padding: 2px 8px 3px;
border-radius: 8px;
background: var(--ink);
color: var(--card);
font-size: var(--fs-xs);
font-weight: 700;
}
.lr-crown { display: flex; margin-inline-start: auto; flex-shrink: 0; }
.lr-leaderboard { .lr-pay {
width:100%;max-width:320px; display: flex; justify-content: center; gap: 10px;
background:rgba(255,255,255,0.02);border:1px solid var(--border); margin-top: 12px;
border-radius:16px;padding:14px; animation: lrRow 420ms var(--ease-pop) 1000ms both;
} --d: 0;
.lr-lb-title { }
font-size:12px;font-weight:700;color:var(--text-muted);text-align:center; .lr-purse {
margin-bottom:10px;text-transform:uppercase;letter-spacing:0.05em; display: inline-flex; align-items: center; gap: 6px;
} min-height: 46px;
.lr-lb-row { padding: 4px 16px 4px 8px;
display:flex;align-items:center;gap:10px; border-radius: 23px;
padding:10px 12px;border-radius:10px; background: var(--ink);
transition:background 0.2s; box-shadow: 0 3px 0 var(--back-950);
} font-family: var(--font-display);
.lr-lb-me { font-size: var(--fs-xl);
background:rgba(228,172,56,0.08);border:1px solid rgba(228,172,56,0.2); color: var(--gold);
} }
.lr-lb-rank { font-size:18px;min-width:28px;text-align:center; } .lr-xp { color: var(--text-primary); font-size: var(--fs-lg); }
.lr-lb-color { width:12px;height:12px;border-radius:50%;flex-shrink:0; } .lr-unit { font-size: var(--fs-sm); color: var(--text-secondary); }
.lr-lb-name { flex:1;font-size:14px;font-weight:600;color:var(--text-primary); }
.lr-lb-place { font-size:11px;color:var(--text-muted);font-weight:500; }
.lr-rewards { display:flex;gap:10px;flex-wrap:wrap;justify-content:center; } .lr-actions {
.lr-reward-pill { width: 100%;
display:flex;align-items:center;gap:4px; display: flex; flex-direction: column; gap: 12px;
padding:8px 14px;border-radius:10px; animation: lrActions 520ms var(--ease-pop) 1150ms both;
font-size:14px;font-weight:700; }
border:1px solid transparent; @keyframes lrActions { from { opacity: 0; translate: 0 24px; } }
}
.lr-reward-coins { color:var(--amber);background:rgba(251,191,36,0.1);border-color:rgba(251,191,36,0.2); }
.lr-reward-xp { color:var(--purple-light);background:rgba(139,92,246,0.1);border-color:rgba(139,92,246,0.2); }
.lr-actions { display:flex;gap:10px;width:100%;max-width:300px; } @media (max-height: 700px) {
.lr-actions .btn { flex:1;min-height:48px;border-radius:14px;font-size:15px;font-weight:700; } .lr-stage { gap: 10px; padding-top: 28px; }
</style>`; .lr-card { padding: 32px 14px 10px; }
.lr-ribbon { font-size: var(--fs-xl); padding: 7px 26px 9px; }
.lr-sticker { height: 62px; margin-bottom: 0; }
.lr-sticker .gi { width: 58px; height: 58px; }
.lr-rays { width: 130px; height: 130px; }
.lr-sub { margin-bottom: 4px; }
.lr-row { min-height: 36px; padding-block: 1px; }
.lr-row + .lr-row { margin-top: 3px; }
.lr-rank { width: 24px; height: 24px; font-size: var(--fs-sm); }
.lr-pay { margin-top: 8px; }
.lr-purse { min-height: 40px; }
.lr-actions { gap: 10px; }
.lr-actions .btn-xl { min-height: 60px; font-size: var(--fs-lg); }
.lr-actions .btn-lg { min-height: 50px; font-size: var(--fs-md); }
} }
`;
...@@ -5,6 +5,8 @@ import * as store from '../../../core/store.js'; ...@@ -5,6 +5,8 @@ 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 { getColor } from '../../../core/theme.js';
import { pawnSVG } from '../canvas/board.js';
export function mountRoom(el, params) { export function mountRoom(el, params) {
const { mode = 'menu' } = params || {}; const { mode = 'menu' } = params || {};
...@@ -229,6 +231,7 @@ function renderSetup(el, params) { ...@@ -229,6 +231,7 @@ function renderSetup(el, params) {
const seats = getSeatPositions(playerCount); const seats = getSeatPositions(playerCount);
// The four player inks, in seat order: red, green, yellow, blue. // The four player inks, in seat order: red, green, yellow, blue.
const inks = ['var(--ludo-red)', 'var(--ludo-green)', 'var(--ludo-yellow)', 'var(--ludo-blue)']; const inks = ['var(--ludo-red)', 'var(--ludo-green)', 'var(--ludo-yellow)', 'var(--ludo-blue)'];
const pawnInk = [getColor('ludo_red', '#FF4D5E'), getColor('ludo_green', '#2BCF76'), getColor('ludo_yellow', '#FFC531'), getColor('ludo_blue', '#3B86FF')];
const labels = [t('ludo.red'), t('ludo.green'), t('ludo.yellow'), t('ludo.blue')]; const labels = [t('ludo.red'), t('ludo.green'), t('ludo.yellow'), t('ludo.blue')];
const positions = ['bottom-left', 'top-left', 'top-right', 'bottom-right']; const positions = ['bottom-left', 'top-left', 'top-right', 'bottom-right'];
...@@ -242,7 +245,7 @@ function renderSetup(el, params) { ...@@ -242,7 +245,7 @@ function renderSetup(el, params) {
return ` return `
<div class="lr-seat lr-seat-${positions[i]} ${active ? 'lr-seat-active' : 'lr-seat-empty'}" style="--seat-color:${inks[i]};"> <div class="lr-seat lr-seat-${positions[i]} ${active ? 'lr-seat-active' : 'lr-seat-empty'}" style="--seat-color:${inks[i]};">
${active ? ` ${active ? `
<span class="lr-seat-token">${icon('game-ludo', 26)}</span> <span class="lr-seat-token">${pawnSVG(pawnInk[i], 26, 'seat' + i)}</span>
<span class="lr-seat-label">${labels[i]}</span> <span class="lr-seat-label">${labels[i]}</span>
<span class="lr-seat-dot">${icon(isHuman ? 'user' : 'bot', 14)}<span>${who}</span></span> <span class="lr-seat-dot">${icon(isHuman ? 'user' : 'bot', 14)}<span>${who}</span></span>
` : `<span class="lr-seat-label">${who}</span>`} ` : `<span class="lr-seat-label">${who}</span>`}
...@@ -351,8 +354,8 @@ function getStyles() { ...@@ -351,8 +354,8 @@ function getStyles() {
.lr-pips { display: inline-flex; gap: 2px; color: currentColor; opacity: 0.8; } .lr-pips { display: inline-flex; gap: 2px; color: currentColor; opacity: 0.8; }
.lr-chip-active .lr-pips { opacity: 1; } .lr-chip-active .lr-pips { opacity: 1; }
/* Seats: four card-stock squares placed as on the board (which does not /* Seats: four tiles placed as on the board (which does not mirror): a
mirror), each with its player's token printed in that player's ink. */ white card with the player's pawn where someone sits, ink where nobody does. */
.lr-seat-preview { display: flex; justify-content: center; } .lr-seat-preview { display: flex; justify-content: center; }
.lr-board-mini { .lr-board-mini {
position: relative; position: relative;
...@@ -372,34 +375,31 @@ function getStyles() { ...@@ -372,34 +375,31 @@ function getStyles() {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
min-height: 92px; min-height: 92px;
padding: var(--s-2); padding: var(--s-2);
border-radius: var(--r-lg); border-radius: 20px;
text-align: center; text-align: center;
transition: background var(--dur-normal) var(--ease-out); transition: background var(--dur-normal) var(--ease-out);
} }
.lr-seat-active { .lr-seat-active {
background: var(--card); background: var(--card);
color: var(--ink); color: var(--ink);
box-shadow: var(--shadow-sm); box-shadow: 0 4px 0 var(--card-edge);
outline: 1px solid var(--card-line);
outline-offset: -5px;
} }
.lr-seat-empty { .lr-seat-empty {
background-color: var(--back-700); background: var(--back-800);
background-image: var(--back-pattern-dense); box-shadow: 0 4px 0 var(--back-950);
background-size: 14px 14px;
color: var(--text-muted); color: var(--text-muted);
} }
.lr-seat-token { display: flex; color: var(--seat-color); } .lr-seat-token { display: flex; }
.lr-seat-label { font-size: var(--fs-sm); font-weight: 800; color: inherit; } .lr-seat-label { font-size: var(--fs-sm); font-weight: 800; color: inherit; }
.lr-seat-dot { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-xs); font-weight: 500; color: var(--ink-3); } .lr-seat-dot { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-xs); font-weight: 500; color: var(--ink-3); }
.lr-board-center { .lr-board-center {
position: absolute; inset: 0; margin: auto; position: absolute; inset: 0; margin: auto;
width: 40px; height: 40px; width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
border-radius: 9px; border-radius: 14px;
background: var(--back-950); background: var(--back-700);
color: var(--text-primary); color: var(--text-primary);
box-shadow: 0 0 0 4px var(--back-900); box-shadow: 0 4px 0 var(--back-950), 0 0 0 4px var(--stage);
} }
.lr-start { margin-top: var(--s-8); } .lr-start { margin-top: var(--s-8); }
...@@ -409,7 +409,7 @@ function getStyles() { ...@@ -409,7 +409,7 @@ function getStyles() {
.lr-searching .lr-hero { flex-direction: column; align-items: center; margin: 0; } .lr-searching .lr-hero { flex-direction: column; align-items: center; margin: 0; }
.lr-pulse-ring { display: grid; place-items: center; height: 120px; margin-bottom: var(--s-5); } .lr-pulse-ring { display: grid; place-items: center; height: 120px; margin-bottom: var(--s-5); }
.lr-pulse-inner { .lr-pulse-inner {
grid-area: 1 / 1; width: 64px; height: 90px; border-radius: 8px; box-shadow: var(--shadow-md); grid-area: 1 / 1; width: 64px; height: 90px; border-radius: 12px; box-shadow: var(--shadow-md);
transform: rotate(calc((var(--q) - 1) * 6deg)); transform: rotate(calc((var(--q) - 1) * 6deg));
animation: lrShuffle 2.8s var(--ease-in-out) infinite; animation: lrShuffle 2.8s var(--ease-in-out) infinite;
animation-delay: calc(var(--q) * 90ms); animation-delay: calc(var(--q) * 90ms);
......
// The die: a flat white die on a lip, ink pips. A roll throws it up, it spins
// with its faces flicking past, and it lands on the rolled face with a squash.
// An admin can replace any face drawing by uploading "emoji_dice_<n>".
import { getAsset } from '../../../core/theme.js';
import { esc } from '../../../core/ui.js';
// Which of the nine pip places are printed for each face.
const PIPS = {
1: [0, 0, 0, 0, 1, 0, 0, 0, 0],
2: [0, 0, 1, 0, 0, 0, 1, 0, 0],
3: [0, 0, 1, 0, 1, 0, 1, 0, 0],
4: [1, 0, 1, 0, 0, 0, 1, 0, 1],
5: [1, 0, 1, 0, 1, 0, 1, 0, 1],
6: [1, 0, 1, 1, 0, 1, 1, 0, 1],
};
export function pipsMarkup(value) {
return (PIPS[value] || PIPS[1]).map(d => `<span class="ld-pip${d ? ' on' : ''}"></span>`).join('');
}
function faceInner(n) {
const url = getAsset('emoji_dice_' + n);
return url
? `<img src="${esc(url)}" alt="" width="44" height="44" style="width:44px;height:44px;object-fit:contain;" draggable="false">`
: pipsMarkup(n);
}
/** Build the die inside `host`; returns its controller. */
export function createDie(host) {
host.innerHTML = `<span class="ld-die-face" data-face="1">${faceInner(1)}</span><span class="ld-die-shadow"></span>`;
const face = host.querySelector('.ld-die-face');
const shadow = host.querySelector('.ld-die-shadow');
let value = 1;
const show = n => { face.dataset.face = String(n); face.innerHTML = faceInner(n); };
const reduced = () => !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
return {
get value() { return value; },
set(n) { value = n; face.getAnimations().forEach(a => a.cancel()); show(n); },
/** Throw the die and land on `n`; onLand fires as it hits the table. */
async roll(n, ms = 760, onLand) {
value = n;
if (reduced()) { show(n); onLand?.(); return; }
face.getAnimations().forEach(a => a.cancel());
const turns = (2 + Math.floor(Math.random() * 2)) * 360 * (Math.random() < 0.5 ? -1 : 1);
// Faces flick past while it is in the air.
let last = n;
const flick = setInterval(() => {
let f = 1 + Math.floor(Math.random() * 6);
if (f === last) f = (f % 6) + 1;
last = f;
show(f);
}, Math.max(40, ms / 10));
const anim = face.animate([
{ transform: 'translateY(0) rotate(0deg) scale(1)' },
{ transform: `translateY(-58%) rotate(${turns * 0.45}deg) scale(1.14)`, offset: 0.35 },
{ transform: `translateY(0) rotate(${turns}deg) scale(1)`, offset: 0.68 },
{ transform: `translateY(0) rotate(${turns}deg) scale(1.14, 0.84)`, offset: 0.78 },
{ transform: `translateY(-8%) rotate(${turns}deg) scale(0.96, 1.05)`, offset: 0.88 },
{ transform: `translateY(0) rotate(${turns}deg) scale(1)` },
], { duration: ms, easing: 'ease-in-out' });
shadow.animate([
{ transform: 'scale(1)', opacity: 0.5 },
{ transform: 'scale(0.55)', opacity: 0.2, offset: 0.35 },
{ transform: 'scale(1.1)', opacity: 0.55, offset: 0.7 },
{ transform: 'scale(1)', opacity: 0.5 },
], { duration: ms, easing: 'ease-in-out' });
setTimeout(() => { clearInterval(flick); show(n); onLand?.(); }, ms * 0.68);
await anim.finished.catch(() => {});
clearInterval(flick);
show(n);
},
/** A short shiver: the roll could not be played. */
shiver() {
if (reduced()) return;
face.animate([
{ transform: 'rotate(0)' }, { transform: 'rotate(-10deg)' }, { transform: 'rotate(9deg)' },
{ transform: 'rotate(-5deg)' }, { transform: 'rotate(0)' },
], { duration: 420, easing: 'ease-out' });
},
};
}
export const DIE_STYLE = `
.ld-die { position: relative; width: 58px; height: 58px; flex-shrink: 0; display: grid; place-items: center; }
.ld-die-face {
position: relative; z-index: 1;
width: 46px; height: 46px;
display: grid; grid-template: repeat(3, 1fr) / repeat(3, 1fr);
padding: 6px;
border-radius: 13px;
background: var(--card);
box-shadow: 0 4px 0 var(--card-edge);
}
.ld-die-face:has(img) { padding: 1px; display: block; }
.ld-pip { width: 8px; height: 8px; border-radius: 50%; margin: auto; }
.ld-pip.on { background: var(--ink); }
.ld-die-face[data-face="1"] .ld-pip.on { width: 12px; height: 12px; background: var(--red-ink); }
.ld-die-shadow {
position: absolute; inset-inline: 10px; bottom: 0; height: 8px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
filter: blur(3px);
opacity: 0.5;
}
`;
/**
* Ludo rules, tested on their own (no browser): node tests/play/ludo-rules.mjs
*
* Covers every rule the game plays by, then simulates thousands of complete
* games with the real bot to prove a game always ends, with every token home
* or ranked and nobody ever stuck on a finished player's turn.
*/
import * as R from '../../public/js/modules/ludo/logic/rules.js';
import { chooseMove } from '../../public/js/modules/ludo/logic/bot.js';
import { SHARED_PATH, HOME_COLUMNS, START_SQUARES, SAFE_SQUARES } from '../../public/js/modules/ludo/logic/board-map.js';
let failed = 0, passed = 0;
function ok(label, cond, detail = '') {
if (cond) { passed++; return; }
failed++;
console.log(` FAIL ${label}${detail ? ' — ' + detail : ''}`);
}
const section = name => console.log(`\n${name}`);
function fresh(n = 4) { const g = R.createGame(n); g.humanPlayers = [0]; return g; }
function place(g, p, k, pos) { g.players[p].pieces[k].pos = pos; g.players[p].pieces[k].finished = pos === R.FINISH_POS; }
/** Roll a fixed value for the current player and return the moves, as the scene does. */
function roll(g, v) { g.forcedRolls = [v]; const d = R.rollDice(g, g.currentPlayer); const r = R.registerRoll(g, d); return { d, ...r, moves: r.forfeit ? [] : R.getValidMoves(g, g.currentPlayer, d) }; }
function play(g, move) { const res = R.applyMove(g, g.currentPlayer, move); R.nextTurn(g); return res; }
section('board map');
ok('the shared track has 52 squares', SHARED_PATH.length === 52);
ok('no square appears twice on the track', new Set(SHARED_PATH.map(s => s.join(','))).size === 52);
ok('each step on the track moves to a neighbouring square', SHARED_PATH.every((s, i) => {
const n = SHARED_PATH[(i + 1) % 52];
return Math.abs(s[0] - n[0]) + Math.abs(s[1] - n[1]) <= 2 && (s[0] === n[0] || s[1] === n[1] || Math.abs(s[0] - n[0]) === 1);
}));
ok('board map and rules agree on the start squares', START_SQUARES.join() === R.START_POSITIONS.join());
ok('board map and rules agree on the safe squares', SAFE_SQUARES.join() === R.SAFE_SQUARES.join());
for (let p = 0; p < 4; p++) {
const entry = SHARED_PATH[(R.HOME_ENTRY_POS + START_SQUARES[p]) % 52];
const first = HOME_COLUMNS[p][0];
ok(`player ${p}: the home column starts next to its entry square`, Math.abs(entry[0] - first[0]) + Math.abs(entry[1] - first[1]) === 1, `${entry} -> ${first}`);
}
section('leaving the yard');
{
const g = fresh();
for (const v of [1, 2, 3, 4, 5]) {
const r = roll(g, v);
ok(`a ${v} cannot bring a token out`, r.moves.length === 0);
R.passNoMove(g, v);
ok(`after a ${v} with no move the turn passes`, g.currentPlayer === 1);
g.currentPlayer = 0;
}
const r = roll(g, 6);
ok('a six offers all four tokens', r.moves.length === 4 && r.moves.every(m => m.type === 'enter'));
const res = play(g, r.moves[0]);
ok('the token stands on its start square', g.players[0].pieces[0].pos === 0);
ok('a six earns another roll', res.extraTurn && g.currentPlayer === 0);
}
section('three sixes');
{
const g = fresh();
place(g, 0, 0, 10);
let r = roll(g, 6); play(g, r.moves.find(m => m.pieceId === '0-0'));
ok('first six: same player again', g.currentPlayer === 0);
r = roll(g, 6); play(g, r.moves.find(m => m.pieceId === '0-0'));
ok('second six: same player again', g.currentPlayer === 0);
const before = g.players[0].pieces[0].pos;
r = roll(g, 6);
ok('the third six is forfeited', r.forfeit && r.moves.length === 0);
R.passNoMove(g, 0);
ok('the token did not move on the third six', g.players[0].pieces[0].pos === before);
ok('the turn passes after three sixes', g.currentPlayer === 1);
ok('the six counter resets for the next player', g.sixesInRow === 0);
}
{
const g = fresh();
place(g, 0, 0, 10);
let r = roll(g, 6); play(g, r.moves[0]);
r = roll(g, 6); play(g, r.moves.find(m => m.pieceId === '0-0'));
r = roll(g, 2); play(g, r.moves.find(m => m.pieceId === '0-0'));
ok('two sixes then a two: the turn passes', g.currentPlayer === 1);
g.currentPlayer = 0;
r = roll(g, 6);
ok('sixes do not carry over into the next turn', !r.forfeit && g.sixesInRow === 1);
}
section('captures and safe squares');
{
const g = fresh();
// Red at local 10 (global 10); green token on global 13 is green's start (safe).
place(g, 0, 0, 10); place(g, 1, 0, 0);
let r = roll(g, 3);
const m = r.moves.find(x => x.pieceId === '0-0');
ok('landing on a start square is not a capture', m.type === 'move');
play(g, m);
ok('both tokens share the safe square', g.players[1].pieces[0].pos === 0 && g.players[0].pieces[0].pos === 13);
}
{
const g = fresh();
// Red at global 4, green token at global 7 (green local 46).
place(g, 0, 0, 4); place(g, 1, 0, 46); place(g, 1, 1, 46);
const r = roll(g, 3);
const m = r.moves.find(x => x.pieceId === '0-0');
ok('landing on an opponent is a capture', m.type === 'capture');
const res = play(g, m);
ok('every opponent token on the square goes home', g.players[1].pieces[0].pos === -1 && g.players[1].pieces[1].pos === -1, JSON.stringify(res.captured));
ok('the capture is reported', res.captured.length === 2 && res.captured[0].from === 46);
ok('a capture earns another roll', res.extraTurn && g.currentPlayer === 0);
}
{
const g = fresh();
// A starred square (global 8): red local 5 -> 8, green local 47 is global 8.
place(g, 0, 0, 5); place(g, 1, 0, 47);
const r = roll(g, 3);
const m = r.moves.find(x => x.pieceId === '0-0');
ok('a starred square is safe', m.type === 'move');
play(g, m);
ok('the token on the star stays', g.players[1].pieces[0].pos === 47);
}
{
const g = fresh();
place(g, 0, 0, 49);
// Another colour's token on red's home column square cannot exist; and a
// token in the home column cannot be captured by anyone.
place(g, 1, 0, 52);
ok('home-column tokens are not on the shared track', R.globalSquare(g, 1, 52) === null);
}
section('home column and the exact roll');
{
const g = fresh();
place(g, 0, 0, 48);
let r = roll(g, 4);
let m = r.moves.find(x => x.pieceId === '0-0');
ok('a token turns into its home column after square 50', m.type === 'home' && m.to === 52);
play(g, m);
g.currentPlayer = 0;
r = roll(g, 5);
ok('a roll that overshoots home is not offered', !r.moves.find(x => x.pieceId === '0-0'));
R.passNoMove(g, 5); g.currentPlayer = 0;
r = roll(g, 4);
m = r.moves.find(x => x.pieceId === '0-0');
ok('the exact roll takes the token home', m && m.type === 'finish');
const res = play(g, m);
ok('the token is finished', g.players[0].pieces[0].finished);
ok('reaching home earns another roll', res.extraTurn && g.currentPlayer === 0);
}
section('a player finishing, and the end of the game');
{
const g = fresh(4);
[0, 1, 2].forEach(k => place(g, 0, k, R.FINISH_POS));
place(g, 0, 3, 54);
const r = roll(g, 2);
const res = play(g, r.moves[0]);
ok('the last token home finishes the player', res.playerFinished && g.players[0].finished);
ok('the player is first in the finishing order', g.winners[0] === 0);
ok('a finished player gets no extra roll', !res.extraTurn);
ok('play moves on past the finished player (no stuck turn)', g.currentPlayer === 1);
ok('the game goes on while three players are left', !g.gameOver);
// Turn order skips the finished player from now on.
g.currentPlayer = 3; R.nextTurn(g);
ok('the finished player is skipped in the turn order', g.currentPlayer === 1);
// Two more finish: the game ends, last place filled in.
[0, 1, 2, 3].forEach(k => place(g, 1, k, R.FINISH_POS)); g.players[1].finished = true; g.winners.push(1);
[0, 1, 2].forEach(k => place(g, 2, k, R.FINISH_POS)); place(g, 2, 3, 55);
g.currentPlayer = 2;
const r2 = roll(g, 1);
play(g, r2.moves[0]);
ok('the game ends when one player is left', g.gameOver);
ok('the finishing order lists everyone once', g.winners.join() === '0,1,2,3', g.winners.join());
}
{
const g = fresh(2);
[0, 1, 2].forEach(k => place(g, 0, k, R.FINISH_POS));
place(g, 0, 3, 55);
const r = roll(g, 1);
play(g, r.moves[0]);
ok('in a duel the first home wins and the game ends', g.gameOver && g.winners.join() === '0,1');
}
section('bot');
{
const g = fresh();
place(g, 0, 0, 4); place(g, 1, 0, 46); // red can capture green at global 7 with a 3
place(g, 0, 1, 20);
g.diceValue = 3;
ok('the bot takes a capture', chooseMove(g, 0, 3, 'hard').pieceId === '0-0');
const g2 = fresh();
place(g2, 0, 0, 53); place(g2, 0, 1, 30);
g2.diceValue = 3;
ok('the bot takes a token home', chooseMove(g2, 0, 3, 'hard').pieceId === '0-0');
const g3 = fresh();
place(g3, 0, 0, 30); g3.diceValue = 6;
const m3 = chooseMove(g3, 0, 6, 'hard');
ok('the bot brings a token out on a six', m3.type === 'enter');
}
section('simulated games');
{
let games = 0, stuck = 0, bad = 0, turnsMax = 0, threeSix = 0, captures = 0, finishes = 0;
for (const n of [2, 3, 4]) {
for (let k = 0; k < 400; k++) {
const g = R.createGame(n);
g.humanPlayers = [0];
g.startTime = Date.now();
let turns = 0;
while (!g.gameOver && turns < 5000) {
const p = g.currentPlayer;
if (g.players[p].finished) { stuck++; break; }
const d = R.rollDice(g, p);
const r = R.registerRoll(g, d);
if (r.forfeit) { threeSix++; R.passNoMove(g, 0); turns++; continue; }
const mv = chooseMove(g, p, d, ['easy', 'medium', 'hard'][k % 3]);
if (!mv) { R.passNoMove(g, d); turns++; continue; }
const res = R.applyMove(g, p, mv);
captures += res.captured.length;
if (res.finishedPiece) finishes++;
R.nextTurn(g);
turns++;
for (const pl of g.players) for (const pc of pl.pieces) {
if (pc.pos < -1 || pc.pos > R.FINISH_POS || (pc.finished !== (pc.pos === R.FINISH_POS))) bad++;
}
}
games++;
turnsMax = Math.max(turnsMax, turns);
if (!g.gameOver) stuck++;
if (g.gameOver && (new Set(g.winners).size !== n || g.winners.length !== n)) bad++;
}
}
ok(`${games} games all end`, stuck === 0, `${stuck} stuck`);
ok('no token is ever in an impossible place', bad === 0, `${bad} bad states`);
ok('captures happen', captures > 0);
ok('tokens get home', finishes > 0);
console.log(` ${games} games, longest ${turnsMax} rolls, ${captures} captures, ${threeSix} triple sixes forfeited`);
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
/**
* Ludo, played for real in Chrome against the local server.
*
* node tests/play/ludo.mjs [--only local4,local2,online,layout] [--base http://127.0.0.1:8080]
* [--speed 0.2] [--out <dir>] [--headful]
*
* Every part drives the real UI: the play table, the ludo room, the roll
* button and the tokens on the canvas (tapped at the point computed from the
* canvas rectangle and the board geometry). A tiny hook, present only when
* localStorage el3ab_test is '1', lets the test read the game state, fix the
* next dice for a scenario, and pace the animations.
*
* local4 1 player + 3 bots, played to the end. Every roll and move the page
* makes is replayed through the rules in Node: turn order, the six to
* leave the yard, extra rolls, three sixes, captures, safe squares,
* the home column, the exact roll home, players finishing and the
* finishing order must all agree. Then the result screen, play
* again, and back to the table.
* local2 two people on one device, played to the end, same checks.
* online two throwaway accounts in two browsers: matchmaking, a game in
* which both boards must stay identical and the turn must pass
* correctly, then one leaves and the other is told (and wins).
* layout the table and the result at 360x640, 390x844 and 430x932, and a
* resize in the middle of a game.
*
* Zero console errors are allowed. Every account, queue row, match, chat row,
* reward row and rating row the test creates is deleted at the end, even when
* an assertion fails.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import * as R from '../../public/js/modules/ludo/logic/rules.js';
import { chooseMove } from '../../public/js/modules/ludo/logic/bot.js';
import { makeView, cellOf } from '../../public/js/modules/ludo/canvas/board.js';
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 ONLY = (argOf('--only', 'local4,local2,timeout,online,layout') || '').split(',').filter(Boolean);
const SPEED = argOf('--speed', '0.2');
const OUT = argOf('--out', path.join(os.tmpdir(), 'el3ab-ludo-shots'));
const HEADFUL = args.includes('--headful');
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = process.env.SUPABASE_SERVICE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
const TAG = 'ZZZ-LUDO-TEST';
// Errors from other games' modules that are being rebuilt in parallel; listed, not counted.
const NOT_LUDO = /module (backgammon|domino|chess) failed to load|modules\/(backgammon|domino|chess)\//;
fs.mkdirSync(OUT, { recursive: true });
const sleep = ms => new Promise(r => setTimeout(r, ms));
const failures = [];
let passed = 0;
function ok(label, cond, detail = '') {
if (cond) { passed++; console.log(` ok ${label}`); return true; }
failures.push(label);
console.log(` FAIL ${label}${detail ? ' — ' + detail : ''}`);
return false;
}
// ── Supabase (assertions and teardown only) ─────────────────────────────────
async function sb(p, { method = 'GET', body } = {}) {
const r = await fetch(`${SB}/rest/v1/${p}`, {
method,
headers: { apikey: SK, Authorization: `Bearer ${SK}`, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: body ? JSON.stringify(body) : undefined,
});
const text = await r.text();
try { return JSON.parse(text); } catch (e) { return text; }
}
const accounts = [];
const matchIds = new Set();
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, 200));
const a = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
accounts.push(a);
await sb(`profiles?id=eq.${a.id}`, { method: 'PATCH', body: { display_name: name, level: 3 } });
a.profile = (await sb(`profiles?id=eq.${a.id}&select=*`))?.[0] || r.profile;
return a;
}
async function teardown() {
console.log('\n=== teardown ===');
for (const a of accounts) {
const rows = await sb(`ludo_matches?players=cs.${encodeURIComponent(JSON.stringify([a.id]))}&select=id`);
if (Array.isArray(rows)) rows.forEach(r => matchIds.add(r.id));
}
for (const m of matchIds) {
await sb(`chat_messages?channel_id=eq.${m}`, { method: 'DELETE' });
await sb(`ludo_matches?id=eq.${m}`, { method: 'DELETE' });
}
console.log(` removed ${matchIds.size} ludo match(es)`);
let stuck = [];
for (const a of accounts) {
for (const p of [`ludo_queue?user_id=eq.${a.id}`, `chat_messages?sender_id=eq.${a.id}`, `economy_transactions?player_id=eq.${a.id}`,
`player_game_ratings?player_id=eq.${a.id}`, `rating_history?player_id=eq.${a.id}`, `player_achievements?player_id=eq.${a.id}`,
`matchmaking_queue?player_id=eq.${a.id}`, `mp_log?player_id=eq.${a.id}`, `notifications?user_id=eq.${a.id}`]) {
await sb(p, { method: 'DELETE' }).catch(() => {});
}
for (let attempt = 0; attempt < 3; attempt++) {
await sb(`profiles?id=eq.${a.id}`, { method: 'DELETE' });
const left = await sb(`profiles?id=eq.${a.id}&select=id`);
if (!Array.isArray(left) || !left.length) break;
if (attempt === 2) stuck.push(a.id);
await sleep(400);
}
await fetch(`${SB}/auth/v1/admin/users/${a.id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
}
const q = accounts.length ? await sb(`ludo_queue?user_id=in.(${accounts.map(a => a.id).join(',')})&select=id`) : [];
console.log(` removed ${accounts.length - stuck.length} of ${accounts.length} throwaway account(s); queue rows left: ${Array.isArray(q) ? q.length : '?'}`);
if (stuck.length) { failures.push('teardown left accounts behind'); console.log(' COULD NOT REMOVE: ' + stuck.join(', ')); }
}
// ── Browser ─────────────────────────────────────────────────────────────────
let browser;
const errors = [];
const otherErrors = [];
async function openPage(account, { w = 390, h = 844, label = 'page' } = {}) {
const ctx = await browser.createBrowserContext();
const page = await ctx.newPage();
page.label = label;
page.on('pageerror', e => { const m = `[${label}] PAGEERROR ${String(e).slice(0, 300)}`; (NOT_LUDO.test(m) ? otherErrors : errors).push(m); });
page.on('console', m => {
if (m.type() !== 'error' && !(m.type().startsWith('warn') && m.text().includes('[i18n] missing'))) return;
const loc = m.location()?.url || '';
const text = `[${label}] ${m.text().slice(0, 300)}${loc ? ' @ ' + loc.replace(BASE, '') : ''}`;
(NOT_LUDO.test(text) ? otherErrors : errors).push(text);
});
await page.setViewport({ width: w, height: h, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
await page.goto(BASE + '/public/css/tokens.css');
await page.evaluate((a, speed) => {
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: false,
}));
localStorage.setItem('el3ab_test', '1');
localStorage.setItem('el3ab_test_speed', String(speed));
}, account, SPEED);
await page.goto(BASE + '/', { waitUntil: 'networkidle2', timeout: 60000 });
await sleep(1500);
return page;
}
const state = page => page.evaluate(() => window.__ludoTest?.active ? window.__ludoTest.state() : null);
const drain = page => page.evaluate(() => window.__ludoTest?.drain?.() || []);
async function waitFor(page, fn, { timeout = 15000, every = 80, label = 'condition', arg } = {}) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
const v = await page.evaluate(fn, arg).catch(() => null);
if (v) return v;
await sleep(every);
}
throw new Error(`timed out waiting for ${label}`);
}
async function tapSel(page, sel) {
await page.waitForSelector(sel, { visible: true, timeout: 15000 });
const el = await page.$(sel);
// Scroll it into view the way a thumb would, then tap its middle.
await el.evaluate(e => e.scrollIntoView({ block: 'center', inline: 'center' }));
await sleep(60);
const box = await el.boundingBox();
if (!box) throw new Error(`${sel} has no box`);
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
}
/** The play table -> ludo -> the room for local or online play. */
async function openLudoRoom(page, online) {
await tapSel(page, '.hand-card[data-game="ludo"]');
await sleep(500);
await tapSel(page, online ? '#btn-multi' : '#btn-single');
await page.waitForSelector('#btn-start', { visible: true, timeout: 10000 });
await sleep(500);
}
/** Tap the token for `move` on the canvas, at the point the board geometry puts it. */
async function tapToken(page, st, move) {
const rect = await page.$eval('#ld-canvas', c => { const r = c.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width }; });
const view = makeView(rect.w, st.rot);
const p = Number(move.pieceId.split('-')[0]);
const k = Number(move.pieceId.split('-')[1]);
const slot = st.seats[p];
const [col, row] = cellOf(slot, st.positions[p][k], k);
const v = view.cell(col, row);
const cs = rect.w / 15;
await page.touchscreen.tap(rect.x + v.x, rect.y + v.y + cs * 0.26 - cs * 0.42);
}
/** Rebuild a rules game from the page state, for choosing a move in Node. */
function gameFrom(st) {
const g = R.createGame(st.positions.length, st.seats);
st.positions.forEach((ps, p) => ps.forEach((pos, k) => { g.players[p].pieces[k].pos = pos; g.players[p].pieces[k].finished = pos === R.FINISH_POS; }));
g.players.forEach((pl, p) => { pl.finished = st.finished[p]; });
g.winners = [...st.winners];
g.currentPlayer = st.current;
g.diceValue = st.dice;
return g;
}
// ── Replaying a page's log through the rules ────────────────────────────────
class Referee {
constructor(n, seats) {
this.g = R.createGame(n, seats);
this.problems = [];
this.seen = { leaveYard: 0, blockedYard: 0, extraSix: 0, threeSixes: 0, captures: 0, safeShare: 0, homeColumn: 0, exactBlocked: 0, tokenHome: 0, playerDone: 0, bonusCapture: 0, bonusHome: 0 };
}
problem(msg) { if (this.problems.length < 12) this.problems.push(msg); }
feed(events) {
const g = this.g;
for (const e of events) {
if (e.t === 'roll') {
if (e.p !== g.currentPlayer) this.problem(`roll by ${e.p} but it is ${g.currentPlayer}'s turn`);
const allYard = g.players[e.p].pieces.every(pc => pc.pos === -1 || pc.finished);
const r = R.registerRoll(g, e.dice);
if (r.forfeit !== !!e.forfeit) this.problem(`three-sixes mismatch at roll ${e.dice}`);
if (r.forfeit) this.seen.threeSixes++;
this.pending = { dice: e.dice, allYard, p: e.p, forfeit: r.forfeit };
} else if (e.t === 'pass') {
const legal = this.pending?.forfeit ? [] : R.getValidMoves(g, e.p, e.dice);
if (legal.length) this.problem(`pass by ${e.p} on ${e.dice} although ${legal.length} moves were legal`);
if (this.pending?.allYard && e.dice !== 6) this.seen.blockedYard++;
const homeBlocked = g.players[e.p].pieces.some(pc => !pc.finished && pc.pos > R.HOME_ENTRY_POS && pc.pos + e.dice > R.FINISH_POS);
if (homeBlocked) this.seen.exactBlocked++;
if (e.forfeit) R.nextTurn(g); else R.passNoMove(g, e.dice);
if (e.next != null && e.next !== g.currentPlayer) this.problem(`after a pass the page gave the turn to ${e.next}, rules say ${g.currentPlayer}`);
this.pending = null;
} else if (e.t === 'move') {
const legal = R.getValidMoves(g, e.p, e.dice);
const m = legal.find(x => x.pieceId === e.move.pieceId && x.to === e.move.to);
if (!m) { this.problem(`illegal move ${JSON.stringify(e.move)} on ${e.dice}`); continue; }
// A token that could not move for want of the exact roll.
const blocked = g.players[e.p].pieces.some(pc => !pc.finished && pc.pos > R.HOME_ENTRY_POS && pc.pos + e.dice > R.FINISH_POS);
if (blocked) this.seen.exactBlocked++;
const res = R.applyMove(g, e.p, m);
if (m.type === 'enter') this.seen.leaveYard++;
if (m.to > R.HOME_ENTRY_POS && m.from <= R.HOME_ENTRY_POS) this.seen.homeColumn++;
if (res.finishedPiece) this.seen.tokenHome++;
if (res.playerFinished) this.seen.playerDone++;
if (res.captured.length) this.seen.captures += res.captured.length;
if (JSON.stringify(res.captured.map(c => [c.playerIdx, c.pieceIdx])) !== JSON.stringify((e.captured || []).map(c => [c.playerIdx, c.pieceIdx]))) this.problem(`capture mismatch on ${JSON.stringify(e.move)}`);
// Safe squares: a token landing where another colour already stands, untouched.
const gsq = R.globalSquare(g, e.p, m.to);
if (gsq != null && R.isSafeSquare(gsq) && R.opponentsOn(g, e.p, gsq).length) this.seen.safeShare++;
const before = g.currentPlayer;
if (!g.gameOver) R.nextTurn(g);
if (!g.gameOver && g.currentPlayer === before && !g.players[before].finished) {
if (e.dice === 6) this.seen.extraSix++;
else if (res.captured.length) this.seen.bonusCapture++;
else if (res.finishedPiece) this.seen.bonusHome++;
else this.problem('an extra roll was given for nothing');
}
// Nothing may stand with a different colour on an unsafe square.
for (let a = 0; a < g.numPlayers; a++) for (const pc of g.players[a].pieces) {
const sq = R.globalSquare(g, a, pc.pos);
if (sq != null && !R.isSafeSquare(sq) && R.opponentsOn(g, a, sq).length) this.problem(`two colours on unsafe square ${sq}`);
}
this.pending = null;
}
}
}
/** The page's board must be the referee's board. */
compare(st) {
const mine = JSON.stringify(this.g.players.map(p => p.pieces.map(pc => pc.pos)));
const theirs = JSON.stringify(st.positions);
return mine === theirs ? null : `referee ${mine} vs page ${theirs}`;
}
}
/**
* Play a local game on `page` to its end. `humans` are the seats tapped by
* the test. Returns the referee. `scenario` may fix dice once.
*/
async function playLocal(page, { humans, label, maxMs = 15 * 60 * 1000, threeSixes = false }) {
const st0 = await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the game to start' });
const ref = new Referee(st0.positions.length, st0.seats);
const t0 = Date.now();
let taps = 0, rolls = 0, forced = false, sixWatch = null, lastSeen = Date.now(), lastKey = '';
while (Date.now() - t0 < maxMs) {
ref.feed(await drain(page));
const st = await state(page);
if (!st) break;
if (st.ended || st.gameOver) break;
const key = `${st.current}|${st.rolled}|${JSON.stringify(st.positions)}`;
if (key !== lastKey) { lastKey = key; lastSeen = Date.now(); }
if (Date.now() - lastSeen > 25000) { ok(`${label}: the game never stalls`, false, `stuck on ${key}`); break; }
if (!humans.includes(st.current)) { await sleep(60); continue; }
if (st.canRoll && !st.busy) {
// Three sixes in a row, once, with a token already on the track.
if (threeSixes && !forced && st.positions[st.current].some(p => p >= 0 && p < 40)) {
await page.evaluate(() => window.__ludoTest.forceDice([6, 6, 6]));
forced = true;
sixWatch = { p: st.current };
}
await tapSel(page, '#roll-btn');
rolls++;
await sleep(120);
continue;
}
if (st.choices && st.choices.length > 1 && !st.busy) {
const g = gameFrom(st);
const pick = chooseMove(g, st.current, st.dice, 'hard') || st.choices[0];
await tapToken(page, st, pick);
taps++;
await sleep(150);
const after = await state(page);
if (after?.choices) {
// The tap missed: take the first choice so the game goes on, and say so.
ok(`${label}: a tap on the token is picked up`, false, `missed ${pick.pieceId}`);
await tapToken(page, after, after.choices[0]);
}
continue;
}
await sleep(60);
}
ref.feed(await drain(page));
const st = await state(page);
ref.taps = taps; ref.rolls = rolls; ref.final = st; ref.forced = forced; ref.sixWatch = sixWatch;
return ref;
}
function reportRules(ref, label, { expectThreeSixes = false } = {}) {
ok(`${label}: every roll and move follows the rules and the turn order`, ref.problems.length === 0, ref.problems.join(' | '));
const st = ref.final;
if (st) {
const diff = ref.compare(st);
ok(`${label}: the page board matches the rules replay`, !diff, diff || '');
}
const s = ref.seen;
console.log(` seen: ${JSON.stringify(s)}; ${ref.rolls} rolls and ${ref.taps} token taps by the test`);
ok(`${label}: a six brings a token out of the yard`, s.leaveYard > 0);
ok(`${label}: without a six the yard stays shut`, s.blockedYard > 0);
ok(`${label}: a six earns another roll`, s.extraSix > 0);
if (expectThreeSixes) ok(`${label}: three sixes forfeit the turn`, s.threeSixes > 0);
ok(`${label}: tokens enter their home column`, s.homeColumn > 0);
ok(`${label}: tokens reach home`, s.tokenHome > 0);
ok(`${label}: a player finishes`, s.playerDone > 0);
}
/**
* Online: keep playing for whoever is to move until both pages stand still
* on the same step, then return both states.
*/
async function settleBoth(pages, { timeout = 30000 } = {}) {
const t0 = Date.now();
let s0, s1;
while (Date.now() - t0 < timeout) {
[s0, s1] = await Promise.all(pages.map(state));
if (!s0 || !s1) return [s0, s1];
const still = s => !s.busy && !s.rolled && !s.animating;
if (s0.seq === s1.seq && still(s0) && still(s1)) {
await sleep(400);
const [r0, r1] = await Promise.all(pages.map(state));
if (r0.seq === s0.seq && r1.seq === s1.seq && still(r0) && still(r1)) return [r0, r1];
continue;
}
const cur = s0.current, ast = cur === 0 ? s0 : s1, actor = pages[cur];
if (ast.me === cur && ast.choices && ast.choices.length > 1 && !ast.busy) {
await tapToken(actor, ast, chooseMove(gameFrom(ast), cur, ast.dice, 'hard') || ast.choices[0]);
}
await sleep(150);
}
return [s0, s1];
}
// ── Parts ───────────────────────────────────────────────────────────────────
async function partLocal4() {
console.log('\n=== local: 1 player + 3 bots, to the end ===');
const me = await createAccount(`${TAG} أ`);
const page = await openPage(me, { label: 'local4' });
await openLudoRoom(page, false);
const chosen = await page.$eval('#player-count-grid [aria-pressed="true"]', b => b.dataset.count);
ok('the room offers four players by default', chosen === '4');
const dist = await page.$eval('#dist-options [aria-pressed="true"]', b => b.dataset.humans);
ok('one player and three bots is the default layout', dist === '1');
await page.screenshot({ path: path.join(OUT, 'room-local.png') });
await tapSel(page, '#btn-start');
const st0 = await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the table' });
ok('the table seats four: red, green, yellow, blue', st0.seats.join() === '0,1,2,3' && st0.positions.length === 4);
ok('every token starts in its yard', st0.positions.flat().every(p => p === -1));
await sleep(700);
await page.screenshot({ path: path.join(OUT, 'local4-start.png') });
// A capture, set up honestly: wait for one in play; screenshot mid-game.
const ref = await playLocal(page, { humans: [0], label: 'local4', threeSixes: true });
reportRules(ref, 'local4', { expectThreeSixes: true });
ok('local4: captures send tokens home', ref.seen.captures > 0);
const st = ref.final;
ok('local4: the game ends with every seat ranked', st && st.gameOver && st.winners.length === 4 && new Set(st.winners).size === 4, JSON.stringify(st?.winners));
// The result screen.
await page.waitForSelector('.lr-card', { visible: true, timeout: 20000 });
await sleep(2600);
await page.screenshot({ path: path.join(OUT, 'local4-result.png') });
const res = await page.evaluate(() => ({
ribbon: document.querySelector('.lr-ribbon')?.textContent.trim(),
tone: document.querySelector('.lr-ribbon')?.className,
rows: [...document.querySelectorAll('.lr-row .lr-name')].map(n => n.textContent.trim()),
sticker: !!document.querySelector('.lr-sticker .gi'),
again: !!document.querySelector('#btn-again.btn-primary.btn-xl'),
back: !!document.querySelector('#btn-back.btn-secondary'),
shot: !!document.querySelector('.lr-shot'),
}));
const myPlace = st.winners.indexOf(0) + 1;
ok('result: the ribbon says the outcome', myPlace === 1 ? res.ribbon === 'فزت!' && /green/.test(res.tone) : myPlace === 4 ? res.ribbon === 'خسرت' : /المركز/.test(res.ribbon), `${res.ribbon} (${res.tone}), place ${myPlace}`);
ok('result: the finishing order lists all four in order', res.rows.length === 4 && res.rows[st.winners.indexOf(0)] !== undefined, JSON.stringify(res.rows));
ok('result: a sticker, green play again, neutral back, the dimmed table', res.sticker && res.again && res.back && res.shot);
// Play again: a fresh table with the same seats.
await tapSel(page, '#btn-again');
const again = await waitFor(page, () => { const s = window.__ludoTest?.active && window.__ludoTest.state(); return s && s.positions.flat().every(p => p === -1) && !s.gameOver ? s : null; }, { label: 'a new game' });
ok('play again starts a fresh game with the same table', again.positions.length === 4 && again.mode === 'bot');
// Leave it through the real exit, then back to the table from the result.
await sleep(600);
await tapSel(page, '#exit-btn');
await tapSel(page, '#modal-confirm');
await page.waitForSelector('.lr-card', { visible: true, timeout: 10000 });
const withdrew = await page.$eval('.lr-ribbon', r => r.textContent.trim());
ok('leaving a game shows "you withdrew"', withdrew === 'انسحبت', withdrew);
await sleep(1400);
await tapSel(page, '#btn-back');
await page.waitForSelector('.hand-card[data-game="ludo"]', { visible: true, timeout: 10000 });
ok('back to the table returns to the play table', true);
await page.close();
}
async function partLocal2() {
console.log('\n=== local: two people on one device, to the end ===');
const me = await createAccount(`${TAG} ب`);
const page = await openPage(me, { label: 'local2' });
await openLudoRoom(page, false);
await tapSel(page, '#player-count-grid [data-count="2"]');
await sleep(250);
await tapSel(page, '#dist-options [data-humans="2"]');
await sleep(250);
await page.screenshot({ path: path.join(OUT, 'room-duel.png') });
await tapSel(page, '#btn-start');
const st0 = await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the table' });
ok('a duel seats red against yellow, across the board', st0.seats.join() === '0,2' && st0.mode === 'local-multi', st0.seats.join());
const ref = await playLocal(page, { humans: [0, 1], label: 'local2' });
reportRules(ref, 'local2');
const st = ref.final;
ok('local2: the first player home wins, and the game ends', st && st.gameOver && st.winners.length === 2, JSON.stringify(st?.winners));
await page.waitForSelector('.lr-card', { visible: true, timeout: 20000 });
await sleep(2400);
await page.screenshot({ path: path.join(OUT, 'local2-result.png') });
const rows = await page.$$eval('.lr-row', r => r.length);
ok('local2: the result lists both players', rows === 2);
await page.close();
}
async function partOnline() {
console.log('\n=== online: two accounts, two browsers ===');
const A = await createAccount(`${TAG} سارة`);
const B = await createAccount(`${TAG} عمر`);
const pa = await openPage(A, { label: 'online-A' });
const pb = await openPage(B, { label: 'online-B' });
for (const p of [pa, pb]) {
await openLudoRoom(p, true);
await tapSel(p, '#player-count-grid [data-count="2"]');
await sleep(200);
}
await pa.screenshot({ path: path.join(OUT, 'room-online.png') });
// Both search at the same moment: the server must still pair them.
await Promise.all([tapSel(pa, '#btn-start'), tapSel(pb, '#btn-start')]);
let sa, sbb;
try {
sa = await waitFor(pa, () => window.__ludoTest?.active && window.__ludoTest.state()?.live && window.__ludoTest.state(), { timeout: 30000, label: 'A at the table' });
sbb = await waitFor(pb, () => window.__ludoTest?.active && window.__ludoTest.state()?.live && window.__ludoTest.state(), { timeout: 30000, label: 'B at the table' });
} catch (e) {
ok('matchmaking seats both players at one table', false, e.message);
await pa.screenshot({ path: path.join(OUT, 'online-A-fail.png') });
await pb.screenshot({ path: path.join(OUT, 'online-B-fail.png') });
return;
}
ok('matchmaking seats both players at one table', true);
ok('the two clients sit in different seats of the same two-player table', sa.me !== sbb.me && sa.seats.join() === '0,2' && sbb.seats.join() === '0,2', `${sa.me}/${sbb.me} ${sa.seats}/${sbb.seats}`);
ok('each player sees their own yard at the bottom left', (sa.seats[sa.me] + sa.rot) % 4 === 0 && (sbb.seats[sbb.me] + sbb.rot) % 4 === 0);
const pages = sa.me === 0 ? [pa, pb] : [pb, pa]; // pages[i] plays seat i
await sleep(1500);
await pa.screenshot({ path: path.join(OUT, 'online-A-start.png') });
await pb.screenshot({ path: path.join(OUT, 'online-B-start.png') });
// Play until enough happened: both sides acting, the turn passing both ways.
const t0 = Date.now();
let actions = 0, syncChecks = 0, mismatches = [], turnPasses = 0, lastCurrent = null, moves = 0;
while (Date.now() - t0 < 240000 && (moves < 14 || turnPasses < 6)) {
const [s0, s1] = await Promise.all(pages.map(state));
if (!s0 || !s1) break;
if (s0.gameOver || s1.gameOver) break;
// In sync whenever both are settled on the same step.
if (s0.seq === s1.seq && !s0.busy && !s1.busy && !s0.rolled && !s1.rolled && !s0.animating && !s1.animating) {
syncChecks++;
if (JSON.stringify(s0.positions) !== JSON.stringify(s1.positions) || s0.current !== s1.current) mismatches.push(`seq ${s0.seq}: ${JSON.stringify(s0.positions)}/${s0.current} vs ${JSON.stringify(s1.positions)}/${s1.current}`);
}
if (lastCurrent != null && s0.current !== lastCurrent && s0.seq === s1.seq) turnPasses++;
lastCurrent = s0.current;
const cur = s0.current;
const actor = pages[cur], ast = cur === 0 ? s0 : s1;
if (ast.me !== cur) { await sleep(120); continue; }
if (ast.canRoll && !ast.busy) { await tapSel(actor, '#roll-btn'); actions++; await sleep(200); continue; }
if (ast.choices && ast.choices.length > 1 && !ast.busy) {
const pick = chooseMove(gameFrom(ast), cur, ast.dice, 'hard') || ast.choices[0];
await tapToken(actor, ast, pick); actions++; moves++; await sleep(200); continue;
}
if (ast.rolled) moves += 0;
await sleep(120);
}
// Final settle: once both stand still on the same step, they must agree.
const [f0, f1] = await settleBoth(pages);
ok('both boards stay identical move after move', mismatches.length === 0 && syncChecks > 5, `${syncChecks} checks, ${mismatches.length} mismatches: ${mismatches.slice(0, 2).join(' | ')}`);
ok('after play both boards agree exactly', f0 && f1 && JSON.stringify(f0.positions) === JSON.stringify(f1.positions) && f0.current === f1.current && f0.seq === f1.seq, `${JSON.stringify(f0?.positions)} vs ${JSON.stringify(f1?.positions)} seq ${f0?.seq}/${f1?.seq}`);
ok('the turn passes between the two players', turnPasses >= 2, `${turnPasses} passes, ${actions} actions`);
await pages[0].screenshot({ path: path.join(OUT, 'online-seat0-mid.png') });
await pages[1].screenshot({ path: path.join(OUT, 'online-seat1-mid.png') });
// A player goes quiet (their client stops acting): after the server's 20 s
// the other client plays that turn for them, and says so.
{
const quietSeat = 1;
await waitFor(pages[quietSeat], () => { const s = window.__ludoTest.state(); return s && s.current === s.me && s.canRoll && !s.busy ? s : null; }, { timeout: 60000, label: 'the quiet player\'s turn' });
await pages[quietSeat].evaluate(() => window.__ludoTest.setTimerScale(1000));
const before = await state(pages[0]);
const tq = Date.now();
const away = await waitFor(pages[0], () => [...document.querySelectorAll('.mu-banner')].map(b => b.textContent).join(' ').includes('لا يستجيب') || null, { timeout: 45000, every: 100, label: 'the away notice' }).catch(() => false);
ok('a quiet player is announced after the server limit', !!away, `${Math.round((Date.now() - tq) / 1000)} s`);
const moved = await waitFor(pages[0], s0 => { const s = window.__ludoTest.state(); return s && s.seq > s0 && !s.busy ? s : null; }, { timeout: 20000, label: 'the turn played for them', arg: before.seq }).catch(() => null);
ok('the other client plays the quiet player\'s turn', !!moved && moved.seq > before.seq);
await pages[quietSeat].evaluate(() => window.__ludoTest.setTimerScale(1));
const [q0, q1] = await settleBoth(pages);
ok('the quiet client catches up and both boards agree', q0 && q1 && q0.seq === q1.seq && JSON.stringify(q0.positions) === JSON.stringify(q1.positions) && q0.current === q1.current, `seq ${q0?.seq}/${q1?.seq}`);
}
// A reaction crosses the table.
await tapSel(pages[0], '#emote-btn');
await sleep(400);
await tapSel(pages[0], '.rx-pick-emote[data-key="gg"]');
const seen = await waitFor(pages[1], () => document.querySelector('.rx-bubble') ? true : null, { timeout: 9000, label: 'the reaction' }).catch(() => false);
ok('a reaction sent by one player appears on the other board', !!seen);
// One leaves; the other is told and wins.
const leaver = pages[1], stayer = pages[0];
await tapSel(leaver, '#exit-btn');
await tapSel(leaver, '#modal-confirm');
await leaver.waitForSelector('.lr-card', { visible: true, timeout: 10000 });
const lw = await leaver.$eval('.lr-ribbon', r => r.textContent.trim());
ok('the player who leaves sees that they withdrew', lw === 'انسحبت', lw);
const told = await waitFor(stayer, () => [...document.querySelectorAll('.mu-banner, .mu-toast')].map(t => t.textContent).join(' ').includes('غادر') || null, { timeout: 15000, every: 50, label: 'the leave notice' }).catch(() => false);
ok('the player still at the table is told the other left', !!told);
await stayer.waitForSelector('.lr-card', { visible: true, timeout: 20000 }).catch(() => {});
await sleep(3200);
await stayer.screenshot({ path: path.join(OUT, 'online-forfeit-win.png') });
const win = await stayer.evaluate(() => ({
ribbon: document.querySelector('.lr-ribbon')?.textContent.trim(),
sub: document.querySelector('.lr-sub')?.textContent.trim(),
coins: document.querySelector('#lr-coins')?.textContent.trim(),
}));
ok('and wins: the result says so', win.ribbon === 'فزت!' && /غادر/.test(win.sub || ''), JSON.stringify(win));
ok('the server paid the win, counted up on the card', win.coins && parseInt(win.coins.replace(/\D/g, ''), 10) > 0, JSON.stringify(win));
const rows = await sb(`ludo_matches?players=cs.${encodeURIComponent(JSON.stringify([A.id]))}&select=id,status,result,winners`);
if (Array.isArray(rows)) rows.forEach(r => matchIds.add(r.id));
const m = Array.isArray(rows) ? rows[0] : null;
ok('the match is settled once, as an abandon', m && m.status === 'completed' && m.result === 'abandon', JSON.stringify(m));
await pa.close(); await pb.close();
}
async function partTimeout() {
console.log('\n=== a player who does nothing: the clock plays for them ===');
const me = await createAccount(`${TAG} د`);
const page = await openPage(me, { label: 'timeout' });
// 15 s on the clock, shortened to about 2 s for the test; no taps at all.
await page.evaluate(() => localStorage.setItem('el3ab_test_timer', '0.14'));
await openLudoRoom(page, false);
await tapSel(page, '#btn-start');
await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the table' });
await page.evaluate(() => window.__ludoTest.forceDice([6]));
await waitFor(page, () => window.__ludoTest.state()?.canRoll, { label: 'my first turn' });
const ringBefore = await page.$eval('#ring-0', r => r.style.strokeDashoffset);
await sleep(900);
const ringMid = await page.$eval('#ring-0', r => Number(r.style.strokeDashoffset));
ok('the countdown ring drains on my plate', ringMid > 0 && ringMid < 100, `${ringBefore} -> ${ringMid}`);
const t0 = Date.now();
let log = [];
while (Date.now() - t0 < 12000 && !log.some(e => e.t === 'move' && e.p === 0)) {
log = log.concat(await drain(page));
await sleep(150);
}
const roll = log.find(e => e.t === 'roll' && e.p === 0);
ok('when the clock runs out the die is thrown for the player', !!roll, JSON.stringify(log.slice(0, 3)));
ok('and a token is moved for them', log.some(e => e.t === 'move' && e.p === 0 && e.move.type === 'enter'), JSON.stringify(log.slice(0, 4)));
await page.evaluate(() => localStorage.removeItem('el3ab_test_timer'));
await page.close();
}
async function partLayout() {
console.log('\n=== layout ===');
const me = await createAccount(`${TAG} ج`);
for (const [w, h] of [[360, 640], [390, 844], [430, 932]]) {
const page = await openPage(me, { w, h, label: `layout-${w}` });
await openLudoRoom(page, false);
await tapSel(page, '#btn-start');
await waitFor(page, () => window.__ludoTest?.active && window.__ludoTest.state(), { label: 'the table' });
await sleep(1200);
const m = await page.evaluate(() => {
const r = s => { const e = document.querySelector(s); if (!e) return null; const b = e.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; };
const plates = [...document.querySelectorAll('.pp')].map(p => { const b = p.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom }; });
return { vw: innerWidth, vh: innerHeight, sw: document.documentElement.scrollWidth, board: r('.ld-board'), canvas: r('#ld-canvas'), roll: r('#roll-btn'), die: r('#dice-box'), controls: r('.ld-controls'), plates };
});
const inside = b => b && b.l >= -0.5 && b.t >= -0.5 && b.r <= m.vw + 0.5 && b.b <= m.vh + 0.5;
ok(`${w}x${h}: nothing scrolls sideways`, m.sw <= m.vw);
ok(`${w}x${h}: board, plates, die and roll button all on screen`, inside(m.board) && m.plates.every(inside) && inside(m.roll) && inside(m.die), JSON.stringify(m));
ok(`${w}x${h}: the board is as large as the width allows`, m.board.w >= m.vw - 30, `board ${Math.round(m.board.w)} of ${m.vw}`);
ok(`${w}x${h}: plates do not cover the board`, m.plates.every(p => p.b <= m.board.t + 1 || p.t >= m.board.b - 1));
ok(`${w}x${h}: the roll button is a real tap target`, m.roll.h >= 44);
await page.screenshot({ path: path.join(OUT, `table-${w}x${h}.png`) });
if (w === 390) {
// A token in mid hop, for the record.
await page.evaluate(() => window.__ludoTest.forceDice([6, 5]));
await tapSel(page, '#roll-btn');
await sleep(1700);
await tapSel(page, '#roll-btn').catch(() => {});
await sleep(420);
await page.screenshot({ path: path.join(OUT, `table-hop-${w}x${h}.png`) });
// Resize mid game: the board follows.
await page.setViewport({ width: 360, height: 700, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
await sleep(900);
const after = await page.evaluate(() => { const b = document.querySelector('.ld-board').getBoundingClientRect(); return { w: b.width, r: b.right, vw: innerWidth, b: b.bottom, vh: innerHeight }; });
ok('after a resize the board refits the screen', after.r <= after.vw + 0.5 && after.w >= after.vw - 30, JSON.stringify(after));
await page.screenshot({ path: path.join(OUT, `table-resized-360x700.png`) });
}
// The result at this size: leave, and check the card and buttons fit.
await page.setViewport({ width: w, height: h, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
await sleep(400);
await tapSel(page, '#exit-btn');
await tapSel(page, '#modal-confirm');
await page.waitForSelector('.lr-card', { visible: true, timeout: 10000 });
await sleep(2200);
const rm = await page.evaluate(() => {
const r = s => { const b = document.querySelector(s).getBoundingClientRect(); return { l: b.left, r: b.right, t: b.top, b: b.bottom }; };
// Nothing (the tab bar, its crown) may sit on top of the two buttons.
const free = s => { const e = document.querySelector(s); const b = e.getBoundingClientRect(); const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); return !!hit && e.contains(hit); };
return { vw: innerWidth, sw: document.documentElement.scrollWidth, card: r('.lr-card'), again: r('#btn-again'), back: r('#btn-back'), freeAgain: free('#btn-again'), freeBack: free('#btn-back') };
});
ok(`${w}x${h}: the result card and both buttons fit the width`, rm.sw <= rm.vw && [rm.card, rm.again, rm.back].every(b => b.l >= 0 && b.r <= rm.vw + 0.5), JSON.stringify(rm));
ok(`${w}x${h}: both result buttons are on screen and uncovered`, rm.freeAgain && rm.freeBack, JSON.stringify(rm));
await page.screenshot({ path: path.join(OUT, `result-withdrew-${w}x${h}.png`) });
await page.close();
}
}
// ── Run ─────────────────────────────────────────────────────────────────────
try {
browser = await puppeteer.launch({ executablePath: CHROME, headless: HEADFUL ? false : 'new', args: ['--no-sandbox', '--font-render-hinting=none', '--autoplay-policy=no-user-gesture-required'] });
for (const [name, fn] of [['local4', partLocal4], ['local2', partLocal2], ['timeout', partTimeout], ['online', partOnline], ['layout', partLayout]]) {
if (!ONLY.includes(name)) continue;
try { await fn(); } catch (e) { ok(`${name} ran to completion`, false, e.stack?.split('\n').slice(0, 3).join(' ')); }
}
console.log('\n=== console ===');
ok('zero console errors from ludo', errors.length === 0, [...new Set(errors)].slice(0, 10).join('\n '));
if (otherErrors.length) console.log(' (not ludo, from other modules: ' + [...new Set(otherErrors)].slice(0, 4).join(' | ') + ')');
} finally {
try { await browser?.close(); } catch (e) {}
await teardown();
console.log(`\n${passed} passed, ${failures.length} failed${failures.length ? ':\n - ' + failures.join('\n - ') : ''}`);
console.log(`screenshots: ${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