Commit 95acecec authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(chess): live games, clocks, refresh, puzzles; Night Arcade board

Live games lost their move history (each opponent move arrived as a bare
FEN), so threefold repetition never triggered and the PGN broke; moves now
replay from the server's list. The mating move was sent after the game
closed and never reached the opponent. A refreshed tab overwrote the
server's move list with a short one; it now rebuilds from the server.
The opponent got the increment twice, Black's clock started before the
first move, and a flag is now confirmed with the server. Refreshing a bot
game lost it; the bot accepted half of all draw offers at random and froze
on an illegal reply. Promotion has a cancellable picker. The result card
no longer invents rating changes for bot games, rematch after a live game
searches at the same clock, and back to table no longer stacks a home.
Analysis kept the evaluation sign and orientation for Black; review runs
one engine call per position and caches. Puzzles accept an alternative
mate and offer retry / show answer.

Board: flat lavender in an ink frame, pieces on a lip, tokens only.
tests/play/chess.mjs: 183/183 (bot, online with two accounts, puzzles,
three phone sizes), zero console errors.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent e4ccd12e
import { createCanvas, clear, getCanvasCoords } from '../../../core/canvas.js'; // The chess board, Night Arcade: a flat board of lavender slate squares in an
// ink frame on a solid lip. No texture, no bevel.
//
// The frame is CSS. The 64 squares, the pieces and every motion are on one
// canvas: flat squares cached once per size; pieces are the uploaded art (or
// the drawn fallback, never a typed glyph) pre-rendered to sprites at the
// device's pixel size so they stay crisp, each standing on a darker lip of its
// own shape; moves slide, lift and land with a squash; captures fade out under
// the piece that takes them; a king in check pulses red.
//
// Colours are tokens read through getColor() (admin-editable); the hex values
// are only the last fallback.
import { getAsset, getColor } from '../../../core/theme.js'; import { getAsset, getColor } from '../../../core/theme.js';
// The board is printed on card stock in the deck's two inks. Every colour is a const reduced = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; } };
// token (admin-editable through theme.js); the hex values are only the last
// fallback when neither a stored theme value nor the CSS token exists.
//
// Colours are read once and cached for a moment, not per square per frame:
// getColor() goes through getComputedStyle, and a drag or a move animation
// redraws the whole board every frame.
const PAL = {
light: '', dark: '', hlLight: '', hlDark: '', check: '',
ink: '', ink3: '', card: '', font: '',
};
let palStamp = -Infinity;
// ── Palette ────────────────────────────────────────────────────────────────
const PAL = {};
let palStamp = -Infinity;
function palette() { function palette() {
const now = performance.now(); const now = performance.now();
if (now - palStamp < 500) return PAL; if (now - palStamp < 1000 && PAL.light) return PAL;
palStamp = now; palStamp = now;
PAL.light = getColor('board_light', '#EFEADF'); PAL.light = getColor('board_light', '#E9E6F1');
PAL.dark = getColor('board_dark', '#B58863'); PAL.dark = getColor('board_dark', '#8B80AC');
PAL.hlLight = getColor('board_highlight_light', '#F3DC98'); PAL.hlLight = getColor('board_highlight_light', '#F6E58C');
PAL.hlDark = getColor('board_highlight_dark', '#C7A65A'); PAL.hlDark = getColor('board_highlight_dark', '#D3C060');
PAL.check = getColor('board_check', '#C8262C'); PAL.check = getColor('board_check', '#FF4D5E');
PAL.ink = getColor('ink', '#3A2112'); PAL.ink = getColor('ink', '#1A1722');
PAL.ink3 = getColor('ink_3', '#6F6858'); PAL.ink2 = getColor('ink_2', '#4B4558');
PAL.card = getColor('card', '#FFF6E2'); PAL.card = getColor('card', '#FFFFFF');
PAL.font = getColor('font_ar', 'Alexandria, system-ui, sans-serif'); PAL.cardEdge = getColor('card_edge', '#CFC9DA');
PAL.lipDark = getColor('back_950', '#0D0C12');
PAL.gold = getColor('gold', '#FFC531');
PAL.font = getColor('font_display', "'El3ab Display', 'Rubik', system-ui, sans-serif");
return PAL; return PAL;
} }
// Legal-move dots and capture rings are ink at low alpha: a pencil mark on the // ── Piece art ─────────────────────────────────────────────────────────────
// card, not a light.
const HINT_ALPHA = 0.22;
const CHECK_ALPHA = 0.55;
const PIECE_ASSET_MAP = { const PIECE_ASSET_MAP = {
K: 'chess_piece_wK', Q: 'chess_piece_wQ', R: 'chess_piece_wR', K: 'chess_piece_wK', Q: 'chess_piece_wQ', R: 'chess_piece_wR',
B: 'chess_piece_wB', N: 'chess_piece_wN', P: 'chess_piece_wP', B: 'chess_piece_wB', N: 'chess_piece_wN', P: 'chess_piece_wP',
...@@ -43,359 +45,615 @@ const PIECE_ASSET_MAP = { ...@@ -43,359 +45,615 @@ const PIECE_ASSET_MAP = {
}; };
const pieceImages = {}; const pieceImages = {};
let pieceImagesLoaded = false; const boards = new Set(); // every live board, repainted when art arrives
let boardInstance = null; let artVersion = 0; // bumps when an image settles: sprites rebuild
function loadPieceImages() { function loadPieceImages() {
if (pieceImagesLoaded) return;
let anyFound = false;
let pending = 0;
// Repaint once every image has settled, loaded or failed. A failed image
// falls back to the drawn piece, so the board is never left half-painted.
const settle = () => { if (--pending === 0 && boardInstance) boardInstance.draw(); };
for (const [piece, slot] of Object.entries(PIECE_ASSET_MAP)) { for (const [piece, slot] of Object.entries(PIECE_ASSET_MAP)) {
const url = getAsset(slot); const url = getAsset(slot);
if (url) { if (!url || pieceImages[piece]?.src === url) continue;
anyFound = true; const img = new Image();
if (pieceImages[piece]?.src === url) continue; img.decoding = 'async';
const img = new Image(); const settle = () => { artVersion++; boards.forEach(b => b.invalidateSprites()); };
pending++; img.onload = settle;
// draw(), not render() — there is no render() on ChessBoard, so this threw img.onerror = () => { delete pieceImages[piece]; settle(); };
// every time a themed piece finished loading and the board never repainted img.src = url;
// with the custom art. pieceImages[piece] = img;
img.onload = settle;
img.onerror = settle;
img.src = url;
pieceImages[piece] = img;
}
} }
if (anyFound) pieceImagesLoaded = true;
} }
// The drawn pieces: the fallback when no piece art is uploaded or an image function imageFor(piece) {
// fails. Canvas paths on a 45-unit grid, card stock with an ink outline for const img = pieceImages[piece];
// White, solid ink for Black. Never a typed chess glyph. return img && img.complete && img.naturalWidth > 0 ? img : null;
const PIECE_SHAPES = { }
k: kingPath, q: queenPath, r: rookPath, b: bishopPath, n: knightPath, p: pawnPath,
}; // The drawn pieces: the fallback when no art is uploaded or an image fails.
// Canvas paths on a 45-unit grid. White is white with a thin lip-coloured edge,
// Black is ink with a slightly lighter edge so it reads on the dark squares.
const PIECE_SHAPES = { k: kingPath, q: queenPath, r: rookPath, b: bishopPath, n: knightPath, p: pawnPath };
function drawPiece(ctx, x, y, s, fill, stroke, pathFn) { function drawVectorPiece(ctx, piece, x, y, s) {
const shape = PIECE_SHAPES[piece.toLowerCase()];
if (!shape) return;
const P = palette();
const white = piece !== piece.toLowerCase();
ctx.save(); ctx.save();
ctx.translate(x, y); ctx.translate(x, y);
ctx.scale(s / 45, s / 45); ctx.scale(s / 45, s / 45);
ctx.fillStyle = fill; ctx.fillStyle = white ? P.card : P.ink;
ctx.strokeStyle = stroke; ctx.strokeStyle = white ? P.cardEdge : P.ink2;
ctx.lineWidth = 1.5; ctx.lineWidth = 1.2;
pathFn(ctx); ctx.lineJoin = 'round';
ctx.lineCap = 'round';
shape(ctx);
ctx.restore(); ctx.restore();
} }
function kingPath(ctx) { function kingPath(ctx) {
ctx.beginPath(); ctx.moveTo(22.5, 11.63); ctx.lineTo(22.5, 6); ctx.moveTo(20, 8); ctx.lineTo(25, 8); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(22.5, 11.63); ctx.moveTo(22.5, 25); ctx.bezierCurveTo(22.5, 25, 27, 17.5, 25.5, 14.5); ctx.bezierCurveTo(25.5, 14.5, 24.5, 12, 22.5, 12);
ctx.lineTo(22.5, 6); ctx.bezierCurveTo(20.5, 12, 19.5, 14.5, 19.5, 14.5); ctx.bezierCurveTo(18, 17.5, 22.5, 25, 22.5, 25);
ctx.moveTo(20, 8); ctx.fill(); ctx.stroke();
ctx.lineTo(25, 8);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(22.5, 25);
ctx.bezierCurveTo(22.5, 25, 27, 17.5, 25.5, 14.5);
ctx.bezierCurveTo(25.5, 14.5, 24.5, 12, 22.5, 12);
ctx.bezierCurveTo(20.5, 12, 19.5, 14.5, 19.5, 14.5);
ctx.bezierCurveTo(18, 17.5, 22.5, 25, 22.5, 25);
ctx.fill();
ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(12.5, 37); ctx.moveTo(12.5, 37); ctx.bezierCurveTo(18, 40.5, 27, 40.5, 32.5, 37); ctx.lineTo(32.5, 30);
ctx.bezierCurveTo(18, 40.5, 27, 40.5, 32.5, 37); ctx.bezierCurveTo(32.5, 30, 41.5, 25.5, 38.5, 19.5); ctx.bezierCurveTo(34.5, 13, 25, 16, 22.5, 23.5);
ctx.lineTo(32.5, 30); ctx.lineTo(22.5, 27); ctx.lineTo(22.5, 23.5); ctx.bezierCurveTo(20, 16, 10.5, 13, 6.5, 19.5);
ctx.bezierCurveTo(32.5, 30, 41.5, 25.5, 38.5, 19.5); ctx.bezierCurveTo(3.5, 25.5, 12.5, 30, 12.5, 30); ctx.lineTo(12.5, 37);
ctx.bezierCurveTo(34.5, 13, 25, 16, 22.5, 23.5); ctx.fill(); ctx.stroke();
ctx.lineTo(22.5, 27);
ctx.lineTo(22.5, 23.5);
ctx.bezierCurveTo(20, 16, 10.5, 13, 6.5, 19.5);
ctx.bezierCurveTo(3.5, 25.5, 12.5, 30, 12.5, 30);
ctx.lineTo(12.5, 37);
ctx.fill();
ctx.stroke();
} }
function queenPath(ctx) { function queenPath(ctx) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(6, 12, 2.5, 0, Math.PI * 2); for (const [cx, cy] of [[6, 12], [14, 9], [22.5, 8], [31, 9], [39, 12]]) { ctx.moveTo(cx + 2.5, cy); ctx.arc(cx, cy, 2.5, 0, Math.PI * 2); }
ctx.arc(14, 9, 2.5, 0, Math.PI * 2);
ctx.arc(22.5, 8, 2.5, 0, Math.PI * 2);
ctx.arc(31, 9, 2.5, 0, Math.PI * 2);
ctx.arc(39, 12, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(9, 26);
ctx.bezierCurveTo(17.5, 24.5, 30, 24.5, 36, 26);
ctx.lineTo(38.5, 13.5);
ctx.lineTo(31, 25);
ctx.lineTo(30.7, 10.9);
ctx.lineTo(25.5, 24.5);
ctx.lineTo(22.5, 10);
ctx.lineTo(19.5, 24.5);
ctx.lineTo(14.3, 10.9);
ctx.lineTo(14, 25);
ctx.lineTo(6.5, 13.5);
ctx.lineTo(9, 26);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(9, 26);
ctx.bezierCurveTo(9, 28, 10.5, 31.5, 12.5, 33);
ctx.bezierCurveTo(16, 35.5, 23, 36, 23, 36);
ctx.bezierCurveTo(23, 36, 30, 35.5, 33.5, 33);
ctx.bezierCurveTo(35.5, 31.5, 36, 28, 36, 26);
ctx.bezierCurveTo(28, 24.5, 17.5, 24.5, 9, 26);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(12.5, 37);
ctx.bezierCurveTo(18, 40.5, 27, 40.5, 32.5, 37);
ctx.lineTo(32.5, 33.5);
ctx.bezierCurveTo(27, 36, 18, 36, 12.5, 33.5);
ctx.lineTo(12.5, 37);
ctx.fill();
ctx.stroke();
}
function rookPath(ctx) {
ctx.beginPath();
ctx.moveTo(9, 39);
ctx.lineTo(36, 39);
ctx.lineTo(36, 36);
ctx.lineTo(9, 36);
ctx.lineTo(9, 39);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(12, 36); ctx.moveTo(9, 26); ctx.bezierCurveTo(17.5, 24.5, 30, 24.5, 36, 26); ctx.lineTo(38.5, 13.5); ctx.lineTo(31, 25);
ctx.lineTo(12, 32); ctx.lineTo(30.7, 10.9); ctx.lineTo(25.5, 24.5); ctx.lineTo(22.5, 10); ctx.lineTo(19.5, 24.5); ctx.lineTo(14.3, 10.9);
ctx.lineTo(33, 32); ctx.lineTo(14, 25); ctx.lineTo(6.5, 13.5); ctx.lineTo(9, 26); ctx.closePath();
ctx.lineTo(33, 36);
ctx.lineTo(12, 36);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(11, 14); ctx.moveTo(9, 26); ctx.bezierCurveTo(9, 28, 10.5, 31.5, 12.5, 33); ctx.bezierCurveTo(16, 35.5, 23, 36, 23, 36);
ctx.lineTo(11, 9); ctx.bezierCurveTo(23, 36, 30, 35.5, 33.5, 33); ctx.bezierCurveTo(35.5, 31.5, 36, 28, 36, 26);
ctx.lineTo(15, 9); ctx.bezierCurveTo(28, 24.5, 17.5, 24.5, 9, 26);
ctx.lineTo(15, 11);
ctx.lineTo(20, 11);
ctx.lineTo(20, 9);
ctx.lineTo(25, 9);
ctx.lineTo(25, 11);
ctx.lineTo(30, 11);
ctx.lineTo(30, 9);
ctx.lineTo(34, 9);
ctx.lineTo(34, 14);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(14, 32); ctx.moveTo(12.5, 37); ctx.bezierCurveTo(18, 40.5, 27, 40.5, 32.5, 37); ctx.lineTo(32.5, 33.5);
ctx.lineTo(11, 14); ctx.bezierCurveTo(27, 36, 18, 36, 12.5, 33.5); ctx.lineTo(12.5, 37);
ctx.lineTo(34, 14);
ctx.lineTo(31, 32);
ctx.lineTo(14, 32);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
} }
function rookPath(ctx) {
function bishopPath(ctx) { ctx.beginPath(); ctx.moveTo(9, 39); ctx.lineTo(36, 39); ctx.lineTo(36, 36); ctx.lineTo(9, 36); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.moveTo(12, 36); ctx.lineTo(12, 32); ctx.lineTo(33, 32); ctx.lineTo(33, 36); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(9, 36); ctx.moveTo(11, 14); ctx.lineTo(11, 9); ctx.lineTo(15, 9); ctx.lineTo(15, 11); ctx.lineTo(20, 11); ctx.lineTo(20, 9);
ctx.bezierCurveTo(12.4, 35.4, 19.5, 34.1, 22.5, 34); ctx.lineTo(25, 9); ctx.lineTo(25, 11); ctx.lineTo(30, 11); ctx.lineTo(30, 9); ctx.lineTo(34, 9); ctx.lineTo(34, 14);
ctx.bezierCurveTo(25.5, 34.1, 32.6, 35.4, 36, 36); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.bezierCurveTo(36, 36, 37.7, 36.8, 39, 38); ctx.beginPath(); ctx.moveTo(14, 32); ctx.lineTo(11, 14); ctx.lineTo(34, 14); ctx.lineTo(31, 32); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.bezierCurveTo(38.1, 40.5, 14, 40.5, 6, 38); }
ctx.bezierCurveTo(7.3, 36.8, 9, 36, 9, 36); function bishopPath(ctx) {
ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(15, 32); ctx.moveTo(9, 36); ctx.bezierCurveTo(12.4, 35.4, 19.5, 34.1, 22.5, 34); ctx.bezierCurveTo(25.5, 34.1, 32.6, 35.4, 36, 36);
ctx.bezierCurveTo(17.5, 34.5, 27.5, 34.5, 30, 32); ctx.bezierCurveTo(36, 36, 37.7, 36.8, 39, 38); ctx.bezierCurveTo(38.1, 40.5, 14, 40.5, 6, 38); ctx.bezierCurveTo(7.3, 36.8, 9, 36, 9, 36);
ctx.bezierCurveTo(30.5, 30.5, 30, 30, 30, 30);
ctx.bezierCurveTo(30, 27.5, 27.5, 26, 27.5, 26);
ctx.bezierCurveTo(33, 24.5, 33.5, 14.5, 22.5, 10.5);
ctx.bezierCurveTo(11.5, 14.5, 12, 24.5, 17.5, 26);
ctx.bezierCurveTo(17.5, 26, 15, 27.5, 15, 30);
ctx.bezierCurveTo(15, 30, 14.5, 30.5, 15, 32);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.arc(22.5, 8, 2.5, 0, Math.PI * 2); ctx.moveTo(15, 32); ctx.bezierCurveTo(17.5, 34.5, 27.5, 34.5, 30, 32); ctx.bezierCurveTo(30.5, 30.5, 30, 30, 30, 30);
ctx.bezierCurveTo(30, 27.5, 27.5, 26, 27.5, 26); ctx.bezierCurveTo(33, 24.5, 33.5, 14.5, 22.5, 10.5);
ctx.bezierCurveTo(11.5, 14.5, 12, 24.5, 17.5, 26); ctx.bezierCurveTo(17.5, 26, 15, 27.5, 15, 30); ctx.bezierCurveTo(15, 30, 14.5, 30.5, 15, 32);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.arc(22.5, 8, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
} }
function knightPath(ctx) { function knightPath(ctx) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(22, 10); ctx.moveTo(22, 10); ctx.bezierCurveTo(32.5, 11, 38.5, 18, 38, 39); ctx.lineTo(15, 39); ctx.bezierCurveTo(15, 30, 25, 32.5, 23, 18);
ctx.bezierCurveTo(32.5, 11, 38.5, 18, 38, 39);
ctx.lineTo(15, 39);
ctx.bezierCurveTo(15, 30, 25, 32.5, 23, 18);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(24, 18); ctx.moveTo(24, 18); ctx.bezierCurveTo(24.38, 20.91, 18.45, 25.37, 16, 27); ctx.bezierCurveTo(13, 29, 13.18, 31.34, 11, 31);
ctx.bezierCurveTo(24.38, 20.91, 18.45, 25.37, 16, 27); ctx.bezierCurveTo(9.96, 30.06, 12.41, 27.96, 11, 28); ctx.bezierCurveTo(10, 28, 11.19, 29.23, 10, 30);
ctx.bezierCurveTo(13, 29, 13.18, 31.34, 11, 31); ctx.bezierCurveTo(9, 30, 5.97, 31.34, 8, 28); ctx.bezierCurveTo(10.91, 23.67, 12.37, 19.87, 11, 16);
ctx.bezierCurveTo(9.96, 30.06, 12.41, 27.96, 11, 28); ctx.bezierCurveTo(10, 13, 13, 7.5, 18, 7); ctx.bezierCurveTo(18, 7, 20, 7.5, 22, 10);
ctx.bezierCurveTo(10, 28, 11.19, 29.23, 10, 30);
ctx.bezierCurveTo(9, 30, 5.97, 31.34, 8, 28);
ctx.bezierCurveTo(10.91, 23.67, 12.37, 19.87, 11, 16);
ctx.bezierCurveTo(10, 13, 13, 7.5, 18, 7);
ctx.bezierCurveTo(18, 7, 20, 7.5, 22, 10);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
} }
function pawnPath(ctx) { function pawnPath(ctx) {
ctx.beginPath(); ctx.arc(22.5, 12, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.arc(22.5, 12, 4, 0, Math.PI * 2); ctx.moveTo(15.5, 26); ctx.bezierCurveTo(17, 22.5, 20, 19.5, 22.5, 18); ctx.bezierCurveTo(25, 19.5, 28, 22.5, 29.5, 26);
ctx.fill(); ctx.stroke(); ctx.bezierCurveTo(30.5, 28, 28.3, 31, 22.5, 31); ctx.bezierCurveTo(16.7, 31, 14.5, 28, 15.5, 26);
ctx.beginPath();
ctx.moveTo(15.5, 26);
ctx.bezierCurveTo(17, 22.5, 20, 19.5, 22.5, 18);
ctx.bezierCurveTo(25, 19.5, 28, 22.5, 29.5, 26);
ctx.bezierCurveTo(30.5, 28, 28.3, 31, 22.5, 31);
ctx.bezierCurveTo(16.7, 31, 14.5, 28, 15.5, 26);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(10, 39); ctx.moveTo(10, 39); ctx.bezierCurveTo(10, 36, 14, 33, 22.5, 33); ctx.bezierCurveTo(31, 33, 35, 36, 35, 39); ctx.closePath();
ctx.bezierCurveTo(10, 36, 14, 33, 22.5, 33);
ctx.bezierCurveTo(31, 33, 35, 36, 35, 39);
ctx.lineTo(10, 39);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
} }
/** Draw a piece's art (or the fallback) fitted inside an s x s box. */
function paintPiece(ctx, piece, x, y, s) {
const img = imageFor(piece);
if (img) {
const k = Math.min(s / img.naturalWidth, s / img.naturalHeight);
const dw = img.naturalWidth * k, dh = img.naturalHeight * k;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// Seated on the square: centred across, standing on the bottom of the box.
ctx.drawImage(img, x + (s - dw) / 2, y + (s - dh) * 0.62, dw, dh);
return;
}
drawVectorPiece(ctx, piece, x, y, s);
}
/**
* A piece as a DOM node (promotion picker, captured row): the uploaded art as
* an <img> at exact pixel size, or the drawn piece rendered to a data URL.
*/
export function pieceHTML(piece, size = 32, cls = '') {
loadPieceImages();
const url = getAsset(PIECE_ASSET_MAP[piece]);
const style = `width:${size}px;height:${size}px;object-fit:contain;`;
if (url) return `<img class="cb-piece ${cls}" src="${url}" width="${size}" height="${size}" style="${style}" alt="" draggable="false">`;
const c = document.createElement('canvas');
const dpr = Math.min(3, window.devicePixelRatio || 1);
c.width = c.height = Math.round(size * dpr);
const ctx = c.getContext('2d');
ctx.scale(dpr, dpr);
drawVectorPiece(ctx, piece, size * 0.04, size * 0.04, size * 0.92);
return `<img class="cb-piece ${cls}" src="${c.toDataURL()}" width="${size}" height="${size}" style="${style}" alt="" draggable="false">`;
}
// ── Frame styles, injected once ───────────────────────────────────────────
const FRAME_CSS = `
.cb-frame {
--cb-f: 8px;
position: relative;
margin: 0 auto;
padding: var(--cb-f);
border-radius: calc(var(--cb-f) + 12px);
background: var(--board-frame);
box-shadow: 0 6px 0 var(--back-950), var(--shadow-lg);
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.cb-well { position: relative; border-radius: 12px; overflow: hidden; }
.cb-well canvas { display: block; touch-action: none; }
.cb-piece { display: inline-block; flex-shrink: 0; pointer-events: none; -webkit-user-drag: none; }
`;
function ensureStyles() {
if (document.getElementById('cb-style')) return;
const s = document.createElement('style');
s.id = 'cb-style';
s.textContent = FRAME_CSS;
document.head.appendChild(s);
}
const SLIDE_MS = 200;
const DROP_SLIDE_MS = 90;
const LAND_MS = 170;
const CHECK_PULSE_MS = 1500;
const easeOut = t => 1 - Math.pow(1 - t, 3);
const easeInOut = t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export class ChessBoard { export class ChessBoard {
constructor(container, options = {}) { constructor(container, options = {}) {
this.flipped = options.flipped || false; ensureStyles();
loadPieceImages();
this.flipped = !!options.flipped;
this.interactive = options.interactive !== false;
this.onMove = options.onMove || null;
this.onPick = options.onPick || null; // a piece was picked up
this.onIllegal = options.onIllegal || null; // dropped where it cannot go
this.coordinates = options.coordinates !== false;
this.maxSize = options.maxSize || 560;
this.maxHeight = options.maxHeight || null; // () => px the board may take vertically
this.squareSize = 0; this.squareSize = 0;
this.size = 0;
this.pieces = {}; this.pieces = {};
this.highlights = { lastMove: null, selected: null, legalMoves: [], check: null }; this.highlights = { lastMove: null, selected: null, legalMoves: [], check: null };
this.dragging = null; this.dragging = null;
this.animating = null; this.hover = null;
this.onMove = options.onMove || null; this.anims = [];
this.interactive = options.interactive !== false; this._raf = 0;
this._drop = null;
this._sprites = new Map();
this._spriteVersion = -1;
this.container = container; this.container = container;
this.wrapper = document.createElement('div'); this.wrapper = document.createElement('div');
this.wrapper.style.cssText = 'position:relative;width:100%;max-width:500px;margin:0 auto;aspect-ratio:1;'; this.wrapper.className = 'cb-frame';
this.well = document.createElement('div');
this.well.className = 'cb-well';
this.wrapper.appendChild(this.well);
container.appendChild(this.wrapper); container.appendChild(this.wrapper);
boardInstance = this; this.canvas = document.createElement('canvas');
this.setupCanvas(); this.ctx = this.canvas.getContext('2d');
this.well.appendChild(this.canvas);
boards.add(this);
this.layout();
this.bindEvents(); this.bindEvents();
// The board is as large as its space allows, and follows that space.
if ('ResizeObserver' in window) {
let pending = 0;
this._ro = new ResizeObserver(() => {
cancelAnimationFrame(pending);
pending = requestAnimationFrame(() => { if (this.availableSize() !== this.size) this.resize(); });
});
this._ro.observe(options.observe || container);
}
} }
/** // ── Geometry ──────────────────────────────────────────────────────────
* The largest square that fits inside the container's CONTENT box. frameWidth(total) { return Math.round(Math.max(5, Math.min(10, total * 0.02))); }
*
* clientWidth includes padding, so sizing from it overflowed any padded /** The largest board (frame included) that fits the container's content box. */
* container by exactly the padding — which is why the puzzle board hung off
* the edge of the screen and pushed the page sideways.
*/
availableSize() { availableSize() {
const cs = getComputedStyle(this.container); const cs = getComputedStyle(this.container);
const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0); const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
const w = (this.container.clientWidth || this.wrapper.clientWidth || 360) - padX; const w = (this.container.clientWidth || 360) - padX;
const h = (this.container.clientHeight || 500) - padY; const h = this.maxHeight ? (this.maxHeight() || w) : (this.container.clientHeight || w) - padY;
return Math.max(120, Math.min(Math.floor(w), Math.floor(h), 500)); const total = Math.max(160, Math.min(Math.floor(w), Math.floor(h > 120 ? h : w), this.maxSize));
const f = this.frameWidth(total);
this._frame = f;
// Whole pixels per square, so squares never blur on their edges.
return Math.floor((total - 2 * f - 4) / 8) * 8;
} }
setupCanvas() { layout() {
const size = this.availableSize(); const size = this.availableSize();
this.squareSize = size / 8; const dpr = Math.min(3, window.devicePixelRatio || 1);
this.wrapper.style.maxWidth = size + 'px';
const { canvas, ctx } = createCanvas(this.wrapper, size, size);
this.canvas = canvas;
this.ctx = ctx;
this.size = size; this.size = size;
this.dpr = dpr;
this.squareSize = size / 8;
const f = this._frame;
this.wrapper.style.setProperty('--cb-f', f + 'px');
this.wrapper.style.width = (size + 2 * f) + 'px';
this.canvas.width = Math.round(size * dpr);
this.canvas.height = Math.round(size * dpr);
this.canvas.style.width = size + 'px'; this.canvas.style.width = size + 'px';
this.canvas.style.height = size + 'px'; this.canvas.style.height = size + 'px';
this.canvas.style.display = 'block'; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Printed on card stock: a thin dark frame edge and the shadow of a card this._layer = null;
// lying on the table. The outline sits outside the canvas, so it never this.invalidateSprites();
// changes the board's geometry or its hit-testing.
this.canvas.style.borderRadius = '3px';
this.canvas.style.outline = '3px solid var(--board-frame)';
this.canvas.style.boxShadow = 'var(--shadow-card)';
this.canvas.style.touchAction = 'none';
} }
bindEvents() { resize() { this.layout(); this.draw(); }
if (!this.interactive) return; // Kept for callers of the old API.
this.canvas.addEventListener('pointerdown', (e) => this.onPointerDown(e)); setupCanvas() { this.layout(); }
this.canvas.addEventListener('pointermove', (e) => this.onPointerMove(e));
this.canvas.addEventListener('pointerup', (e) => this.onPointerUp(e)); squareToXY(square) {
const file = square.charCodeAt(0) - 97;
const rank = parseInt(square[1], 10) - 1;
const c = this.flipped ? 7 - file : file;
const r = this.flipped ? rank : 7 - rank;
return { x: c * this.squareSize, y: r * this.squareSize };
} }
setPosition(fenStr) { xyToSquare(x, y) {
this.pieces = {}; const c = Math.floor(x / this.squareSize);
const fenBoard = fenStr.split(' ')[0]; const r = Math.floor(y / this.squareSize);
const rows = fenBoard.split('/'); if (c < 0 || c > 7 || r < 0 || r > 7) return null;
for (let r = 0; r < 8; r++) { const file = this.flipped ? 7 - c : c;
const rank = this.flipped ? r : 7 - r;
return String.fromCharCode(97 + file) + String(rank + 1);
}
/** Centre of a square in client (viewport) coordinates. */
squareCenterClient(square) {
const r = this.canvas.getBoundingClientRect();
const { x, y } = this.squareToXY(square);
const k = r.width / this.size;
return { x: r.left + (x + this.squareSize / 2) * k, y: r.top + (y + this.squareSize / 2) * k };
}
pointerXY(e) {
const r = this.canvas.getBoundingClientRect();
const k = this.size / (r.width || this.size);
return { x: (e.clientX - r.left) * k, y: (e.clientY - r.top) * k };
}
// ── Position ──────────────────────────────────────────────────────────
parseFen(fenStr) {
const pieces = {};
const rows = String(fenStr || '').split(' ')[0].split('/');
for (let r = 0; r < 8 && r < rows.length; r++) {
let col = 0; let col = 0;
for (const ch of rows[r]) { for (const ch of rows[r]) {
if (ch >= '1' && ch <= '8') { col += parseInt(ch); } if (ch >= '1' && ch <= '8') col += parseInt(ch, 10);
else { else { pieces[String.fromCharCode(97 + col) + String(8 - r)] = ch; col++; }
const file = String.fromCharCode(97 + col);
const rank = String(8 - r);
this.pieces[file + rank] = ch;
col++;
}
} }
} }
return pieces;
}
setPosition(fenStr) {
this.settleSlides();
this.pieces = this.parseFen(fenStr);
this.anims = this.anims.filter(a => a.type === 'check' || a.type === 'ripple'
|| (a.type === 'land' && this.pieces[a.sq] === a.piece));
this.draw(); this.draw();
} }
draw() { /** Land any piece still in flight now, so its touchdown callback still fires. */
settleSlides() {
for (const a of this.anims.filter(x => x.type === 'slide')) {
this.anims.splice(this.anims.indexOf(a), 1);
this.finishSlide(a);
}
}
/**
* Play a move (a chess.js verbose move) from the position on the board to
* `fenAfter`: the piece slides (and the rook with it when castling), lifts a
* little in flight and lands with a squash; a captured piece fades under it;
* a promoted pawn lands as its new piece. `onLand` fires on touchdown, which
* is where the sound belongs.
*/
playMove(m, fenAfter, { onLand, duration } = {}) {
this.settleSlides();
const before = this.pieces;
this.pieces = this.parseFen(fenAfter);
this.highlights.lastMove = { from: m.from, to: m.to };
this.highlights.selected = null;
this.highlights.legalMoves = [];
this.anims = this.anims.filter(a => a.type === 'check' || a.type === 'ripple');
const movedPiece = before[m.from] || (m.color === 'w' ? m.piece.toUpperCase() : m.piece);
const flags = m.flags || '';
let fromXY = this.squareToXY(m.from);
let dur = duration ?? SLIDE_MS;
// A piece the player dropped is already at the target: it only settles in.
if (this._drop && this._drop.sq === m.to && performance.now() - this._drop.at < 600) {
fromXY = { x: this._drop.x - this.squareSize / 2, y: this._drop.y - this.squareSize / 2 };
dur = DROP_SLIDE_MS;
}
this._drop = null;
if (reduced()) dur = 0;
const sliders = [{ piece: movedPiece, from: fromXY, to: m.to, landAs: this.pieces[m.to] || movedPiece }];
if (flags.includes('k') || flags.includes('q')) {
const rank = m.color === 'w' ? '1' : '8';
const [rf, rt] = flags.includes('k') ? ['h', 'f'] : ['a', 'd'];
const rook = before[rf + rank];
if (rook) sliders.push({ piece: rook, from: this.squareToXY(rf + rank), to: rt + rank, landAs: rook, delay: 0.25 });
}
let captured = null;
if (flags.includes('e')) {
const sq = m.to[0] + m.from[1];
captured = { piece: before[sq], sq };
} else if (m.captured && before[m.to]) {
captured = { piece: before[m.to], sq: m.to };
}
const anim = { type: 'slide', t0: performance.now(), dur, sliders, captured, promo: !!m.promotion, onLand };
if (dur === 0) { this.finishSlide(anim); this.draw(); return; }
this.anims.push(anim);
this.kick();
}
/** The old API: slide whatever stands on `from` to `to`, then call back. */
animateMove(from, to, callback) {
this.settleSlides();
const piece = this.pieces[from];
if (!piece) { callback?.(); return; }
const next = { ...this.pieces };
delete next[from];
next[to] = piece;
const anim = {
type: 'slide', t0: performance.now(), dur: reduced() ? 0 : SLIDE_MS,
sliders: [{ piece, from: this.squareToXY(from), to, landAs: piece }],
captured: this.pieces[to] ? { piece: this.pieces[to], sq: to } : null,
onLand: callback,
};
this.pieces = next;
if (!anim.dur) { this.finishSlide(anim); return; }
this.anims.push(anim);
this.kick();
}
finishSlide(a) {
for (const s of a.sliders) {
this.anims.push({ type: 'land', t0: performance.now(), dur: reduced() ? 0 : LAND_MS, sq: s.to, piece: s.landAs, pop: a.promo && s === a.sliders[0] });
if (s === a.sliders[0] && !reduced()) this.anims.push({ type: 'ripple', t0: performance.now(), dur: 420, sq: s.to, strong: !!a.captured });
}
try { a.onLand?.(); } catch (e) { console.warn('[board] onLand', e); }
}
isAnimating() { return this.anims.some(a => a.type === 'slide' || a.type === 'land'); }
setLastMove(from, to) { this.highlights.lastMove = from && to ? { from, to } : null; this.draw(); }
setCheck(square) {
const was = this.highlights.check;
this.highlights.check = square || null;
this.anims = this.anims.filter(a => a.type !== 'check');
if (square && square !== was && !reduced()) {
this.anims.push({ type: 'check', t0: performance.now(), dur: CHECK_PULSE_MS, sq: square });
this.kick();
} else this.draw();
}
clearHighlights() {
this.highlights = { lastMove: null, selected: null, legalMoves: [], check: null };
this.draw();
}
flip() {
this.flipped = !this.flipped;
this.draw();
}
invalidateSprites() {
this._sprites.clear();
this._spriteVersion = artVersion;
if (this.ctx) this.draw();
}
// ── Animation loop ────────────────────────────────────────────────────
kick() {
if (this._raf) return;
const step = (now) => {
this._raf = 0;
let live = false;
for (const a of [...this.anims]) {
const t = a.dur ? (now - a.t0) / a.dur : 1;
if (t >= 1) {
this.anims.splice(this.anims.indexOf(a), 1);
if (a.type === 'slide') this.finishSlide(a);
}
}
if (this.anims.length) live = true;
this.draw(now);
if (live) this._raf = requestAnimationFrame(step);
};
this._raf = requestAnimationFrame(step);
}
// ── Painting ──────────────────────────────────────────────────────────
/** The 64 flat squares, cached per size. */
squaresLayer() {
if (this._layer && this._layer.flipped === this.flipped) return this._layer.canvas;
const P = palette();
const px = Math.round(this.size * this.dpr);
const c = document.createElement('canvas');
c.width = c.height = px;
const g = c.getContext('2d');
const sq = px / 8;
g.fillStyle = P.light;
g.fillRect(0, 0, px, px);
g.fillStyle = P.dark;
for (let r = 0; r < 8; r++) {
for (let col = 0; col < 8; col++) {
if ((r + col) % 2 === 1) g.fillRect(Math.round(col * sq), Math.round(r * sq), Math.round((col + 1) * sq) - Math.round(col * sq), Math.round((r + 1) * sq) - Math.round(r * sq));
}
}
this._layer = { canvas: c, flipped: this.flipped };
return c;
}
/** A piece pre-rendered at the board's pixel size, on a lip of its own shape. */
sprite(piece, cssSize) {
if (this._spriteVersion !== artVersion) { this._sprites.clear(); this._spriteVersion = artVersion; }
const px = Math.max(8, Math.round(cssSize * this.dpr));
const key = piece + '@' + px;
let s = this._sprites.get(key);
if (s) return s;
const P = palette();
const white = piece !== piece.toLowerCase();
// The silhouette, tinted, sits a few pixels below the piece: the lip.
const shape = document.createElement('canvas');
shape.width = shape.height = px;
const sg = shape.getContext('2d');
paintPiece(sg, piece, px * 0.06, px * 0.03, px * 0.88);
sg.globalCompositeOperation = 'source-in';
sg.fillStyle = white ? P.cardEdge : P.lipDark;
sg.fillRect(0, 0, px, px);
s = document.createElement('canvas');
s.width = s.height = px;
const g = s.getContext('2d');
g.drawImage(shape, 0, Math.max(1, Math.round(px * 0.035)));
paintPiece(g, piece, px * 0.06, px * 0.03, px * 0.88);
this._sprites.set(key, s);
return s;
}
drawPieceAt(ctx, piece, x, y, s) {
ctx.drawImage(this.sprite(piece, s), x, y, s, s);
}
draw(now = performance.now()) {
const ctx = this.ctx; const ctx = this.ctx;
if (!ctx || !this.size) return;
const sq = this.squareSize; const sq = this.squareSize;
const P = palette(); const P = palette();
const hl = this.highlights; const hl = this.highlights;
clear(ctx, this.size, this.size);
// Squares, with last-move and selection printed in the highlight inks. ctx.save();
for (let r = 0; r < 8; r++) { ctx.setTransform(1, 0, 0, 1, 0, 0);
for (let c = 0; c < 8; c++) { ctx.drawImage(this.squaresLayer(), 0, 0);
const x = c * sq; ctx.restore();
const y = r * sq;
const isLight = (r + c) % 2 === 0;
const file = this.flipped ? 7 - c : c;
const rank = this.flipped ? r : 7 - r;
const square = String.fromCharCode(97 + file) + String(rank + 1);
let color = isLight ? P.light : P.dark;
if ((hl.lastMove && (square === hl.lastMove.from || square === hl.lastMove.to)) || hl.selected === square) {
color = isLight ? P.hlLight : P.hlDark;
}
ctx.fillStyle = color; const tint = (square, color, alpha) => {
ctx.fillRect(x, y, sq, sq); const { x, y } = this.squareToXY(square);
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.fillRect(x, y, sq, sq);
ctx.globalAlpha = 1;
};
const isLight = (square) => ((square.charCodeAt(0) - 97) + (parseInt(square[1], 10) - 1)) % 2 === 1;
if (hl.check === square) { // Last move: both squares washed in the highlight ink.
ctx.globalAlpha = CHECK_ALPHA; if (hl.lastMove) {
ctx.fillStyle = P.check; for (const s of [hl.lastMove.from, hl.lastMove.to]) tint(s, isLight(s) ? P.hlLight : P.hlDark, 0.9);
ctx.fillRect(x, y, sq, sq); }
ctx.globalAlpha = 1; // The picked-up piece's square glows gold.
} if (hl.selected) {
} tint(hl.selected, P.gold, 0.42);
const { x, y } = this.squareToXY(hl.selected);
ctx.strokeStyle = P.card;
ctx.lineWidth = Math.max(2, sq * 0.05);
ctx.strokeRect(x + ctx.lineWidth / 2, y + ctx.lineWidth / 2, sq - ctx.lineWidth, sq - ctx.lineWidth);
}
// Check: the king's square burns red, pulsing when it first happens.
if (hl.check) {
const a = this.anims.find(x => x.type === 'check');
let k = 1;
if (a) { const t = (now - a.t0) / a.dur; k = 0.65 + 0.35 * Math.abs(Math.sin(t * Math.PI * 3)); }
const { x, y } = this.squareToXY(hl.check);
// A flat red square that beats when the check is new.
ctx.globalAlpha = 0.85 * k;
ctx.fillStyle = P.check;
ctx.fillRect(x, y, sq, sq);
ctx.globalAlpha = 1;
}
// Where the dragged piece would land.
if (this.dragging && this.hover && this.hover !== this.dragging.square) {
const legal = hl.legalMoves.some(m => m.to === this.hover);
const { x, y } = this.squareToXY(this.hover);
ctx.strokeStyle = legal ? P.card : P.ink;
ctx.globalAlpha = legal ? 0.95 : 0.3;
ctx.lineWidth = Math.max(2, sq * 0.06);
ctx.strokeRect(x + ctx.lineWidth / 2, y + ctx.lineWidth / 2, sq - ctx.lineWidth, sq - ctx.lineWidth);
ctx.globalAlpha = 1;
} }
// Legal moves: a dot on an empty square, a ring around a capture. if (this.coordinates) this.drawCoordinates(ctx, P);
// Squares a sliding piece is heading for are drawn by the slide itself.
const hidden = new Set();
for (const a of this.anims) {
if (a.type === 'slide') a.sliders.forEach(s => hidden.add(s.to));
if (a.type === 'land') hidden.add(a.sq);
}
if (this.dragging) hidden.add(this.dragging.square);
const box = sq;
for (const [square, piece] of Object.entries(this.pieces)) {
if (hidden.has(square)) continue;
const { x, y } = this.squareToXY(square);
this.drawPieceAt(ctx, piece, x, y, box);
}
// Legal moves sit over the pieces they would take: a dot on an empty
// square, a ring of corner notches around a capture.
if (hl.legalMoves.length) { if (hl.legalMoves.length) {
ctx.globalAlpha = HINT_ALPHA;
ctx.fillStyle = P.ink; ctx.fillStyle = P.ink;
ctx.strokeStyle = P.ink; ctx.globalAlpha = 0.26;
ctx.lineWidth = sq * 0.08;
for (const move of hl.legalMoves) { for (const move of hl.legalMoves) {
const { x, y } = this.squareToXY(move.to); const { x, y } = this.squareToXY(move.to);
ctx.beginPath();
if (this.pieces[move.to]) { if (this.pieces[move.to]) {
ctx.arc(x + sq / 2, y + sq / 2, sq * 0.44, 0, Math.PI * 2); ctx.save();
ctx.stroke(); ctx.beginPath();
ctx.rect(x, y, sq, sq);
ctx.arc(x + sq / 2, y + sq / 2, sq * 0.5, 0, Math.PI * 2, true);
ctx.fill('evenodd');
ctx.restore();
} else { } else {
ctx.beginPath();
ctx.arc(x + sq / 2, y + sq / 2, sq * 0.15, 0, Math.PI * 2); ctx.arc(x + sq / 2, y + sq / 2, sq * 0.15, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} }
...@@ -403,228 +661,203 @@ export class ChessBoard { ...@@ -403,228 +661,203 @@ export class ChessBoard {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
// Draw pieces for (const a of this.anims) {
loadPieceImages(); const t = Math.min(1, Math.max(0, a.dur ? (now - a.t0) / a.dur : 1));
const pad = sq * 0.1; if (a.type === 'slide') {
for (const [square, piece] of Object.entries(this.pieces)) { if (a.captured?.piece) {
if (this.dragging && this.dragging.square === square) continue; // The taken piece shrinks and fades as the attacker arrives.
if (this.animating && this.animating.square === square) continue; const k = Math.max(0, 1 - Math.max(0, t - 0.45) / 0.55);
const { x, y } = this.squareToXY(square); const { x, y } = this.squareToXY(a.captured.sq);
this.drawPieceAt(ctx, piece, x + pad, y + pad, sq - pad * 2); const s = box * (0.7 + 0.3 * k);
ctx.globalAlpha = k;
this.drawPieceAt(ctx, a.captured.piece, x + (box - s) / 2, y + (box - s) / 2, s);
ctx.globalAlpha = 1;
}
for (const s of a.sliders) {
const local = s.delay ? Math.max(0, (t - s.delay) / (1 - s.delay)) : t;
const e = easeInOut(local);
const to = this.squareToXY(s.to);
const x = s.from.x + (to.x - s.from.x) * e;
const y = s.from.y + (to.y - s.from.y) * e;
const lift = Math.sin(local * Math.PI);
const sc = 1 + 0.12 * lift;
const size = box * sc;
// Its shadow stays on the board while the piece lifts.
ctx.globalAlpha = 0.2 * lift;
ctx.fillStyle = P.ink;
ctx.beginPath();
ctx.ellipse(x + box / 2, y + box * 0.86, box * 0.32, box * 0.1, 0, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
this.drawPieceAt(ctx, s.piece, x + (box - size) / 2, y + (box - size) / 2 - box * 0.08 * lift, size);
}
}
} }
// Draw animating piece with squish for (const a of this.anims) {
if (this.animating) { const t = Math.min(1, Math.max(0, a.dur ? (now - a.t0) / a.dur : 1));
const { piece, x, y, scale } = this.animating; if (a.type === 'land') {
const sc = scale || 1; // Touchdown: squashes flat, springs back. A promoted piece pops.
const s = (sq - pad * 2) * sc; const { x, y } = this.squareToXY(a.sq);
const offset = ((sq - pad * 2) - s) / 2; const sx = a.pop ? 1 + 0.3 * (1 - easeOut(t)) : 1 + 0.1 * Math.sin(t * Math.PI) * (1 - t);
this.drawPieceAt(ctx, piece, x + pad + offset, y + pad + offset, s); const sy = a.pop ? sx : 1 - 0.12 * Math.sin(t * Math.PI) * (1 - t * 0.5);
const w = box * sx, h = box * sy;
ctx.save();
ctx.translate(x + box / 2, y + box);
ctx.drawImage(this.sprite(a.piece, box), -w / 2, -h, w, h);
ctx.restore();
} else if (a.type === 'ripple') {
const { x, y } = this.squareToXY(a.sq);
ctx.globalAlpha = (1 - t) * (a.strong ? 0.75 : 0.5);
ctx.strokeStyle = a.strong ? P.check : P.card;
ctx.lineWidth = Math.max(1.5, sq * 0.05) * (1 - t);
ctx.beginPath();
ctx.arc(x + sq / 2, y + sq / 2, sq * (0.35 + 0.45 * easeOut(t)), 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
} }
// Draw dragging piece // The dragged piece rides above everything, a little larger, with a
if (this.dragging) { // shadow under it on the board.
const ds = sq * 1.1; if (this.dragging && this.dragging.moved) {
this.drawPieceAt(ctx, this.dragging.piece, this.dragging.x - ds / 2, this.dragging.y - ds / 2, ds); const ds = box * 1.18;
const { x, y } = this.dragging;
ctx.globalAlpha = 0.24;
ctx.fillStyle = P.ink;
ctx.beginPath();
ctx.ellipse(x, y + box * 0.42, box * 0.36, box * 0.11, 0, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
this.drawPieceAt(ctx, this.dragging.piece, x - ds / 2, y - ds * 0.62, ds);
} else if (this.dragging) {
const { x, y } = this.squareToXY(this.dragging.square);
const ds = box * 1.08;
this.drawPieceAt(ctx, this.dragging.piece, x - (ds - box) / 2, y - (ds - box) / 2 - box * 0.05, ds);
} }
}
// Coordinates, printed small in the corner of the edge squares: ranks down drawCoordinates(ctx, P) {
// the left edge, files along the bottom. Ink on light squares, card stock on const sq = this.squareSize;
// dark ones, so both read. The board is never mirrored for RTL: a1 is always const fs = Math.max(11, Math.round(sq * 0.24));
// bottom-left from White's side, h8 from Black's. ctx.font = `${fs}px ${P.font}`;
const fs = Math.max(11, Math.round(sq * 0.2));
if (this._coordFs !== fs || this._coordFamily !== P.font) {
this._coordFs = fs;
this._coordFamily = P.font;
this._coordFont = `700 ${fs}px ${P.font}`;
}
ctx.font = this._coordFont;
ctx.direction = 'ltr'; ctx.direction = 'ltr';
const inset = Math.max(2, sq * 0.06); const inset = Math.max(2, sq * 0.06);
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
// Rank label in column 0, row i. // Ranks down the left edge, files along the bottom; a1 is always the
// bottom-left from White's side. Never mirrored for RTL.
const rank = this.flipped ? i + 1 : 8 - i; const rank = this.flipped ? i + 1 : 8 - i;
ctx.fillStyle = (i % 2 === 0) ? P.ink3 : P.card; // Each square prints its label in the other square colour.
ctx.fillStyle = (i % 2 === 0) ? P.dark : P.light;
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.fillText(String(rank), inset, i * sq + inset); ctx.fillText(String(rank), inset, i * sq + inset * 0.6);
// File label in row 7, column i.
const file = this.flipped ? 7 - i : i; const file = this.flipped ? 7 - i : i;
ctx.fillStyle = ((7 + i) % 2 === 0) ? P.ink3 : P.card; ctx.fillStyle = ((7 + i) % 2 === 0) ? P.dark : P.light;
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'bottom'; ctx.textBaseline = 'bottom';
ctx.fillText(String.fromCharCode(97 + file), (i + 1) * sq - inset, this.size - inset * 0.6); ctx.fillText(String.fromCharCode(97 + file), (i + 1) * sq - inset, this.size - inset * 0.3);
} }
} }
squareToXY(square) { // ── Input ────────────────────────────────────────────────────────────
const file = square.charCodeAt(0) - 97; bindEvents() {
const rank = parseInt(square[1]) - 1; this.canvas.addEventListener('pointerdown', (e) => this.onPointerDown(e));
const c = this.flipped ? 7 - file : file; this.canvas.addEventListener('pointermove', (e) => this.onPointerMove(e));
const r = this.flipped ? rank : 7 - rank; this.canvas.addEventListener('pointerup', (e) => this.onPointerUp(e));
return { x: c * this.squareSize, y: r * this.squareSize }; this.canvas.addEventListener('pointercancel', () => this.cancelDrag());
} this.canvas.addEventListener('lostpointercapture', () => { if (this.dragging) this.onPointerUp(null); });
drawPieceAt(ctx, piece, x, y, s) {
const img = pieceImages[piece];
if (img && img.complete && img.naturalWidth > 0) {
const iw = img.naturalWidth;
const ih = img.naturalHeight;
const scale = Math.min(s / iw, s / ih);
const dw = iw * scale;
const dh = ih * scale;
ctx.drawImage(img, x + (s - dw) / 2, y + (s - dh) / 2, dw, dh);
return;
}
const shape = PIECE_SHAPES[piece.toLowerCase()];
if (!shape) return;
const P = palette();
const white = piece !== piece.toLowerCase();
drawPiece(ctx, x, y, s, white ? P.card : P.ink, P.ink, shape);
}
xyToSquare(x, y) {
const c = Math.floor(x / this.squareSize);
const r = Math.floor(y / this.squareSize);
const file = this.flipped ? 7 - c : c;
const rank = this.flipped ? r : 7 - r;
if (file < 0 || file > 7 || rank < 0 || rank > 7) return null;
return String.fromCharCode(97 + file) + String(rank + 1);
} }
onPointerDown(e) { onPointerDown(e) {
if (!this.interactive) return; if (!this.interactive || e.button > 0) return;
const { x, y } = getCanvasCoords(this.canvas, e); const { x, y } = this.pointerXY(e);
const square = this.xyToSquare(x, y); const square = this.xyToSquare(x, y);
if (!square) return; if (!square) return;
const piece = this.pieces[square]; const piece = this.pieces[square];
if (piece && this.canSelect(piece)) {
this.dragging = { square, piece, x, y }; if (piece && this.canSelect(piece) && !(this.highlights.selected && this.highlights.legalMoves.some(m => m.to === square))) {
const reselect = this.highlights.selected === square;
this.highlights.selected = square; this.highlights.selected = square;
this.showLegalMoves(square); this.showLegalMoves(square);
this.canvas.setPointerCapture(e.pointerId); this.dragging = { square, piece, x, y, sx: x, sy: y, moved: false, reselect };
this.draw(); this.hover = square;
try { this.canvas.setPointerCapture(e.pointerId); } catch (err) {}
if (!reselect) this.onPick?.(square);
this.kick();
} else if (this.highlights.selected) { } else if (this.highlights.selected) {
this.tryMove(this.highlights.selected, square); this.tryMove(this.highlights.selected, square, null);
} }
} }
onPointerMove(e) { onPointerMove(e) {
if (!this.dragging) return; if (!this.dragging) return;
const { x, y } = getCanvasCoords(this.canvas, e); const { x, y } = this.pointerXY(e);
this.dragging.x = x; this.dragging.x = x;
this.dragging.y = y; this.dragging.y = y;
if (!this.dragging.moved && Math.hypot(x - this.dragging.sx, y - this.dragging.sy) > this.squareSize * 0.18) this.dragging.moved = true;
this.hover = this.xyToSquare(x, y);
this.draw(); this.draw();
} }
onPointerUp(e) { onPointerUp(e) {
if (!this.dragging) return; if (!this.dragging) return;
const { x, y } = getCanvasCoords(this.canvas, e); const d = this.dragging;
const { x, y } = e ? this.pointerXY(e) : d;
const square = this.xyToSquare(x, y); const square = this.xyToSquare(x, y);
const from = this.dragging.square;
this.dragging = null; this.dragging = null;
this.hover = null;
if (square && square !== from) {
this.tryMove(from, square); if (square && square !== d.square) {
this._drop = d.moved ? { sq: square, x, y, at: performance.now() } : null;
this.tryMove(d.square, square, d.moved ? { x, y } : null);
} else if (d.reselect && !d.moved) {
// Tapping the chosen piece again puts it down.
this.highlights.selected = null;
this.highlights.legalMoves = [];
this.draw();
} else { } else {
this.draw(); this.draw();
} }
} }
tryMove(from, to) { cancelDrag() {
this.dragging = null;
this.hover = null;
this.draw();
}
tryMove(from, to, dropXY) {
const isLegal = this.highlights.legalMoves.some(m => m.from === from && m.to === to); const isLegal = this.highlights.legalMoves.some(m => m.from === from && m.to === to);
this.highlights.selected = null;
this.highlights.legalMoves = [];
if (isLegal && this.onMove) { if (isLegal && this.onMove) {
this.onMove(from, to); this.onMove(from, to);
this.draw();
return;
}
this._drop = null;
if (dropXY) {
// Dropped where it cannot go: it flies home.
const piece = this.pieces[from];
if (piece && !reduced()) {
this.anims.push({ type: 'slide', t0: performance.now(), dur: 160,
sliders: [{ piece, from: { x: dropXY.x - this.squareSize / 2, y: dropXY.y - this.squareSize / 2 }, to: from, landAs: piece }] });
this.kick();
}
this.onIllegal?.(from, to);
} }
this.highlights.selected = null;
this.highlights.legalMoves = [];
this.draw();
}
canSelect(piece) { return true; }
showLegalMoves(square) {}
setLastMove(from, to) {
this.highlights.lastMove = { from, to };
this.draw();
}
setCheck(square) {
this.highlights.check = square;
this.draw();
}
clearHighlights() {
this.highlights = { lastMove: null, selected: null, legalMoves: [], check: null };
this.draw();
}
flip() {
this.flipped = !this.flipped;
this.draw();
}
resize() {
// Both the backing-store size and the CSS size. Setting only canvas.width
// lays the element out at its attribute width — devicePixelRatio times too
// large — which overflowed the board on every 2x phone.
const size = this.availableSize();
this.squareSize = size / 8;
this.size = size;
this.wrapper.style.maxWidth = size + 'px';
const dpr = window.devicePixelRatio || 1;
this.canvas.width = size * dpr;
this.canvas.height = size * dpr;
this.canvas.style.width = size + 'px';
this.canvas.style.height = size + 'px';
// Setting canvas.width resets the transform, so this is not cumulative.
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.scale(dpr, dpr);
this.draw(); this.draw();
} }
animateMove(from, to, callback) { canSelect() { return true; }
const piece = this.pieces[from]; showLegalMoves() {}
if (!piece) { callback?.(); return; }
const fromXY = this.squareToXY(from);
const toXY = this.squareToXY(to);
const isCapture = !!this.pieces[to];
const duration = 120;
const startTime = performance.now();
const squishAmount = isCapture ? 0.72 : 0.85;
this.animating = { square: from, piece, x: fromXY.x, y: fromXY.y, scale: 1 };
const step = (now) => {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
this.animating.x = fromXY.x + (toXY.x - fromXY.x) * ease;
this.animating.y = fromXY.y + (toXY.y - fromXY.y) * ease;
// Squish: scale dips at midpoint then pops back
const squishT = Math.sin(t * Math.PI);
this.animating.scale = 1 - (1 - squishAmount) * squishT;
this.draw();
if (t < 1) {
requestAnimationFrame(step);
} else {
this.animating = null;
callback?.();
}
};
requestAnimationFrame(step);
}
destroy() { destroy() {
this._ro?.disconnect();
cancelAnimationFrame(this._raf);
this._raf = 0;
boards.delete(this);
this.wrapper.remove(); this.wrapper.remove();
} }
} }
// The promotion picker: a small white card dealt onto the board with the four
// pieces the pawn can become, in the player's own colour. Resolves to
// 'q' | 'r' | 'b' | 'n', or null when the player changes their mind.
import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import * as audio from '../../../core/audio.js';
import { haptic } from '../../../core/feel.js';
import { pieceHTML } from '../canvas/board.js';
const PIECES = ['q', 'r', 'b', 'n'];
/**
* @param {HTMLElement} dialog the host element (kept: #promo-dialog in the game)
* @param {'w'|'b'} color
*/
export function pickPromotion(dialog, color) {
return new Promise(resolve => {
dialog.classList.add('cg-promo');
dialog.classList.toggle('cg-promo-black', color === 'b');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-label', t('chess.promote_to'));
dialog.innerHTML = `
<div class="cg-promo-title">${t('chess.promote_to')}</div>
<div class="cg-promo-row">
${PIECES.map(p => `
<button type="button" class="cg-promo-pick" data-piece="${p}" aria-label="${t('chess.piece_' + p)}">
${pieceHTML(color === 'w' ? p.toUpperCase() : p, 46)}
</button>`).join('')}
</div>
<button type="button" class="cg-promo-cancel" aria-label="${t('common.cancel')}">${icon('x', 18)}</button>
`;
dialog.hidden = false;
dialog.style.display = '';
audio.play('flip');
haptic('medium');
const done = (val) => {
dialog.hidden = true;
dialog.innerHTML = '';
resolve(val);
};
dialog.querySelectorAll('[data-piece]').forEach(btn => {
btn.addEventListener('click', () => done(btn.dataset.piece), { once: true });
});
dialog.querySelector('.cg-promo-cancel').addEventListener('click', () => done(null), { once: true });
});
}
export const PROMO_STYLE = `
.cg-promo {
position: absolute;
inset: 0;
margin: auto;
width: max-content;
height: max-content;
z-index: 40;
padding: 16px 14px 14px;
border-radius: var(--r-card);
background: var(--card);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
animation: deal var(--dur-normal) var(--ease-pop) both;
}
.cg-promo[hidden] { display: none !important; }
.cg-promo-title { font-family: var(--font-display); font-size: var(--fs-md); color: var(--ink); }
.cg-promo-row { display: flex; gap: 8px; }
.cg-promo-pick {
width: 58px; height: 62px;
display: flex; align-items: center; justify-content: center;
border: none;
border-radius: 16px;
background: var(--card-2);
box-shadow: 0 4px 0 var(--card-edge);
cursor: pointer;
padding: 0;
}
.cg-promo-black .cg-promo-pick { background: var(--card-3); }
.cg-promo-pick.is-pressed { translate: 0 3px; box-shadow: 0 1px 0 var(--card-edge); }
.cg-promo-cancel {
position: absolute;
top: -14px; inset-inline-end: -14px;
width: 44px; height: 44px;
border-radius: 14px;
border: none;
background: var(--btn-red-2);
box-shadow: 0 3px 0 var(--btn-red-edge);
color: var(--text-primary);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
padding: 0;
}
.cg-promo-cancel .i { stroke-width: 3; }
`;
...@@ -8,9 +8,9 @@ export function renderRatingGraph(container, history, options = {}) { ...@@ -8,9 +8,9 @@ export function renderRatingGraph(container, history, options = {}) {
const { const {
width = 340, width = 340,
height = 140, height = 140,
color = getColor('gold', '#F6BB3C'), color = getColor('gold', '#FFC531'),
bgColor = null, // null: let the ground show through bgColor = null, // null: let the ground show through
gridColor = getColor('back_line', 'rgba(255, 210, 140, 0.14)'), gridColor = getColor('back_line', 'rgba(255, 255, 255, 0.07)'),
} = options; } = options;
container.innerHTML = ''; container.innerHTML = '';
...@@ -19,12 +19,12 @@ export function renderRatingGraph(container, history, options = {}) { ...@@ -19,12 +19,12 @@ export function renderRatingGraph(container, history, options = {}) {
return; return;
} }
const muted = getColor('text_muted', '#CFAC80'); const muted = getColor('text_muted', '#8E889E');
const primary = getColor('text_primary', '#F3F0E8'); const primary = getColor('text_primary', '#FFFFFF');
const ground = getColor('back_900', '#221008'); const ground = getColor('back_900', '#14121A');
const up = getColor('win', '#3DBE7E'); const up = getColor('win', '#2ED47A');
const down = getColor('red', '#EF4D55'); const down = getColor('red', '#FF4D5E');
const font = getColor('font_ar', 'Alexandria, system-ui, sans-serif'); const font = getColor('font_ar', 'Rubik, system-ui, sans-serif');
const { canvas, ctx } = createCanvas(container, width, height); const { canvas, ctx } = createCanvas(container, width, height);
canvas.style.width = '100%'; canvas.style.width = '100%';
......
// Move Classification Engine // Move Classification Engine
// Classifies moves as: brilliant, great, best, good, book, inaccuracy, mistake, blunder // Classifies moves as: brilliant, great, best, good, book, inaccuracy, mistake, blunder
import { emoji } from '../../../core/theme.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
const THRESHOLDS = { const THRESHOLDS = {
...@@ -19,7 +18,7 @@ export function classifyMove(evalBefore, evalAfter, bestEval, isPlayerWhite, mov ...@@ -19,7 +18,7 @@ export function classifyMove(evalBefore, evalAfter, bestEval, isPlayerWhite, mov
const lossFromBest = (bestEval - evalAfter) * sign; const lossFromBest = (bestEval - evalAfter) * sign;
// Book moves (first 2 full moves = 4 half-moves) // Book moves (first 2 full moves = 4 half-moves)
if (moveNum <= 2) return { class: 'book', symbol: emoji('book', '📖', 14), color: 'var(--text-secondary)' }; if (moveNum <= 2) return { class: 'book', symbol: '', color: 'var(--text-secondary)' };
// If eval is mate-level, special handling // If eval is mate-level, special handling
if (Math.abs(evalAfter) > 900) { if (Math.abs(evalAfter) > 900) {
...@@ -42,7 +41,7 @@ export function classifyMove(evalBefore, evalAfter, bestEval, isPlayerWhite, mov ...@@ -42,7 +41,7 @@ export function classifyMove(evalBefore, evalAfter, bestEval, isPlayerWhite, mov
return { class: 'brilliant', symbol: '!!', color: 'var(--cyan)' }; return { class: 'brilliant', symbol: '!!', color: 'var(--cyan)' };
} }
if (lossFromBest <= THRESHOLDS.great) { if (lossFromBest <= THRESHOLDS.great) {
return { class: 'best', symbol: emoji('best_move_star', '★', 14), color: 'var(--emerald)' }; return { class: 'best', symbol: '', color: 'var(--emerald)' };
} }
return { class: 'great', symbol: '!', color: 'var(--blue)' }; return { class: 'great', symbol: '!', color: 'var(--blue)' };
} }
......
// Two chess clocks. Time is kept in milliseconds; the running side loses the
// wall-clock time that passes. A move "presses" the clock: the mover is credited
// the increment (as on the server, api/game.php) and the other side starts.
export class ChessClock { export class ChessClock {
constructor(timeMs, incrementMs = 0) { constructor(timeMs, incrementMs = 0) {
this.initial = timeMs; this.initial = timeMs;
...@@ -13,33 +16,48 @@ export class ChessClock { ...@@ -13,33 +16,48 @@ export class ChessClock {
start(color) { start(color) {
this.stop(); this.stop();
if (color !== 'w' && color !== 'b') return;
this.running = color; this.running = color;
this.lastTick = performance.now(); this.lastTick = performance.now();
const tick = () => { const tick = () => {
const now = performance.now(); const now = performance.now();
const elapsed = now - this.lastTick; const elapsed = now - this.lastTick;
this.lastTick = now; this.lastTick = now;
if (this.running === 'w') { const key = this.running === 'w' ? 'white' : 'black';
this.white -= elapsed; this[key] -= elapsed;
if (this.white <= 0) { this.white = 0; this.stop(); this.onFlag?.('w'); return; } if (this[key] <= 0) {
} else { this[key] = 0;
this.black -= elapsed; const flagged = this.running;
if (this.black <= 0) { this.black = 0; this.stop(); this.onFlag?.('b'); return; } this.stop();
this.onTick?.(this.white, this.black);
this.onFlag?.(flagged);
return;
} }
this.onTick?.(this.white, this.black); this.onTick?.(this.white, this.black);
}; };
this.interval = setInterval(tick, 100); this.interval = setInterval(tick, 100);
} }
/** `color` just moved: credit its increment and start the other side. */
press(color) {
if (color === 'w') this.white += this.increment;
else if (color === 'b') this.black += this.increment;
this.start(color === 'w' ? 'b' : 'w');
this.onTick?.(this.white, this.black);
}
/** Kept for old callers: the running side presses. */
switch() { switch() {
if (!this.running) return; if (!this.running) return;
if (this.running === 'w') { this.press(this.running);
this.white += this.increment; }
this.start('b');
} else { /** Adopt times from the server (it is the authority in live games). */
this.black += this.increment; set(whiteMs, blackMs) {
this.start('w'); if (Number.isFinite(whiteMs)) this.white = Math.max(0, whiteMs);
} if (Number.isFinite(blackMs)) this.black = Math.max(0, blackMs);
this.lastTick = performance.now();
this.onTick?.(this.white, this.black);
} }
stop() { stop() {
...@@ -54,8 +72,13 @@ export class ChessClock { ...@@ -54,8 +72,13 @@ export class ChessClock {
return color === 'w' ? this.white : this.black; return color === 'w' ? this.white : this.black;
} }
/** m:ss, h:mm:ss for long games, and tenths in the last ten seconds. */
format(ms) { format(ms) {
if (ms <= 0) return '0:00'; if (ms <= 0) return '0:00';
if (ms < 10000) {
const tenths = Math.floor(ms / 100);
return `0:0${Math.floor(tenths / 10)}.${tenths % 10}`;
}
const totalSec = Math.ceil(ms / 1000); const totalSec = Math.ceil(ms / 1000);
const m = Math.floor(totalSec / 60); const m = Math.floor(totalSec / 60);
const s = totalSec % 60; const s = totalSec % 60;
...@@ -78,9 +101,9 @@ export class ChessClock { ...@@ -78,9 +101,9 @@ export class ChessClock {
} }
export function parseTimeControl(key) { export function parseTimeControl(key) {
const match = key.match(/(\w+)_(\d+)_(\d+)/); const match = String(key || '').match(/(\w+)_(\d+)_(\d+)/);
if (!match) return { time: 600000, increment: 0 }; if (!match) return { time: 600000, increment: 0 };
const minutes = parseInt(match[2]); const minutes = parseInt(match[2], 10);
const inc = parseInt(match[3]); const inc = parseInt(match[3], 10);
return { time: minutes * 60 * 1000, increment: inc * 1000 }; return { time: minutes * 60 * 1000, increment: inc * 1000 };
} }
// A thin wrapper round chess.js (the global `Chess`, 0.10 API, with the 1.x
// method names accepted too). One game at a time: the scene that is open.
let game = null; let game = null;
export function create(fen) { export function create(fen) {
game = new Chess(fen); game = fen ? new Chess(fen) : new Chess();
return game; return game;
} }
export function get() { return game; } export function get() { return game; }
/** A legal move, or null. Never throws: chess.js 1.x throws on illegal moves. */
export function move(from, to, promotion) { export function move(from, to, promotion) {
const m = game.move({ from, to, promotion: promotion || undefined }); try {
return m; return game.move({ from, to, promotion: promotion || undefined }) || null;
} catch (e) {
return null;
}
} }
export function moveUci(uci) { export function moveUci(uci) {
const from = uci.substring(0, 2); if (!uci || uci.length < 4) return null;
const to = uci.substring(2, 4); return move(uci.substring(0, 2), uci.substring(2, 4), uci.length > 4 ? uci[4] : undefined);
const promotion = uci.length > 4 ? uci[4] : undefined;
return move(from, to, promotion);
} }
export function undo() { return game.undo(); }
export function fen() { return game.fen(); } export function fen() { return game.fen(); }
export function turn() { return game.turn(); } export function turn() { return game.turn(); }
export function isGameOver() { return game.game_over ? game.game_over() : game.isGameOver(); } export function isGameOver() { return game.game_over ? game.game_over() : game.isGameOver(); }
...@@ -49,7 +54,40 @@ export function reset() { ...@@ -49,7 +54,40 @@ export function reset() {
} }
export function load(fen) { export function load(fen) {
game.load(fen); return game.load(fen);
}
/**
* Rebuild the game from the start by playing `moves` ({from, to, promotion}).
* Keeping the real history (not just a FEN) is what lets threefold repetition
* be detected and the PGN be complete. Returns how many moves applied.
*/
export function replay(moves, startFen) {
game = startFen ? new Chess(startFen) : new Chess();
let n = 0;
for (const m of moves || []) {
if (!m || !m.from || !m.to) break;
if (!move(m.from, m.to, m.promotion)) break;
n++;
}
return n;
}
/** The board part of a FEN, for comparing positions without the counters. */
export function placement(fenStr) {
return String(fenStr || '').split(' ').slice(0, 2).join(' ');
}
/** The square of `color`'s king, or null. */
export function kingSquare(color) {
const b = game.board();
for (let r = 0; r < 8; r++) {
for (let c = 0; c < 8; c++) {
const p = b[r][c];
if (p && p.type === 'k' && p.color === color) return String.fromCharCode(97 + c) + String(8 - r);
}
}
return null;
} }
export function getResult(playerColor) { export function getResult(playerColor) {
...@@ -59,3 +97,13 @@ export function getResult(playerColor) { ...@@ -59,3 +97,13 @@ export function getResult(playerColor) {
} }
return 'draw'; return 'draw';
} }
/** Why the position on the board is final, in the server's vocabulary. */
export function endReason() {
if (isCheckmate()) return 'checkmate';
if (isStalemate()) return 'stalemate';
if (isThreefoldRepetition()) return 'threefold';
if (isInsufficientMaterial()) return 'insufficient';
if (isDraw()) return 'fifty_moves';
return 'draw';
}
// ECO Opening Database — maps move sequences to opening names // ECO Opening Database — maps move sequences to opening names
const OPENINGS = { const OPENINGS = {
'1.e4': { name: "King's Pawn Opening", eco: 'B00' }, '1.e4': { name: "King's Pawn Opening", nameAr: "افتتاحية بيدق الملك", eco: 'B00' },
'1.e4 e5': { name: "Open Game", eco: 'C20' }, '1.e4 e5': { name: "Open Game", nameAr: "اللعبة المفتوحة", eco: 'C20' },
'1.e4 e5 2.Nf3': { name: "King's Knight Opening", eco: 'C40' }, '1.e4 e5 2.Nf3': { name: "King's Knight Opening", nameAr: "افتتاحية حصان الملك", eco: 'C40' },
'1.e4 e5 2.Nf3 Nc6': { name: "Four Knights Game", eco: 'C44' }, '1.e4 e5 2.Nf3 Nc6': { name: "King's Knight Opening", nameAr: "افتتاحية حصان الملك", eco: 'C44' },
'1.e4 e5 2.Nf3 Nc6 3.Nc3 Nf6': { name: "Four Knights Game", nameAr: "الأحصنة الأربعة", eco: 'C47' },
'1.e4 e5 2.Nf3 Nc6 3.Bb5': { name: "Ruy Lopez", nameAr: "الإسبانية", eco: 'C60' }, '1.e4 e5 2.Nf3 Nc6 3.Bb5': { name: "Ruy Lopez", nameAr: "الإسبانية", eco: 'C60' },
'1.e4 e5 2.Nf3 Nc6 3.Bc4': { name: "Italian Game", nameAr: "الإيطالية", eco: 'C50' }, '1.e4 e5 2.Nf3 Nc6 3.Bc4': { name: "Italian Game", nameAr: "الإيطالية", eco: 'C50' },
'1.e4 e5 2.Nf3 Nc6 3.d4': { name: "Scotch Game", nameAr: "الاسكتلندية", eco: 'C45' }, '1.e4 e5 2.Nf3 Nc6 3.d4': { name: "Scotch Game", nameAr: "الاسكتلندية", eco: 'C45' },
'1.e4 e5 2.Nf3 Nf6': { name: "Petrov's Defense", nameAr: "دفاع بتروف", eco: 'C42' }, '1.e4 e5 2.Nf3 Nf6': { name: "Petrov's Defense", nameAr: "دفاع بتروف", eco: 'C42' },
'1.e4 e5 2.f4': { name: "King's Gambit", nameAr: "غامبيت الملك", eco: 'C30' }, '1.e4 e5 2.f4': { name: "King's Gambit", nameAr: "غامبيت الملك", eco: 'C30' },
'1.e4 c5': { name: "Sicilian Defense", nameAr: "الصقلية", eco: 'B20' }, '1.e4 c5': { name: "Sicilian Defense", nameAr: "الصقلية", eco: 'B20' },
'1.e4 c5 2.Nf3 d6': { name: "Sicilian: Najdorf", nameAr: "الصقلية: نايدورف", eco: 'B90' }, '1.e4 c5 2.Nf3 d6': { name: "Sicilian Defense", nameAr: "الصقلية", eco: 'B50' },
'1.e4 c5 2.Nf3 Nc6': { name: "Sicilian: Classical", nameAr: "الصقلية: الكلاسيكية", eco: 'B30' }, '1.e4 c5 2.Nf3 Nc6': { name: "Sicilian Defense", nameAr: "الصقلية", eco: 'B30' },
'1.e4 c5 2.Nf3 e6': { name: "Sicilian: French", nameAr: "الصقلية: الفرنسية", eco: 'B40' }, '1.e4 c5 2.Nf3 e6': { name: "Sicilian: French Variation", nameAr: "الصقلية: المتغير الفرنسي", eco: 'B40' },
'1.e4 e6': { name: "French Defense", nameAr: "الدفاع الفرنسي", eco: 'C00' }, '1.e4 e6': { name: "French Defense", nameAr: "الدفاع الفرنسي", eco: 'C00' },
'1.e4 e6 2.d4 d5': { name: "French: Classical", nameAr: "الفرنسي: الكلاسيكي", eco: 'C01' }, '1.e4 e6 2.d4 d5': { name: "French Defense", nameAr: "الدفاع الفرنسي", eco: 'C00' },
'1.e4 c6': { name: "Caro-Kann Defense", nameAr: "كارو-كان", eco: 'B10' }, '1.e4 c6': { name: "Caro-Kann Defense", nameAr: "كارو-كان", eco: 'B10' },
'1.e4 c6 2.d4 d5': { name: "Caro-Kann: Main Line", nameAr: "كارو-كان: الخط الرئيسي", eco: 'B12' }, '1.e4 c6 2.d4 d5': { name: "Caro-Kann: Main Line", nameAr: "كارو-كان: الخط الرئيسي", eco: 'B12' },
'1.e4 d5': { name: "Scandinavian Defense", nameAr: "الإسكندنافي", eco: 'B01' }, '1.e4 d5': { name: "Scandinavian Defense", nameAr: "الإسكندنافي", eco: 'B01' },
'1.e4 d6': { name: "Pirc Defense", nameAr: "دفاع بيرك", eco: 'B07' }, '1.e4 d6': { name: "Pirc Defense", nameAr: "دفاع بيرك", eco: 'B07' },
'1.e4 g6': { name: "Modern Defense", nameAr: "الدفاع الحديث", eco: 'B06' }, '1.e4 g6': { name: "Modern Defense", nameAr: "الدفاع الحديث", eco: 'B06' },
'1.e4 Nf6': { name: "Alekhine's Defense", nameAr: "دفاع أليخين", eco: 'B02' }, '1.e4 Nf6': { name: "Alekhine's Defense", nameAr: "دفاع أليخين", eco: 'B02' },
'1.d4': { name: "Queen's Pawn Opening", eco: 'D00' }, '1.d4': { name: "Queen's Pawn Opening", nameAr: "افتتاحية بيدق الوزير", eco: 'A40' },
'1.d4 d5': { name: "Closed Game", eco: 'D00' }, '1.d4 d5': { name: "Closed Game", nameAr: "اللعبة المغلقة", eco: 'D00' },
'1.d4 d5 2.c4': { name: "Queen's Gambit", nameAr: "غامبيت الملكة", eco: 'D06' }, '1.d4 d5 2.c4': { name: "Queen's Gambit", nameAr: "غامبيت الملكة", eco: 'D06' },
'1.d4 d5 2.c4 e6': { name: "QGD: Classical", nameAr: "غامبيت الملكة المرفوض", eco: 'D30' }, '1.d4 d5 2.c4 e6': { name: "Queen's Gambit Declined", nameAr: "غامبيت الملكة المرفوض", eco: 'D30' },
'1.d4 d5 2.c4 dxc4': { name: "QGA: Accepted", nameAr: "غامبيت الملكة المقبول", eco: 'D20' }, '1.d4 d5 2.c4 dxc4': { name: "Queen's Gambit Accepted", nameAr: "غامبيت الملكة المقبول", eco: 'D20' },
'1.d4 d5 2.c4 c6': { name: "Slav Defense", nameAr: "الدفاع السلافي", eco: 'D10' }, '1.d4 d5 2.c4 c6': { name: "Slav Defense", nameAr: "الدفاع السلافي", eco: 'D10' },
'1.d4 Nf6': { name: "Indian Defense", eco: 'A45' }, '1.d4 Nf6': { name: "Indian Defense", nameAr: "الدفاع الهندي", eco: 'A45' },
'1.d4 Nf6 2.c4 g6': { name: "King's Indian Defense", nameAr: "هندي الملك", eco: 'E60' }, '1.d4 Nf6 2.c4 g6': { name: "King's Indian Defense", nameAr: "هندي الملك", eco: 'E60' },
'1.d4 Nf6 2.c4 e6': { name: "Nimzo/Queen's Indian", nameAr: "نيمزو-هندية", eco: 'E00' }, '1.d4 Nf6 2.c4 e6': { name: "Indian Defense", nameAr: "الدفاع الهندي", eco: 'E00' },
'1.d4 Nf6 2.c4 e6 3.Nc3 Bb4': { name: "Nimzo-Indian Defense", nameAr: "نيمزو-هندية", eco: 'E20' }, '1.d4 Nf6 2.c4 e6 3.Nc3 Bb4': { name: "Nimzo-Indian Defense", nameAr: "نيمزو-هندية", eco: 'E20' },
'1.d4 Nf6 2.c4 c5': { name: "Benoni Defense", nameAr: "دفاع بنوني", eco: 'A56' }, '1.d4 Nf6 2.c4 c5': { name: "Benoni Defense", nameAr: "دفاع بنوني", eco: 'A56' },
'1.d4 f5': { name: "Dutch Defense", nameAr: "الدفاع الهولندي", eco: 'A80' }, '1.d4 f5': { name: "Dutch Defense", nameAr: "الدفاع الهولندي", eco: 'A80' },
......
import * as scene from '../../core/scene.js'; import * as scene from '../../core/scene.js';
import { mountGame, unmountGame } from './scenes/game.js'; import { mountGame, unmountGame } from './scenes/game.js';
import { mountResult } from './scenes/result.js'; import { mountResult, unmountResult } from './scenes/result.js';
import { mountAnalysis } from './scenes/analysis.js'; import { mountAnalysis, unmountAnalysis } from './scenes/analysis.js';
import { mountHistory } from './scenes/history.js'; import { mountHistory } from './scenes/history.js';
import { mountReview } from './scenes/review.js'; import { mountReview, unmountReview } from './scenes/review.js';
import { mountSpectate } from './scenes/spectate.js'; import { mountSpectate } from './scenes/spectate.js';
scene.register('chess-game', mountGame, unmountGame); scene.register('chess-game', mountGame, unmountGame);
scene.register('chess-result', mountResult); scene.register('chess-result', mountResult, unmountResult);
scene.register('chess-analysis', mountAnalysis); scene.register('chess-analysis', mountAnalysis, unmountAnalysis);
scene.register('chess-history', mountHistory); scene.register('chess-history', mountHistory);
scene.register('chess-review', mountReview); scene.register('chess-review', mountReview, unmountReview);
scene.register('chess-spectate', mountSpectate); scene.register('chess-spectate', mountSpectate);
...@@ -12,11 +12,20 @@ import { icon } from '../../../core/icons.js'; ...@@ -12,11 +12,20 @@ import { icon } from '../../../core/icons.js';
import { screenHead, wireBack, esc } from '../../../core/ui.js'; import { screenHead, wireBack, esc } from '../../../core/ui.js';
let board, analysisData, currentMoveIdx; let board, analysisData, currentMoveIdx;
let requestSeq = 0; // only the newest analysis may paint
let analyzeTimer = 0;
export function unmountAnalysis() {
requestSeq++;
clearTimeout(analyzeTimer);
if (board) { board.destroy(); board = null; }
}
export function mountAnalysis(el, params) { export function mountAnalysis(el, params) {
const { pgn, moveHistory = [], finalFen } = params; const { pgn, moveHistory = [], finalFen, playerColor = 'w' } = params;
currentMoveIdx = 0; currentMoveIdx = 0;
analysisData = null; analysisData = null;
requestSeq++;
engine.create(); engine.create();
...@@ -41,7 +50,11 @@ export function mountAnalysis(el, params) { ...@@ -41,7 +50,11 @@ export function mountAnalysis(el, params) {
<!-- Timeline scrubber --> <!-- Timeline scrubber -->
<div class="an-timeline"> <div class="an-timeline">
<div id="move-display" class="an-move num">${t('analysis.start')}</div> <div class="an-step">
<button type="button" class="icon-btn" id="an-prev" aria-label="${t('analysis.prev')}">${icon('chevron-start', 22, { weight: 3 })}</button>
<div id="move-display" class="an-move num">${t('analysis.start')}</div>
<button type="button" class="icon-btn" id="an-next" aria-label="${t('analysis.next')}">${icon('chevron-end', 22, { weight: 3 })}</button>
</div>
<div id="scrubber-track" class="an-track" role="slider" aria-label="${t('chess.timeline')}"> <div id="scrubber-track" class="an-track" role="slider" aria-label="${t('chess.timeline')}">
<div id="scrubber-fill" class="an-track-fill"></div> <div id="scrubber-fill" class="an-track-fill"></div>
<div id="scrubber-thumb" class="an-thumb"></div> <div id="scrubber-thumb" class="an-thumb"></div>
...@@ -56,7 +69,7 @@ export function mountAnalysis(el, params) { ...@@ -56,7 +69,7 @@ export function mountAnalysis(el, params) {
`; `;
const boardContainer = el.querySelector('#analysis-board'); const boardContainer = el.querySelector('#analysis-board');
board = new ChessBoard(boardContainer, { interactive: false }); board = new ChessBoard(boardContainer, { interactive: false, flipped: playerColor === 'b' });
board.setPosition(engine.fen()); board.setPosition(engine.fen());
// Build move list // Build move list
...@@ -95,6 +108,9 @@ export function mountAnalysis(el, params) { ...@@ -95,6 +108,9 @@ export function mountAnalysis(el, params) {
track.addEventListener('pointerup', () => { isDragging = false; }); track.addEventListener('pointerup', () => { isDragging = false; });
track.addEventListener('pointercancel', () => { isDragging = false; }); track.addEventListener('pointercancel', () => { isDragging = false; });
el.querySelector('#an-prev').addEventListener('click', () => { if (currentMoveIdx > 0) goToMove(el, moves, currentMoveIdx - 1, true); });
el.querySelector('#an-next').addEventListener('click', () => { if (currentMoveIdx < moves.length) goToMove(el, moves, currentMoveIdx + 1, true); });
// Go to final position // Go to final position
goToMove(el, moves, moves.length); goToMove(el, moves, moves.length);
} }
...@@ -120,20 +136,26 @@ function renderMoveChips(el, moves) { ...@@ -120,20 +136,26 @@ function renderMoveChips(el, moves) {
}); });
} }
function goToMove(el, moves, idx) { function goToMove(el, moves, idx, step = false) {
if (!board) return;
const prevIdx = currentMoveIdx;
currentMoveIdx = idx; currentMoveIdx = idx;
engine.create(); engine.create();
let last = null;
for (let i = 0; i < idx; i++) { for (let i = 0; i < idx; i++) {
const m = moves[i]; const m = moves[i];
if (m) engine.move(m.from, m.to, m.promotion); if (m) last = engine.move(m.from, m.to, m.promotion) || last;
} }
board.setPosition(engine.fen()); // One step forward slides the piece; any other jump sets the position.
if (idx > 0) { if (step && idx === prevIdx + 1 && last) {
const lastMove = moves[idx - 1]; board.playMove(last, engine.fen(), { onLand: () => audio.play(last.captured ? 'capture' : 'move', 'game') });
board.setLastMove(lastMove.from, lastMove.to); } else {
board.setPosition(engine.fen());
board.setLastMove(last ? last.from : null, last ? last.to : null);
} }
board.setCheck(engine.isCheck() ? engine.kingSquare(engine.turn()) : null);
// Update display // Update display
const display = el.querySelector('#move-display'); const display = el.querySelector('#move-display');
...@@ -162,13 +184,19 @@ function goToMove(el, moves, idx) { ...@@ -162,13 +184,19 @@ function goToMove(el, moves, idx) {
activeChip.scrollIntoView({ inline: 'center', behavior: 'smooth' }); activeChip.scrollIntoView({ inline: 'center', behavior: 'smooth' });
} }
// Request analysis for this position // Request analysis for this position, once the player stops scrubbing.
analyzePosition(el, engine.fen()); const fen = engine.fen();
clearTimeout(analyzeTimer);
analyzeTimer = setTimeout(() => analyzePosition(el, fen), 220);
} }
async function analyzePosition(el, fen) { async function analyzePosition(el, fen) {
const seq = ++requestSeq;
const linesContainer = el.querySelector('#analysis-lines'); const linesContainer = el.querySelector('#analysis-lines');
if (!linesContainer) return;
linesContainer.innerHTML = `<div class="an-quiet">${t('analysis.analyzing')}</div>`; linesContainer.innerHTML = `<div class="an-quiet">${t('analysis.analyzing')}</div>`;
// The engine scores from the side to move; the bar is always White's view.
const pov = fen.split(' ')[1] === 'b' ? -1 : 1;
// Show opening explorer if data exists // Show opening explorer if data exists
const explorerData = lookup(fen); const explorerData = lookup(fen);
...@@ -178,12 +206,15 @@ async function analyzePosition(el, fen) { ...@@ -178,12 +206,15 @@ async function analyzePosition(el, fen) {
} }
try { try {
const data = await net.post('analysis.php', { fen, depth: 18, lines: 3 }); const data = await net.post('analysis.php', { fen, depth: 16, lines: 3 });
if (seq !== requestSeq) return;
if (data.lines && data.lines.length > 0) { if (data.lines && data.lines.length > 0) {
renderAnalysisLines(el, data.lines, fen, explorerData); const lines = data.lines.map(l => ({ ...l, evaluation: (Number(l.evaluation) || 0) * pov }));
updateEvalBar(el, data.lines[0].evaluation); renderAnalysisLines(el, lines, fen, explorerData);
updateEvalBar(el, lines[0].evaluation);
} }
} catch (e) { } catch (e) {
if (seq !== requestSeq) return;
if (!explorerData) { if (!explorerData) {
linesContainer.innerHTML = `<div class="an-quiet an-error">${icon('alert', 14)}${t('analysis.failed')}</div>`; linesContainer.innerHTML = `<div class="an-quiet an-error">${icon('alert', 14)}${t('analysis.failed')}</div>`;
} }
...@@ -226,6 +257,7 @@ function renderAnalysisLines(el, lines, fen, explorerData) { ...@@ -226,6 +257,7 @@ function renderAnalysisLines(el, lines, fen, explorerData) {
function updateEvalBar(el, evaluation) { function updateEvalBar(el, evaluation) {
const fill = el.querySelector('#eval-fill'); const fill = el.querySelector('#eval-fill');
const text = el.querySelector('#eval-text'); const text = el.querySelector('#eval-text');
if (!fill || !text) return;
const clamped = Math.max(-10, Math.min(10, evaluation)); const clamped = Math.max(-10, Math.min(10, evaluation));
const percent = 50 + (clamped / 10) * 50; const percent = 50 + (clamped / 10) * 50;
fill.style.transform = `scaleX(${percent / 100})`; fill.style.transform = `scaleX(${percent / 100})`;
...@@ -250,10 +282,12 @@ const ANALYSIS_STYLE = ` ...@@ -250,10 +282,12 @@ const ANALYSIS_STYLE = `
width: 100%; width: 100%;
max-width: 560px; max-width: 560px;
margin-inline: auto; margin-inline: auto;
/* Clear the play button that stands out of the tab bar. */
padding-bottom: 30px;
} }
.an-head { padding: var(--s-2) var(--gutter) 0; } .an-head { padding: var(--s-2) var(--gutter) 0; }
.an-head .screen-head { margin-bottom: var(--s-2); } .an-head .screen-head { margin-bottom: var(--s-2); }
.an-board { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: var(--s-1) var(--s-2); } .an-board { flex: 1; min-height: 200px; display: flex; align-items: center; justify-content: center; padding: var(--s-1) var(--s-2); }
.an-eval { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-3) var(--gutter) var(--s-2); } .an-eval { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-3) var(--gutter) var(--s-2); }
.an-eval-text { .an-eval-text {
...@@ -303,7 +337,8 @@ const ANALYSIS_STYLE = ` ...@@ -303,7 +337,8 @@ const ANALYSIS_STYLE = `
.an-book-games { font-size: var(--fs-xs); color: var(--text-muted); } .an-book-games { font-size: var(--fs-xs); color: var(--text-muted); }
.an-timeline { padding: var(--s-2) var(--gutter) 0; border-top: 1px solid var(--back-line); } .an-timeline { padding: var(--s-2) var(--gutter) 0; border-top: 1px solid var(--back-line); }
.an-move { text-align: center; font-size: var(--fs-sm); font-weight: 700; color: var(--text-primary); margin-bottom: var(--s-2); } .an-step { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); }
.an-move { flex: 1; text-align: center; font-family: var(--font-display); font-size: var(--fs-lg); color: var(--text-primary); }
.an-track { .an-track {
position: relative; position: relative;
height: 44px; height: 44px;
......
This source diff could not be displayed because it is too large. You can view the blob instead.
// The end of a chess game. The table stays where it was, dimmed, with the
// final position on it; a white result card flips in over it with a ribbon
// across its top edge (green "won", red "lost", blue "draw"), a big sticker
// (a trophy and confetti for a win, the toppled king for a loss, the handshake
// for a draw), and rows for the rating change, the coins and the XP that count
// up. On a win the coins fly to the purse in the HUD.
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import { icon } from '../../../core/icons.js'; import { haptic, countUp } from '../../../core/feel.js';
import { icon, gicon } from '../../../core/icons.js';
import { esc } from '../../../core/ui.js'; import { esc } from '../../../core/ui.js';
import { ChessBoard } from '../canvas/board.js';
const reduced = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; } };
/** How the game ended, worded from this player's side of the board. */ /** How the game ended, worded from this player's side of the board. */
function reasonLine(reason, result) { function reasonLine(reason, result) {
...@@ -27,244 +36,338 @@ function reasonLine(reason, result) { ...@@ -27,244 +36,338 @@ function reasonLine(reason, result) {
return t(map[reason] || 'chess.reason_unknown'); return t(map[reason] || 'chess.reason_unknown');
} }
let tableBoard = null;
let timers = [];
let pendingPay = null; // the purse is paid once, even if the card is left early
const later = (fn, ms) => { const id = setTimeout(fn, ms); timers.push(id); return id; };
export function unmountResult() {
timers.forEach(clearTimeout);
timers = [];
if (pendingPay) { const pay = pendingPay; pendingPay = null; pay(); }
if (tableBoard) { tableBoard.destroy(); tableBoard = null; }
}
export function mountResult(el, params) { export function mountResult(el, params) {
unmountResult();
const { result, reason, coins = 0, xp = 0, moves = 0, mode, botId, pgn, moveHistory = [] } = params; const { result, reason, coins = 0, xp = 0, moves = 0, mode, botId, pgn, moveHistory = [] } = params;
const isWin = result === 'win'; const isWin = result === 'win';
const isDraw = result === 'draw'; const isDraw = result === 'draw';
const tone = isWin ? 'win' : isDraw ? 'draw' : 'loss';
// A rating line only when the server returned a real change.
const ratingChange = Number.isFinite(params.ratingChange) ? params.ratingChange : null;
const signed = n => (n > 0 ? `+${Math.round(n)}` : `${Math.round(n)}`);
const ratingChange = params.ratingChange || (isWin ? 12 : isDraw ? 1 : -8); const ribbon = { win: 'green', loss: '', draw: 'blue' }[tone];
const ratingStr = ratingChange >= 0 ? `+${ratingChange}` : `${ratingChange}`; const title = t('chess.outcome_' + tone);
const sticker = {
// The outcome is a card turned face up: the word in red ink for a win, black win: gicon('trophy', 112),
// ink for a loss, the softer ink for a draw. loss: gicon('king', 104, { cls: 'rs-toppled' }),
const resultConfig = { draw: gicon('handshake', 112),
win: { title: t('chess.outcome_win'), tone: 'win' }, }[tone];
loss: { title: t('chess.outcome_loss'), tone: 'loss' }, const live = mode === 'live';
draw: { title: t('chess.outcome_draw'), tone: 'draw' } const againLabel = live ? t('game.play_again') : t('game.rematch');
};
const cfg = resultConfig[result] || resultConfig.loss;
const reasonText = reasonLine(reason, result);
const ratingTone = ratingChange > 0 ? 'up' : ratingChange < 0 ? 'down' : 'flat';
const ratingInk = ratingChange > 0 ? 'var(--win-ink)' : ratingChange < 0 ? 'var(--red-ink)' : 'var(--ink-2)';
const index = icon('game-chess', 18);
el.innerHTML = ` el.innerHTML = `
<div class="rs"> <div class="rs rs-${tone}">
<article class="deck-card rs-card flip-in ${cfg.tone === 'win' ? 'ink-red' : ''}" aria-live="polite"> <div class="rs-table" aria-hidden="true"><div class="rs-board" id="rs-board"></div></div>
<span class="index top">${index}</span> <div class="rs-dim" aria-hidden="true"></div>
<span class="index bottom">${index}</span>
<div class="rs-face"> <div class="rs-stage">
<h1 class="rs-outcome rs-${cfg.tone}">${cfg.title}</h1> <article class="deck-card rs-card" aria-live="polite" aria-labelledby="rs-title">
<p class="rs-reason">${reasonText}${moves ? ` <span class="rs-dot" aria-hidden="true"></span> ${t('game.moves_count', { n: `<span class="num">${moves}</span>` })}` : ''}</p> <div class="ribbon ${ribbon} rs-ribbon"><h1 id="rs-title">${title}</h1></div>
</div> <div class="rs-sticker">${sticker}</div>
<p class="rs-reason">${reasonLine(reason, result)}${moves ? `<span class="rs-dot" aria-hidden="true"></span><span>${t('game.moves_count', { n: `<span class="num">${moves}</span>` })}</span>` : ''}</p>
<dl class="rs-rows"> <dl class="rs-rows">
<div class="rs-row"> ${ratingChange != null ? `
<dt>${icon(ratingTone === 'down' ? 'trending-down' : 'trending-up', 18)}${t('game.rating')}</dt> <div class="rs-row">
<dd> <dt>${gicon('up', 26, { cls: ratingChange < 0 ? 'rs-down' : '' })}${t('game.rating')}</dt>
${params.ratingAfter ? `<span class="rs-after num">${esc(params.ratingAfter)}</span>` : ''} <dd>
<span class="rs-value rs-rating num rs-${ratingTone}" id="rs-rating">${ratingChange ? '0' : ratingStr}</span> ${params.ratingAfter ? `<span class="rs-after num">${esc(Math.round(params.ratingAfter))}</span>` : ''}
</dd> <span class="rs-value num ${ratingChange > 0 ? 'up' : ratingChange < 0 ? 'down' : ''}" id="rs-rating">${ratingChange ? signed(0) : signed(ratingChange)}</span>
</div> </dd>
<div class="rs-row"> </div>` : ''}
<dt><span class="rs-coin">${icon('coin', 18)}</span>${t('game.coins')}</dt> </dl>
<dd><span class="rs-value rs-coins num">+${coins}</span></dd> <dl class="rs-pay">
</div> <div class="rs-cell">
<div class="rs-row"> <dt><span class="rs-coin" id="rs-coin">${gicon('coin', 30)}</span><span class="sr-only">${t('game.coins')}</span></dt>
<dt>${icon('level-up', 18)}${t('game.xp')}</dt> <dd><span class="rs-value rs-coins num" id="rs-coins">+0</span><span class="rs-unit">${t('game.coins')}</span></dd>
<dd><span class="rs-value num">+${xp}</span></dd> </div>
</div> <div class="rs-cell">
</dl> <dt>${gicon('star', 30)}<span class="sr-only">${t('game.xp')}</span></dt>
</article> <dd><span class="rs-value num" id="rs-xp">+0</span><span class="rs-unit">${t('game.xp')}</span></dd>
</div>
${moveHistory.length > 0 ? `<div class="rs-moves num" dir="ltr">${formatMoveHistory(moveHistory)}</div>` : ''} </dl>
</article>
<div class="rs-actions"> <div class="rs-actions">
${params.tournamentId ${params.tournamentId
? `<button class="btn btn-primary btn-lg btn-block" id="btn-back-tournament">${icon('trophy', 20)}${t('tournament.back_to')}</button>` ? `<button class="btn btn-primary btn-xl btn-block" id="btn-back-tournament">${gicon('trophy', 40)}<span>${t('tournament.back_to')}</span></button>`
: `<button class="btn btn-primary btn-lg btn-block" id="btn-rematch">${icon('refresh', 20)}${t('game.rematch')}</button>` : `<button class="btn btn-primary btn-xl btn-block" id="btn-rematch">${gicon('refresh', 40)}<span>${againLabel}</span></button>`}
} <button class="btn btn-secondary btn-lg btn-block" id="btn-back">${gicon('home', 32)}<span>${t('chess.back_to_table')}</span></button>
<button class="btn btn-secondary btn-block" id="btn-back">${icon('cards', 18)}${t('chess.back_to_table')}</button> <div class="chips rs-chips">
<div class="rs-chips"> <button class="chip" id="btn-analyze" aria-label="${t('review.title')}" ${moveHistory.length ? '' : 'aria-disabled="true"'}>${gicon('search', 22)}<span>${t('chess.chip_review')}</span></button>
<button class="chip" id="btn-analyze">${icon('chart', 16)}${t('review.title')}</button> <button class="chip" id="btn-share" aria-label="${t('result.share_pgn')}">${icon('share', 18)}<span>${t('chess.chip_share')}</span></button>
<button class="chip" id="btn-share">${icon('share', 16)}${t('result.share_pgn')}</button> <button class="chip" id="btn-copy-pgn">${gicon('scroll', 22)}<span>${t('chess.chip_copy')}</span></button>
<button class="chip" id="btn-copy-pgn">${icon('copy', 16)}<span>${t('chess.copy_pgn')}</span></button> </div>
</div> </div>
</div> </div>
</div> </div>
<style>${RESULT_STYLE}</style> <style>${RESULT_STYLE}</style>
`; `;
// JUICE: Result screen effects // The table under the card: the final position, dimmed.
setTimeout(() => { if (params.finalFen) {
// Flash on entry const host = el.querySelector('#rs-board');
juice.flash(isWin ? 'color-mix(in srgb, var(--win) 27%, transparent)' : 'color-mix(in srgb, var(--red-ink) 27%, transparent)', 200); tableBoard = new ChessBoard(host, { interactive: false, flipped: params.playerColor === 'b', maxSize: 520 });
tableBoard.setPosition(params.finalFen);
if (params.lastMove) tableBoard.setLastMove(params.lastMove.from, params.lastMove.to);
if (params.check) tableBoard.highlights.check = params.check;
tableBoard.draw();
}
// The outcome word lands on the card like a stamp as it turns face up. wireActions(el, params, { live, mode, botId, moveHistory, pgn, result });
const stamp = el.querySelector('.rs-outcome');
if (stamp) juice.slamIn(stamp, { scale: 1.8, duration: 500 });
// Rating counter ticks (from zero, so the change is counted out) // Coming back to this card (from the review) shows it as it was left: the
setTimeout(() => { // ceremony, and the coins, happen once.
const ratingEl = el.querySelector('#rs-rating'); if (params._shown) {
if (ratingEl && ratingChange) juice.countTo(ratingEl, ratingChange, { duration: 800, prefix: ratingChange >= 0 ? '+' : '', color: ratingInk }); el.querySelector('.rs').classList.add('rs-settled');
}, 400); const ratingEl = el.querySelector('#rs-rating');
if (ratingEl && ratingChange != null) ratingEl.textContent = signed(ratingChange);
el.querySelector('#rs-coins').textContent = `+${coins}`;
el.querySelector('#rs-xp').textContent = `+${xp}`;
return;
}
params._shown = true;
// Confetti on win // ── The ceremony ──
if (isWin) { const card = el.querySelector('.rs-card');
setTimeout(() => juice.confetti(window.innerWidth / 2, window.innerHeight / 3, 40), 300); const stickerEl = el.querySelector('.rs-sticker');
juice.hapticSuccess(); if (isWin) audio.play('win', 'reward'); else if (isDraw) audio.play('reward', 'reward', { volume: 0.6 }); else audio.play('lose', 'game');
} else { audio.play('flip');
juice.shake(el, 3, 200); haptic(isWin ? 'success' : isDraw ? 'medium' : 'error');
juice.hapticError();
}
// Coins fly to HUD later(() => {
if (coins > 0) { const r = stickerEl?.getBoundingClientRect();
setTimeout(() => { if (isWin && r) {
const rect = el.getBoundingClientRect(); juice.confetti(r.left + r.width / 2, r.top + r.height / 2, 46);
juice.coinFlyTo(rect.width / 2, rect.height / 2, '#hud-coins', Math.min(coins / 10, 8)); juice.starBurst(r.left + r.width / 2, r.top + r.height / 2, 10);
}, 800); } else if (!isWin && !isDraw && card) {
juice.shake(card, 5, 320);
} }
}, 520);
// Stagger buttons // Rows count up once the card has turned.
const buttons = el.querySelectorAll('.rs-actions .btn, .rs-actions .chip'); later(() => {
juice.stagger(Array.from(buttons), juice.slideUpBounce, 600, 80); const ratingEl = el.querySelector('#rs-rating');
}, 100); if (ratingEl && ratingChange) countUp(ratingEl, 0, ratingChange, { duration: 800, format: n => signed(n) });
countUp(el.querySelector('#rs-coins'), 0, coins, { duration: 800, format: n => `+${Math.round(n)}` });
countUp(el.querySelector('#rs-xp'), 0, xp, { duration: 800, format: n => `+${Math.round(n)}` });
}, 760);
if (params.tournamentId) { // The coins go to the purse: they fly there on a win, and the purse counts up.
el.querySelector('#btn-back-tournament')?.addEventListener('click', () => { later(() => {
audio.play('click'); if (coins > 0 && isWin) {
juice.hapticLight(); const from = el.querySelector('#rs-coin')?.getBoundingClientRect();
scene.replace('tournament-detail', { tournamentId: params.tournamentId }); if (from) juice.coinFlyTo(from.left + from.width / 2, from.top + from.height / 2, '#hud-coins', Math.min(8, Math.max(4, Math.round(coins / 8))));
}); }
} else { }, 1500);
el.querySelector('#btn-rematch')?.addEventListener('click', () => { // The server pays the coins (complete_match) and the HUD pulls the real
audio.play('click'); // balance after every game (hud.js afterGame), so the purse is never
juice.hapticLight(); // credited here as well: adding them locally counted every win twice.
scene.replace('chess-game', { mode, botId, timeControl: params.timeControl || 'rapid_10_0' }); pendingPay = () => {
}); bus.emit('coins:earned', { amount: coins });
} bus.emit('xp:earned', { amount: xp });
};
later(() => { if (pendingPay) { const pay = pendingPay; pendingPay = null; pay(); } }, isWin ? 2300 : 1600);
el.querySelector('#btn-analyze').addEventListener('click', () => { }
audio.play('click');
juice.hapticLight();
scene.push('chess-review', { moveHistory, playerColor: params.playerColor || 'w', botId, result });
});
function wireActions(el, params, { live, mode, botId, moveHistory, pgn, result }) {
el.querySelector('#btn-back-tournament')?.addEventListener('click', () => {
scene.replace('tournament-detail', { tournamentId: params.tournamentId });
});
el.querySelector('#btn-rematch')?.addEventListener('click', () => {
if (live) {
// A live rematch is a new search at the same clock.
scene.replace('play-queue', { game: 'chess', mode: 'human', timeControl: params.timeControl || 'rapid_10_0' });
} else {
scene.replace('chess-game', { mode: mode || 'bot', botId, timeControl: params.timeControl || 'rapid_10_0' });
}
});
el.querySelector('#btn-back').addEventListener('click', () => { el.querySelector('#btn-back').addEventListener('click', () => {
audio.play('click'); // Back to the table itself, not a new copy of it on top of this card.
juice.hapticLight(); scene.switchWorld('play', true);
bus.emit('navigate', { world: 'play', scene: 'play-table' }); });
el.querySelector('#btn-analyze').addEventListener('click', () => {
if (!moveHistory.length) return;
scene.push('chess-review', { moveHistory, playerColor: params.playerColor || 'w', botId, result });
}); });
el.querySelector('#btn-share')?.addEventListener('click', () => { const pgnText = () => pgn || formatMoveHistory(moveHistory);
const pgnText = pgn || formatMoveHistory(moveHistory); el.querySelector('#btn-share').addEventListener('click', () => {
if (navigator.share) { if (navigator.share) {
navigator.share({ title: t('chess.share_title'), text: pgnText }); navigator.share({ title: t('chess.share_title'), text: pgnText() }).catch(() => {});
} else { } else {
navigator.clipboard?.writeText(pgnText); copyText(pgnText()).then(ok => ok && flashChip(el.querySelector('#btn-share')));
juice.pulseElement(el.querySelector('#btn-share'));
} }
}); });
el.querySelector('#btn-copy-pgn').addEventListener('click', () => {
el.querySelector('#btn-copy-pgn')?.addEventListener('click', () => { copyText(pgnText()).then(ok => {
const pgnText = pgn || formatMoveHistory(moveHistory);
navigator.clipboard?.writeText(pgnText).then(() => {
const btn = el.querySelector('#btn-copy-pgn'); const btn = el.querySelector('#btn-copy-pgn');
btn.innerHTML = `${icon('check', 16)}<span>${t('result.copied')}</span>`; if (!btn) return;
juice.pulseElement(btn, 'var(--win)'); if (ok) {
setTimeout(() => { btn.innerHTML = `${icon('copy', 16)}<span>${t('chess.copy_pgn')}</span>`; }, 2000); btn.innerHTML = `${gicon('check', 22)}<span>${t('result.copied')}</span>`;
haptic('success');
later(() => { btn.innerHTML = `${gicon('scroll', 22)}<span>${t('chess.chip_copy')}</span>`; }, 2000);
} else {
audio.play('error');
}
}); });
}); });
}
const player = store.get('player'); function flashChip(btn) {
if (player && coins > 0) { if (!btn) return;
store.set('player', { ...player, coins: (player.coins || 0) + coins, xp: (player.xp || 0) + xp }); juice.pulseElement(btn, 'var(--win)');
} haptic('success');
}
async function copyText(text) {
try {
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
} catch (e) {}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;opacity:0;inset-inline-start:-9999px;';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch (e) { return false; }
} }
function formatMoveHistory(history) { function formatMoveHistory(history) {
let html = ''; let out = '';
for (let i = 0; i < Math.min(history.length, 30); i += 2) { for (let i = 0; i < history.length; i += 2) {
const num = Math.floor(i / 2) + 1; out += `${Math.floor(i / 2) + 1}. ${history[i]?.san || ''} ${history[i + 1]?.san || ''} `;
html += `${num}.${history[i]?.san || ''} ${history[i+1]?.san || ''} `;
} }
if (history.length > 30) html += '...'; return out.trim();
return html;
} }
const RESULT_STYLE = ` const RESULT_STYLE = `
.rs { .rs {
position: relative;
min-height: 100%; min-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--s-5); /* The bottom clears the play button that stands out of the tab bar. */
padding: var(--s-6) var(--gutter) var(--s-8); padding-block: var(--s-4) 40px;
overflow: hidden;
}
.rs-table { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: var(--s-3); pointer-events: none; }
.rs-board { width: 100%; max-width: 520px; height: 100%; display: flex; align-items: center; justify-content: center; }
.rs-settled .rs-card, .rs-settled .rs-ribbon, .rs-settled .rs-sticker, .rs-settled .rs-actions, .rs-settled .rs-dim, .rs-settled .rs-toppled { animation: none; }
.rs-settled .rs-toppled { rotate: 90deg; }
.rs-dim {
position: absolute; inset: 0; pointer-events: none;
background: color-mix(in srgb, var(--stage) 82%, transparent);
animation: fadeIn 480ms var(--ease-out) both;
}
.rs-stage {
position: relative;
z-index: 1;
width: min(100%, 380px);
padding-inline: var(--gutter);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
} }
.rs-card { .rs-card {
width: min(86vw, 340px); width: 100%;
padding: 52px var(--s-6) 44px; margin-top: 26px;
padding: 38px var(--s-5) var(--s-4);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s-5); align-items: center;
gap: var(--s-2);
animation: flipIn 680ms var(--ease-deal) 120ms both;
} }
.rs-card .index { font-size: 13px; } .rs-ribbon {
position: absolute;
top: -24px;
inset-inline: 22px;
min-height: 54px;
padding-inline: 12px;
animation: slamIn 560ms var(--ease-out) 560ms both;
}
.rs-ribbon h1 { font-family: var(--font-display); font-size: var(--fs-2xl); line-height: 1.1; color: var(--text-primary); }
.rs-sticker { display: flex; align-items: center; justify-content: center; height: 118px; animation: bounceIn 620ms var(--ease-pop) 640ms both; }
.rs-win .rs-sticker .gi { animation: rsBob 2.4s ease-in-out 1.4s infinite; }
@keyframes rsBob { 0%, 100% { translate: 0 0; rotate: 0deg; } 50% { translate: 0 -6px; rotate: 3deg; } }
.rs-toppled { animation: rsTopple 900ms var(--ease-pop) 900ms both; transform-origin: 70% 90%; }
@keyframes rsTopple { 0% { rotate: 0deg; } 60% { rotate: 98deg; } 80% { rotate: 84deg; } 100% { rotate: 90deg; } }
.rs-face { text-align: center; display: flex; flex-direction: column; align-items: center; gap: var(--s-2); } .rs-reason {
.rs-outcome { font-size: var(--fs-3xl); font-weight: 900; line-height: 1.1; letter-spacing: -0.01em; transform-origin: 50% 60%; } display: inline-flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px;
.rs-outcome.rs-win { color: var(--red-ink); } text-align: center;
.rs-outcome.rs-loss { color: var(--ink); } font-size: var(--fs-sm); font-weight: 700; color: var(--ink-2);
.rs-outcome.rs-draw { color: var(--ink-2); } }
.rs-reason { font-size: var(--fs-sm); font-weight: 500; color: var(--ink-2); display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center; } .rs-dot { width: 5px; height: 5px; background: var(--ink-3); transform: rotate(45deg); flex-shrink: 0; }
.rs-dot { width: 4px; height: 4px; background: var(--ink-3); transform: rotate(45deg); flex-shrink: 0; }
/* Printed rows on the face, ruled with the card's own hairline. */ .rs-rows { width: 100%; display: flex; flex-direction: column; margin-top: var(--s-1); border-top: 2px solid var(--card-line); }
.rs-rows { display: flex; flex-direction: column; border-top: 1px solid var(--card-line); }
.rs-row { .rs-row {
display: flex; display: flex; align-items: center; justify-content: space-between; gap: var(--s-3);
align-items: center; min-height: 46px;
justify-content: space-between;
gap: var(--s-3);
min-height: 48px;
border-bottom: 1px solid var(--card-line); border-bottom: 1px solid var(--card-line);
} }
.rs-row:last-child { border-bottom: none; } .rs-row:last-child { border-bottom: none; }
.rs-row dt { display: inline-flex; align-items: center; gap: var(--s-2); font-size: var(--fs-sm); font-weight: 600; color: var(--ink-2); } .rs-row dt { display: inline-flex; align-items: center; gap: 8px; font-size: var(--fs-sm); font-weight: 700; color: var(--ink-2); }
.rs-row dt .i { color: var(--ink-3); } .rs-row dt .gi { width: 26px; height: 26px; }
.rs-row dd { display: inline-flex; align-items: baseline; gap: var(--s-2); } .rs-row dt .gi.rs-down { transform: rotate(180deg); }
.rs-value { font-size: var(--fs-lg); font-weight: 800; color: var(--ink); }
.rs-after { font-size: var(--fs-xs); font-weight: 600; color: var(--ink-3); }
.rs-rating.rs-up { color: var(--win-ink); }
.rs-rating.rs-down { color: var(--red-ink); }
.rs-rating.rs-flat { color: var(--ink-2); }
.rs-coin { display: inline-flex; } .rs-coin { display: inline-flex; }
.rs-row dt .rs-coin .i { color: var(--gold-ink); } .rs-pay { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding-top: 8px; }
.rs-cell {
display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 50px;
border-radius: 16px;
background: var(--card-2);
box-shadow: 0 3px 0 var(--card-line);
}
.rs-cell dt .gi { width: 30px; height: 30px; }
.rs-cell dd { display: flex; flex-direction: column; align-items: flex-start; gap: 0; line-height: 1; }
.rs-unit { font-size: var(--fs-xs); font-weight: 700; color: var(--ink-3); margin-top: 2px; }
.rs-row dd { display: inline-flex; align-items: baseline; gap: 8px; }
.rs-value { font-family: var(--font-display); font-size: var(--fs-xl); line-height: 1; color: var(--ink); }
.rs-value.up { color: var(--win-ink); }
.rs-value.down { color: var(--red-ink); }
.rs-coins { color: var(--gold-ink); } .rs-coins { color: var(--gold-ink); }
.rs-after { font-size: var(--fs-xs); font-weight: 700; color: var(--ink-3); }
.rs-moves { .rs-actions { width: 100%; display: flex; flex-direction: column; gap: var(--s-3); animation: slideUpBounce 520ms var(--ease-deal) 900ms both; }
width: min(86vw, 340px); .rs-chips { justify-content: center; flex-wrap: nowrap; gap: 8px; overflow: visible; }
font-size: var(--fs-xs); .rs-chips .chip { flex: 1 1 0; min-width: 0; justify-content: center; min-height: 44px; gap: 5px; padding-inline: 8px; white-space: nowrap; }
color: var(--text-muted); .rs-chips .chip span { overflow: hidden; text-overflow: ellipsis; }
white-space: nowrap; .rs-chips .chip .gi { width: 22px; height: 22px; }
overflow-x: auto;
scrollbar-width: none;
text-align: center;
}
.rs-moves::-webkit-scrollbar { display: none; }
.rs-actions { @media (max-height: 700px) {
width: min(86vw, 340px); .rs { padding-block: var(--s-2) 34px; }
display: flex; .rs-card { padding-top: 34px; gap: 4px; }
flex-direction: column; .rs-sticker { height: 76px; }
gap: var(--s-3); .rs-sticker .gi { width: 72px; height: 72px; }
.rs-row { min-height: 40px; }
.rs-cell { min-height: 44px; }
.rs-actions { gap: var(--s-2); }
.rs-actions .btn-xl { min-height: 62px; }
.rs-actions .btn-lg { min-height: 52px; }
} }
.rs-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s-2); }
.rs-chips .chip { min-height: 44px; }
`; `;
...@@ -12,6 +12,10 @@ import { icon } from '../../../core/icons.js'; ...@@ -12,6 +12,10 @@ import { icon } from '../../../core/icons.js';
import { screenHead, wireBack, esc } from '../../../core/ui.js'; import { screenHead, wireBack, esc } from '../../../core/ui.js';
let reviewData = null; let reviewData = null;
let reviewSeq = 0; // a review left half-way stops asking the engine
let reviewCache = null; // { key, results, applied } of the last finished review
export function unmountReview() { reviewSeq++; }
export async function mountReview(el, params) { export async function mountReview(el, params) {
const { moveHistory = [], playerColor = 'w', botId, result } = params; const { moveHistory = [], playerColor = 'w', botId, result } = params;
...@@ -37,72 +41,87 @@ export async function mountReview(el, params) { ...@@ -37,72 +41,87 @@ export async function mountReview(el, params) {
wireBack(el); wireBack(el);
// Start analyzing
await analyzeAllMoves(el, moveHistory, playerColor); await analyzeAllMoves(el, moveHistory, playerColor);
} }
/**
* Score every position once (the eval after move i is the eval before move
* i+1, so n moves need n+1 engine calls, not 2n), a few at a time. Scores
* come from the side to move and are turned to White's point of view.
*/
async function analyzeAllMoves(el, moves, playerColor) { async function analyzeAllMoves(el, moves, playerColor) {
const results = []; const seq = ++reviewSeq;
// Coming back from the analysis re-mounts this screen: reuse the last review.
const key = moves.map(m => m.from + m.to + (m.promotion || '')).join(' ');
if (reviewCache && reviewCache.key === key) {
reviewData = reviewCache.results;
renderReview(el, reviewCache.results, reviewCache.applied, playerColor);
return;
}
const chess = new Chess(); const chess = new Chess();
let prevEval = 0.2; // Slight white advantage at start const fens = [chess.fen()];
const applied = [];
for (const move of moves) {
const m = chess.move({ from: move.from, to: move.to, promotion: move.promotion });
if (!m) break;
applied.push(move);
fens.push(chess.fen());
}
const finalMate = chess.in_checkmate ? chess.in_checkmate() : chess.isCheckmate();
const evals = new Array(fens.length).fill(null);
const best = new Array(fens.length).fill(null);
const progressBar = el.querySelector('#progress-bar'); const progressBar = el.querySelector('#progress-bar');
const progressText = el.querySelector('#progress-text'); const progressText = el.querySelector('#progress-text');
let done = 0;
for (let i = 0; i < moves.length; i++) {
const fen = chess.fen(); const work = async (i) => {
const isWhiteMove = chess.turn() === 'w'; if (i === fens.length - 1 && finalMate) {
const move = moves[i]; evals[i] = fens[i].split(' ')[1] === 'w' ? -999 : 999;
} else {
// Apply move try {
chess.move({ from: move.from, to: move.to, promotion: move.promotion }); const a = await net.post('analysis.php', { fen: fens[i], depth: 12, lines: 1 });
const line = a?.lines?.[0];
// Get engine eval for position AFTER the move if (line) {
let evalAfter = prevEval; const pov = fens[i].split(' ')[1] === 'w' ? 1 : -1;
let bestEval = prevEval; evals[i] = (Number(line.evaluation) || 0) * pov;
let bestMove = null; best[i] = line.move || null;
}
try { } catch (e) {}
const analysis = await net.post('analysis.php', { fen: chess.fen(), depth: 12, lines: 1 });
if (analysis?.lines?.[0]) {
// Eval is from side-to-move perspective, flip it to white's perspective
evalAfter = analysis.lines[0].evaluation * (chess.turn() === 'w' ? 1 : -1);
}
} catch (e) {}
try {
const bestAnalysis = await net.post('analysis.php', { fen, depth: 12, lines: 1 });
if (bestAnalysis?.lines?.[0]) {
bestEval = bestAnalysis.lines[0].evaluation * (isWhiteMove ? 1 : -1);
bestMove = bestAnalysis.lines[0].move;
}
} catch (e) {
bestEval = evalAfter;
} }
done++;
if (seq !== reviewSeq) return;
const shown = Math.min(applied.length, done);
if (progressBar) progressBar.style.transform = `scaleX(${fens.length ? done / fens.length : 1})`;
if (progressText) progressText.textContent = `${shown}/${applied.length}`;
};
let next = 0;
await Promise.all(Array.from({ length: Math.min(4, fens.length) }, async () => {
while (next < fens.length && seq === reviewSeq) await work(next++);
}));
if (seq !== reviewSeq) return;
if (evals[0] == null) evals[0] = 0.2; // a slight White edge at the start
for (let i = 1; i < evals.length; i++) if (evals[i] == null) evals[i] = evals[i - 1];
const results = applied.map((move, i) => {
const isWhiteMove = i % 2 === 0;
const moveNum = Math.floor(i / 2) + 1; const moveNum = Math.floor(i / 2) + 1;
const classification = classifyMove(prevEval, evalAfter, bestEval, isWhiteMove, moveNum); return {
results.push({
san: move.san, san: move.san,
evalBefore: prevEval, evalBefore: evals[i],
evalAfter, evalAfter: evals[i + 1],
bestEval, bestEval: evals[i],
bestMove, bestMove: best[i],
classification, classification: classifyMove(evals[i], evals[i + 1], evals[i], isWhiteMove, moveNum),
isWhite: isWhiteMove isWhite: isWhiteMove,
}); };
});
prevEval = evalAfter;
// Update progress
const pct = Math.round(((i + 1) / moves.length) * 100);
progressBar.style.transform = `scaleX(${pct / 100})`;
progressText.textContent = `${i + 1}/${moves.length}`;
}
reviewData = results; reviewData = results;
renderReview(el, results, moves, playerColor); reviewCache = { key, results, applied };
renderReview(el, results, applied, playerColor);
} }
function renderReview(el, results, moves, playerColor) { function renderReview(el, results, moves, playerColor) {
...@@ -220,11 +239,11 @@ function renderEvalGraph(container, results) { ...@@ -220,11 +239,11 @@ function renderEvalGraph(container, results) {
canvas.style.height = height + 'px'; canvas.style.height = height + 'px';
canvas.style.display = 'block'; canvas.style.display = 'block';
const ink = getColor('ink', '#3A2112'); const ink = getColor('ink', '#1A1722');
const ink3 = getColor('ink_3', '#6F6858'); const ink3 = getColor('ink_3', '#7D768C');
const paper = getColor('card_2', '#F7E6C4'); const paper = getColor('card_2', '#F4F2F8');
const card = getColor('card', '#FFF6E2'); const card = getColor('card', '#FFFFFF');
const red = getColor('red_ink', '#C8262C'); const red = getColor('red_ink', '#F0384B');
const padding = 6; const padding = 6;
const graphW = width - padding * 2; const graphW = width - padding * 2;
......
...@@ -20,14 +20,19 @@ export const THEMES = [ ...@@ -20,14 +20,19 @@ export const THEMES = [
{ key: 'endgame', name: 'نهايات', nameEn: 'Endgame', icon: 'flag' }, { key: 'endgame', name: 'نهايات', nameEn: 'Endgame', icon: 'flag' },
{ key: 'promotion', name: 'ترقية', nameEn: 'Promotion', icon: 'chess-queen' }, { key: 'promotion', name: 'ترقية', nameEn: 'Promotion', icon: 'chess-queen' },
{ key: 'trapped', name: 'قطعة محاصرة', nameEn: 'Trapped Piece', icon: 'lock' }, { key: 'trapped', name: 'قطعة محاصرة', nameEn: 'Trapped Piece', icon: 'lock' },
{ key: 'zugzwang', name: 'تسوغزوانغ', nameEn: 'Zugzwang', icon: 'hourglass' }, { key: 'zugzwang', name: 'زوغزوانغ', nameEn: 'Zugzwang', icon: 'hourglass' },
{ key: 'tactics', name: 'تكتيك', nameEn: 'Tactics', icon: 'puzzle' }, { key: 'tactics', name: 'تكتيك', nameEn: 'Tactics', icon: 'puzzle' },
{ key: 'capture', name: 'أخذ', nameEn: 'Capture', icon: 'circle-x' }, { key: 'capture', name: 'أخذ', nameEn: 'Capture', icon: 'circle-x' },
{ key: 'center', name: 'المركز', nameEn: 'Center', icon: 'grid' }, { key: 'center', name: 'المركز', nameEn: 'Center', icon: 'grid' },
{ key: 'pawn', name: 'بيادق', nameEn: 'Pawns', icon: 'chess-pawn' }, { key: 'pawn', name: 'بيادق', nameEn: 'Pawns', icon: 'chess-pawn' },
{ key: 'en-passant', name: 'أخذ في المرور', nameEn: 'En Passant', icon: 'chess-pawn' }, { key: 'en-passant', name: 'الأخذ بالمرور', nameEn: 'En Passant', icon: 'chess-pawn' },
{ key: 'rook', name: 'طابية', nameEn: 'Rook', icon: 'chess-rook' }, { key: 'rook', name: 'طابية', nameEn: 'Rook', icon: 'chess-rook' },
{ key: 'stalemate', name: 'بات', nameEn: 'Stalemate', icon: 'ban' }, { key: 'stalemate', name: 'بات', nameEn: 'Stalemate', icon: 'ban' },
{ key: 'opening', name: 'افتتاحية', nameEn: 'Opening', icon: 'book' },
{ key: 'italian', name: 'الإيطالية', nameEn: 'Italian', icon: 'book' },
{ key: 'advance', name: 'تقدّم', nameEn: 'Advance', icon: 'trending-up' },
{ key: 'check', name: 'كش', nameEn: 'Check', icon: 'chess-king' },
{ key: 'queen', name: 'الوزير', nameEn: 'Queen', icon: 'chess-queen' },
]; ];
export function getThemeName(key, lang = 'ar') { export function getThemeName(key, lang = 'ar') {
......
import * as scene from '../../core/scene.js'; import * as scene from '../../core/scene.js';
import { mountPuzzle } from './scenes/puzzle.js'; import { mountPuzzle, unmountPuzzle } from './scenes/puzzle.js';
scene.register('puzzles', mountPuzzle); scene.register('puzzles', mountPuzzle, unmountPuzzle);
// Chess puzzles: a position on the board, one side to move, one best
// move (or a short line) to find. Solving it lands with a star burst and a
// jingle and grows a streak; a wrong move is played, refused with a shake,
// then taken back so the player can try again, see the answer, or move on.
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import { haptic } from '../../../core/feel.js';
import { t, getLang } from '../../../core/i18n.js'; import { t, getLang } from '../../../core/i18n.js';
import { ChessBoard } from '../../chess/canvas/board.js'; import { ChessBoard } from '../../chess/canvas/board.js';
import * as engine from '../../chess/logic/engine.js'; import * as engine from '../../chess/logic/engine.js';
import { icon } from '../../../core/icons.js'; import { pickPromotion, PROMO_STYLE } from '../../chess/components/promotion.js';
import { icon, gicon } from '../../../core/icons.js';
import { errorState, esc } from '../../../core/ui.js'; import { errorState, esc } from '../../../core/ui.js';
import { getThemeName, getThemeIcon, parseThemes } from '../logic/themes.js'; import { getThemeName, getThemeIcon, parseThemes } from '../logic/themes.js';
let board, puzzleData, moveIndex, solved; let board = null;
let puzzle = null; // { id, fen, moves: [uci...], rating, themes, playerColor }
let moveIndex = 0;
let state = 'idle'; // idle | play | busy | solved | failed
let token = 0; // bumps on every new puzzle and on unmount
let streak = 0; // solved in a row this session
const recent = []; // ids seen lately, so the same puzzle does not repeat
// The puzzles API returns one of the ten puzzles nearest a rating. Asking
// around a few ratings reaches the whole set instead of the same ten.
const RATING_PROBES = [700, 900, 1100, 1300];
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
export function unmountPuzzle() {
token++;
if (board) { board.destroy(); board = null; }
scene.exitGameMode();
if (window.__el3abPuzzle) delete window.__el3abPuzzle;
}
export async function mountPuzzle(el) { export async function mountPuzzle(el) {
scene.enterGameMode(); scene.enterGameMode();
board = null; // a board left from an earlier visit belongs to a detached element if (board) { board.destroy(); board = null; }
state = 'idle';
el.innerHTML = ` el.innerHTML = `
<div class="pz"> <div class="pz" id="pz">
<div class="pz-top"> <div class="pz-top">
<button class="icon-btn" id="back-btn" aria-label="${t('common.back')}">${icon('back', 20)}</button> <button class="icon-btn" data-back id="back-btn" aria-label="${t('common.back')}">${icon('back', 22)}</button>
<div class="pz-head"> <div class="pz-head">
<h1 class="pz-title">${t('puzzle.title')}</h1> <h1 class="pz-title">${t('puzzle.title')}</h1>
<div id="puzzle-themes" class="pz-themes"></div> <div id="puzzle-themes" class="pz-themes"></div>
</div> </div>
<span id="puzzle-rating" class="pz-rating"></span> <div id="puzzle-rating" class="pz-rating" hidden></div>
</div> </div>
<div class="pz-main"> <div id="puzzle-board" class="pz-board"><div id="promo-dialog" hidden></div></div>
<div id="puzzle-board" class="pz-board"></div> <div class="pz-foot">
<div class="pz-foot"> <div id="puzzle-status" class="pz-status" aria-live="polite"></div>
<div id="puzzle-status" class="pz-status" aria-live="polite"></div> <div id="pz-actions" class="pz-actions"></div>
</div>
</div> </div>
</div> </div>
<style>${PUZZLE_STYLE}</style> <style>${PUZZLE_STYLE}${PROMO_STYLE}</style>
`; `;
el.querySelector('#back-btn').addEventListener('click', () => { el.querySelector('#back-btn').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.exitGameMode(); // Back down the stack to the table it was opened from; straight to the
bus.emit('navigate', { world: 'play', scene: 'play-table' }); // table if there is nothing beneath.
const before = token;
scene.pop();
setTimeout(() => { if (token === before && el.isConnected) scene.switchWorld('play', true); }, 120);
}); });
exposeTestHook();
await loadPuzzle(el); await loadPuzzle(el);
} }
async function fetchPuzzle() {
let last = null;
const probes = [...RATING_PROBES].sort(() => Math.random() - 0.5);
for (const rating of probes) {
const data = await net.get('puzzles.php', { action: 'random', rating });
if (!data || data.error) throw new Error(data?.error || 'no puzzle');
last = data;
if (!recent.includes(data.id)) break;
}
if (last) {
recent.push(last.id);
if (recent.length > 8) recent.shift();
}
return last;
}
async function loadPuzzle(el) { async function loadPuzzle(el) {
const my = ++token;
const status = el.querySelector('#puzzle-status'); const status = el.querySelector('#puzzle-status');
const boardHost = el.querySelector('#puzzle-board'); const host = el.querySelector('#puzzle-board');
if (!el.isConnected || !status || !boardHost) return; // the player left while the timer ran if (!el.isConnected || !status || !host) return;
status.style.color = ''; state = 'idle';
setActions(el, '');
status.className = 'pz-status';
status.innerHTML = `<span class="pz-quiet">${t('common.loading')}</span>`; status.innerHTML = `<span class="pz-quiet">${t('common.loading')}</span>`;
if (!board) boardHost.innerHTML = '<div class="pz-skeleton skeleton" aria-hidden="true"></div>'; if (!board) host.insertAdjacentHTML('afterbegin', '<div class="pz-skeleton skeleton" aria-hidden="true"></div>');
try { try {
const data = await net.get('puzzles.php', { action: 'random' }); const data = await fetchPuzzle();
if (!data || data.error) throw new Error(data?.error || 'no puzzle'); if (my !== token) return;
puzzleData = data;
setupPuzzle(el, data); setupPuzzle(el, data);
} catch (e) { } catch (e) {
board = null; if (my !== token) return;
boardHost.innerHTML = errorState(t('common.error_load'), 'puzzle-retry'); if (board) { board.destroy(); board = null; }
host.querySelector('.pz-skeleton')?.remove();
host.insertAdjacentHTML('afterbegin', `<div class="pz-error">${errorState(t('common.error_load'), 'puzzle-retry')}</div>`);
status.innerHTML = ''; status.innerHTML = '';
boardHost.querySelector('#puzzle-retry')?.addEventListener('click', () => { host.querySelector('#puzzle-retry')?.addEventListener('click', () => {
audio.play('click'); host.querySelector('.pz-error')?.remove();
loadPuzzle(el); loadPuzzle(el);
}); });
} }
...@@ -71,137 +119,226 @@ async function loadPuzzle(el) { ...@@ -71,137 +119,226 @@ async function loadPuzzle(el) {
/** /**
* Two puzzle conventions exist, and the board has to know which one it holds. * Two puzzle conventions exist, and the board has to know which one it holds.
*
* - Lichess: the FEN is the position BEFORE the opponent's last move; moves[0] * - Lichess: the FEN is the position BEFORE the opponent's last move; moves[0]
* is that move, and the solution follows it. Always an even number of moves. * is that move, and the solution follows. Always an even number of moves.
* - The EL3AB puzzles table: the FEN is already the solver's turn and moves[0] * - The EL3AB puzzles table: the FEN is the solver's turn and moves[0] is the
* is the solver's first move. Always an odd number of moves. * solver's first move. Always an odd number of moves.
*
* The scene used to assume Lichess for every puzzle. With our own puzzles that
* meant the solution was played for you as the "opponent's" move, the board was
* turned to the wrong side (upside down), and nothing could be moved: the
* puzzle sat on "your turn" forever.
*/ */
function setupPuzzle(el, puzzle) { function setupPuzzle(el, data) {
const fen = puzzle.fen; const moves = String(data.moves || '').split(' ').filter(Boolean);
const moves = (puzzle.moves || '').split(' ').filter(Boolean); engine.create(data.fen);
const hasSetupMove = moves.length > 0 && moves.length % 2 === 0;
const playerColor = hasSetupMove ? (engine.turn() === 'w' ? 'b' : 'w') : engine.turn();
puzzle = { id: data.id, fen: data.fen, moves, rating: data.rating, themes: data.themes, playerColor, startIndex: hasSetupMove ? 1 : 0 };
moveIndex = 0; moveIndex = 0;
solved = false;
engine.create(fen);
const ratingEl = el.querySelector('#puzzle-rating'); const ratingEl = el.querySelector('#puzzle-rating');
ratingEl.innerHTML = puzzle.rating ratingEl.hidden = !data.rating;
? `${icon('target', 14)}<span class="sr-only">${t('puzzles.rating')}</span><span class="num">${esc(puzzle.rating)}</span>` ratingEl.innerHTML = data.rating ? `${gicon('puzzle', 26)}<span class="sr-only">${t('puzzles.rating')}</span><span class="num">${esc(data.rating)}</span>` : '';
: '';
const lang = getLang(); const lang = getLang();
const themes = parseThemes(puzzle.themes).slice(0, 3); el.querySelector('#puzzle-themes').innerHTML = parseThemes(data.themes).slice(0, 3).map(k =>
el.querySelector('#puzzle-themes').innerHTML = themes.map(k => `<span class="pz-theme">${getThemeIcon(k, 13)}${esc(getThemeName(k, lang))}</span>`).join('');
`<span class="pz-theme">${getThemeIcon(k, 12)}${esc(getThemeName(k, lang))}</span>`).join('');
const hasSetupMove = moves.length > 0 && moves.length % 2 === 0;
const playerColor = hasSetupMove
? (engine.turn() === 'w' ? 'b' : 'w')
: engine.turn();
let setup = null;
if (hasSetupMove) {
try { setup = engine.moveUci(moves[0]); } catch (e) { setup = null; }
moveIndex = 1;
}
const boardContainer = el.querySelector('#puzzle-board'); const host = el.querySelector('#puzzle-board');
boardContainer.innerHTML = ''; host.querySelector('.pz-skeleton')?.remove();
board = new ChessBoard(boardContainer, { if (board) { board.destroy(); board = null; }
board = new ChessBoard(host, {
flipped: playerColor === 'b', flipped: playerColor === 'b',
interactive: true, interactive: true,
onMove: (from, to) => handlePuzzleMove(el, from, to, moves, playerColor) observe: el.querySelector('#pz'),
maxHeight: () => puzzleRoom(el),
onMove: (from, to) => onBoardMove(el, from, to),
onPick: () => { audio.play('tap', 'game', { volume: 0.7 }); haptic('light'); },
onIllegal: () => { audio.play('error'); haptic('error'); },
}); });
board.canSelect = (piece) => state === 'play' && (piece === piece.toUpperCase() ? 'w' : 'b') === playerColor;
board.showLegalMoves = (square) => { board.highlights.legalMoves = engine.legalMoves(square); };
board.setPosition(engine.fen());
// Pieces arrive as FEN letters: upper case is White, lower case is Black. if (hasSetupMove) {
// This compared the first letter with 'w' / 'b', which no piece ever matches, // The opponent's last move is played on the board first.
// so no piece could be picked up. const my = token;
board.canSelect = (piece) => !solved && (piece === piece.toUpperCase() ? 'w' : 'b') === playerColor; state = 'busy';
board.showLegalMoves = (square) => { setTimeout(() => {
board.highlights.legalMoves = engine.legalMoves(square); if (my !== token || !board) return;
}; const setup = engine.moveUci(moves[0]);
if (setup) board.playMove(setup, engine.fen(), { onLand: () => audio.play(setup.captured ? 'capture' : 'move', 'game') });
moveIndex = 1;
state = 'play';
}, 500);
} else {
state = 'play';
}
showPlayStatus(el);
audio.play('deal');
}
board.setPosition(engine.fen()); function puzzleRoom(el) {
if (setup) board.setLastMove(setup.from, setup.to); const root = el.querySelector('#pz');
if (!root) return 0;
const used = (el.querySelector('.pz-top')?.getBoundingClientRect().height || 0)
+ (el.querySelector('.pz-foot')?.getBoundingClientRect().height || 0) + 24;
return Math.max(180, root.clientHeight - used);
}
function showPlayStatus(el) {
const status = el.querySelector('#puzzle-status'); const status = el.querySelector('#puzzle-status');
status.innerHTML = `<span class="pz-side ${playerColor}" aria-hidden="true"></span><span>${t(playerColor === 'w' ? 'puzzles.white_to_move' : 'puzzles.black_to_move')}</span>`; status.className = 'pz-status';
status.style.color = 'var(--text-primary)'; status.innerHTML = `
<span class="pz-side ${puzzle.playerColor}" aria-hidden="true"></span>
<span class="pz-say">
<span class="pz-turn">${t(puzzle.playerColor === 'w' ? 'puzzles.white_to_move' : 'puzzles.black_to_move')}</span>
<span class="pz-hint">${t('puzzle.find_best')}</span>
</span>
${streak > 0 ? `<span class="pz-streak">${gicon('flame', 24)}<span class="num">${streak}</span></span>` : ''}`;
setActions(el, '');
} }
function handlePuzzleMove(el, from, to, moves, playerColor) { function setActions(el, html) {
if (solved || moveIndex >= moves.length) return; const a = el.querySelector('#pz-actions');
if (a) a.innerHTML = html;
}
const expectedMove = moves[moveIndex]; function onBoardMove(el, from, to) {
const expectedFrom = expectedMove.substring(0, 2); if (state !== 'play') return;
const expectedTo = expectedMove.substring(2, 4); const piece = engine.get().get(from);
if (piece && piece.type === 'p' && (to[1] === '8' || to[1] === '1')) {
const my = token;
state = 'busy';
pickPromotion(el.querySelector('#promo-dialog'), piece.color).then(choice => {
if (my !== token) return;
state = 'play';
if (choice) playerMove(el, from, to, choice);
else board?.draw();
});
return;
}
playerMove(el, from, to);
}
if (from === expectedFrom && to === expectedTo) { function playerMove(el, from, to, promotion) {
const promo = expectedMove.length > 4 ? expectedMove[4] : undefined; const expected = puzzle.moves[moveIndex];
engine.move(from, to, promo); const m = engine.move(from, to, promotion);
board.setPosition(engine.fen()); if (!m) { board.setPosition(engine.fen()); return; }
board.setLastMove(from, to); const uci = m.from + m.to + (m.promotion || '');
audio.play('move', 'game'); const exp = String(expected || '').toLowerCase();
moveIndex++; const isLast = moveIndex === puzzle.moves.length - 1;
// Any mate on the final move is a solution, not only the one in the data.
const right = uci === exp || (exp.length === 4 && uci.slice(0, 4) === exp && !m.promotion) || (isLast && engine.isCheckmate());
if (moveIndex >= moves.length) { state = 'busy';
onSolved(el); const my = token;
return; board.playMove(m, engine.fen(), { onLand: () => audio.play(m.captured ? 'capture' : 'move', 'game') });
} board.setCheck(engine.isCheck() ? engine.kingSquare(engine.turn()) : null);
setTimeout(() => { if (!right) { setTimeout(() => { if (my === token) onFailed(el); }, 380); return; }
const response = moves[moveIndex];
const rf = response.substring(0, 2); moveIndex++;
const rt = response.substring(2, 4); if (moveIndex >= puzzle.moves.length) { setTimeout(() => { if (my === token) onSolved(el, m.to); }, 260); return; }
const rp = response.length > 4 ? response[4] : undefined;
engine.move(rf, rt, rp); // The opponent answers, then it is the player's move again.
board.setPosition(engine.fen()); haptic('light');
board.setLastMove(rf, rt); setTimeout(() => {
audio.play('move', 'game'); if (my !== token || !board) return;
moveIndex++; const reply = engine.moveUci(puzzle.moves[moveIndex]);
moveIndex++;
if (moveIndex >= moves.length) { if (reply) {
onSolved(el); board.playMove(reply, engine.fen(), { onLand: () => audio.play(reply.captured ? 'capture' : 'move', 'game') });
} board.setCheck(engine.isCheck() ? engine.kingSquare(engine.turn()) : null);
}, 500); }
} else { if (moveIndex >= puzzle.moves.length) onSolved(el, reply?.to);
onFailed(el); else state = 'play';
} }, 520);
} }
function onSolved(el) { function onSolved(el, square) {
solved = true; state = 'solved';
streak++;
board.interactive = false; board.interactive = false;
audio.play('win', 'reward'); audio.play('win', 'reward');
juice.hapticSuccess(); haptic('success');
const c = square && board ? board.squareCenterClient(square) : null;
if (c) {
juice.starBurst(c.x, c.y, 12);
juice.confetti(c.x, c.y, 26);
}
const status = el.querySelector('#puzzle-status'); const status = el.querySelector('#puzzle-status');
status.innerHTML = `${icon('check-circle', 20)}<span>${t('puzzle.correct')}</span>`; status.className = 'pz-status pz-good';
status.style.color = 'var(--win)'; status.innerHTML = `${gicon('check', 40)}<span class="pz-say"><span class="pz-turn">${t('puzzle.correct')}</span>
juice.slamIn(status, { scale: 1.4, duration: 420 }); <span class="pz-hint">${t('puzzle.streak', { n: `<span class="num">${streak}</span>` })}</span></span>`;
juice.slamIn(status, { scale: 1.3, duration: 420 });
setTimeout(() => { setActions(el, `<button class="btn btn-primary btn-lg btn-block" id="pz-next">${gicon('puzzle', 34)}<span>${t('puzzle.next')}</span></button>`);
loadPuzzle(el); el.querySelector('#pz-next').addEventListener('click', () => loadPuzzle(el));
}, 2000);
} }
function onFailed(el) { function onFailed(el) {
solved = true; state = 'failed';
board.interactive = false; streak = 0;
audio.play('lose', 'game'); audio.play('lose', 'game');
juice.hapticError(); haptic('error');
juice.shake(el.querySelector('#puzzle-board'), 4, 260); juice.shake(el.querySelector('#puzzle-board'), 5, 300);
const status = el.querySelector('#puzzle-status'); const status = el.querySelector('#puzzle-status');
status.innerHTML = `${icon('circle-x', 20)}<span>${t('puzzle.wrong')}</span>`; status.className = 'pz-status pz-bad';
status.style.color = 'var(--loss)'; status.innerHTML = `${gicon('cross', 40)}<span class="pz-say"><span class="pz-turn">${t('puzzle.wrong')}</span>
<span class="pz-hint">${t('puzzle.try_hint')}</span></span>`;
setActions(el, `
<div class="pz-row">
<button class="btn btn-secondary btn-lg" id="pz-retry">${gicon('refresh', 30)}<span>${t('puzzle.retry')}</span></button>
<button class="btn btn-primary btn-lg" id="pz-next">${gicon('puzzle', 30)}<span>${t('puzzle.next')}</span></button>
</div>
<button class="chip pz-chip" id="pz-solution">${gicon('search', 22)}<span>${t('puzzle.show_solution')}</span></button>`);
// The wrong move is taken back so the position can be tried again.
const my = token;
setTimeout(() => { setTimeout(() => {
loadPuzzle(el); if (my !== token || !board) return;
}, 2000); const wrong = engine.undo();
board.setPosition(engine.fen());
// The wrong move's squares are no longer the last move on the board.
const prev = engine.history().slice(-1)[0];
board.setLastMove(prev ? prev.from : null, prev ? prev.to : null);
board.setCheck(engine.isCheck() ? engine.kingSquare(engine.turn()) : null);
if (wrong) audio.play('swish');
}, 700);
el.querySelector('#pz-retry').addEventListener('click', () => {
if (!board) return;
state = 'play';
board.interactive = true;
showPlayStatus(el);
});
el.querySelector('#pz-next').addEventListener('click', () => loadPuzzle(el));
el.querySelector('#pz-solution').addEventListener('click', () => showSolution(el));
}
/** Play the rest of the answer on the board, one move at a time. */
async function showSolution(el) {
const my = token;
state = 'busy';
setActions(el, `<button class="btn btn-primary btn-lg btn-block" id="pz-next">${gicon('puzzle', 34)}<span>${t('puzzle.next')}</span></button>`);
el.querySelector('#pz-next').addEventListener('click', () => loadPuzzle(el));
await sleep(250);
while (my === token && board && moveIndex < puzzle.moves.length) {
const m = engine.moveUci(puzzle.moves[moveIndex++]);
if (!m) break;
board.playMove(m, engine.fen(), { onLand: () => audio.play(m.captured ? 'capture' : 'move', 'game') });
board.setCheck(engine.isCheck() ? engine.kingSquare(engine.turn()) : null);
await sleep(700);
}
}
/** Read-only state for tests/play/chess.mjs, when localStorage.el3ab_test === '1'. */
function exposeTestHook() {
try { if (localStorage.getItem('el3ab_test') !== '1') return; } catch (e) { return; }
window.__el3abPuzzle = {
id: () => puzzle?.id || null,
fen: () => engine.fen(),
state: () => state,
playerColor: () => puzzle?.playerColor || null,
flipped: () => !!board?.flipped,
busy: () => !!board?.isAnimating() || state === 'busy',
};
} }
const PUZZLE_STYLE = ` const PUZZLE_STYLE = `
...@@ -212,59 +349,68 @@ const PUZZLE_STYLE = ` ...@@ -212,59 +349,68 @@ const PUZZLE_STYLE = `
max-width: 560px; max-width: 560px;
height: 100%; height: 100%;
margin-inline: auto; margin-inline: auto;
padding-top: env(safe-area-inset-top, 0px); padding-top: max(var(--s-2), env(safe-area-inset-top, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px); padding-bottom: max(var(--s-2), env(safe-area-inset-bottom, 0px));
justify-content: center;
overflow: hidden;
} }
.pz-top { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-3) var(--gutter) var(--s-2); } .pz-top { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-2) var(--gutter); }
.pz-head { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } .pz-head { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.pz-title { font-size: var(--fs-lg); font-weight: 800; line-height: 1.2; color: var(--text-primary); } .pz-title { font-family: var(--font-display); font-size: var(--fs-xl); line-height: 1.2; color: var(--text-primary); white-space: nowrap; }
.pz-themes { display: flex; flex-wrap: wrap; gap: 6px; min-height: 0; } .pz-themes { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
.pz-theme { .pz-theme {
display: inline-flex; display: inline-flex; align-items: center; gap: 4px;
align-items: center; padding: 3px 10px;
gap: 4px; border-radius: var(--r-full);
padding: 1px 8px; background: var(--back-800);
border-radius: var(--r-sm);
border: 1px solid var(--back-line-strong);
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--fs-xs); font-size: var(--fs-xs);
font-weight: 600; font-weight: 700;
} }
.pz-rating { .pz-rating {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex; align-items: center; gap: 4px;
align-items: center; height: 46px;
gap: 6px; padding-inline: 8px 12px;
height: 32px; border-radius: 14px;
padding-inline: 10px;
border-radius: var(--r-md);
background: var(--back-800); background: var(--back-800);
border: 1px solid var(--back-line); box-shadow: 0 4px 0 var(--back-950);
color: var(--text-primary); color: var(--gold);
font-size: var(--fs-sm); font-family: var(--font-display);
font-weight: 700; font-size: var(--fs-lg);
} }
.pz-rating:empty { display: none; } .pz-rating[hidden] { display: none; }
.pz-rating .i { color: var(--text-secondary); }
.pz-main { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: center; padding-bottom: var(--s-8); } .pz-board { position: relative; flex: 0 0 auto; display: flex; align-items: center; justify-content: center; padding: 4px var(--s-2) 10px; min-height: 200px; }
.pz-board { flex: 0 0 auto; display: flex; align-items: center; justify-content: center; padding: var(--s-2); } .pz-skeleton { width: min(100%, 420px); aspect-ratio: 1; border-radius: 20px; }
.pz-skeleton { width: min(100%, 500px); aspect-ratio: 1; border-radius: 4px; } .pz-error { width: 100%; }
.pz-foot { padding: var(--s-3) var(--gutter) 0; } .pz-foot { padding: var(--s-2) var(--gutter) 0; display: flex; flex-direction: column; gap: var(--s-3); }
.pz-status { .pz-status {
min-height: 56px; min-height: 64px;
display: flex; display: flex; align-items: center; justify-content: center; gap: var(--s-3);
align-items: center; padding: 8px 14px;
justify-content: center; border-radius: 20px;
gap: var(--s-2); background: var(--back-800);
text-align: center; box-shadow: 0 4px 0 var(--back-950);
font-size: var(--fs-md);
font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
.pz-quiet { font-size: var(--fs-sm); font-weight: 500; color: var(--text-muted); } .pz-status.pz-good { background: var(--btn-green-2); box-shadow: 0 4px 0 var(--btn-green-edge); }
.pz-side { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; border: 1px solid var(--back-line-strong); } .pz-status.pz-bad { background: var(--btn-red-2); box-shadow: 0 4px 0 var(--btn-red-edge); }
.pz-side.w { background: var(--card); } .pz-say { display: flex; flex-direction: column; align-items: flex-start; gap: 0; min-width: 0; }
.pz-side.b { background: var(--ink); } .pz-turn { font-family: var(--font-display); font-size: var(--fs-lg); line-height: 1.2; color: var(--text-primary); }
.pz-hint { font-size: var(--fs-xs); font-weight: 700; color: var(--text-secondary); }
.pz-good .pz-hint, .pz-bad .pz-hint { color: var(--text-primary); opacity: 0.85; }
.pz-quiet { font-size: var(--fs-sm); font-weight: 600; color: var(--text-muted); }
.pz-side { width: 30px; height: 30px; border-radius: 10px; flex-shrink: 0; }
.pz-side.w { background: var(--card); box-shadow: 0 3px 0 var(--card-edge); }
.pz-side.b { background: var(--back-950); box-shadow: 0 3px 0 var(--back-600); }
.pz-streak { display: inline-flex; align-items: center; gap: 2px; margin-inline-start: auto; font-family: var(--font-display); font-size: var(--fs-lg); color: var(--gold); }
.pz-actions { display: flex; flex-direction: column; align-items: center; gap: var(--s-2); min-height: 0; }
.pz-actions:empty { display: none; }
.pz-row { display: flex; gap: var(--s-2); width: 100%; }
.pz-row .btn { flex: 1; padding-inline: 10px; min-width: 0; }
.pz-chip { min-height: 44px; gap: 6px; }
.pz-chip .gi { width: 22px; height: 22px; }
`; `;
/**
* Chess and puzzles, played like a player: real taps and drags on real
* squares, through the real UI, against the local server.
*
* node tests/play/chess.mjs [--base http://127.0.0.1:8080] [--out <dir for screenshots>]
* [--only bot,online,puzzles,layout] [--width 390 --height 844] [--headful]
*
* What it does:
* bot Home -> chess -> vs computer -> Amina -> 10+0. Plays a few real moves
* against the real bot (ours chosen by Stockfish), flips the board,
* offers a draw (refused this early), resigns, checks the result
* card, opens the review and the move-by-move analysis, returns home.
* Then a second game with the bot's replies scripted at the network
* (its /api/bots.php answers are replaced, nothing in the app is),
* which forces castling, captures, a promotion through the picker
* and a mate: the win card, coins, and "back to table".
* online Two throwaway accounts in two browser contexts queue the same clock,
* get matched, and play a line with captures, checks and castling.
* Both boards must stay identical after every move. A draw offer is
* made and declined, one player refreshes mid-game and must rejoin
* the same position on the right side, then one resigns and both
* see the result. Then both press "play again" (a new search at the
* same clock), draw by threefold repetition, play again and agree a
* draw, and finally play a bullet game that White loses on time
* ("quick" skips these).
* puzzles One puzzle solved, one failed (then retried).
* layout The game, the result card and a puzzle at 360x640, 390x844 and
* 430x932: nothing overflows sideways, the board is whole and as
* large as fits, the controls and the result buttons need no scroll.
*
* The position is read through window.__el3abChess / __el3abPuzzle, read-only
* hooks that exist only when localStorage.el3ab_test === '1'.
*
* Writes to the Supabase the dev server points at (production). Every row it
* creates (matches, mp_log, matchmaking_queue, rating_history, achievements,
* transactions, profiles, auth users) is deleted in a finally block.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
const args = process.argv.slice(2);
const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const BASE = argOf('--base', 'http://127.0.0.1:8080');
const OUT = argOf('--out', path.join(os.tmpdir(), 'el3ab-play-chess'));
const ONLY = (argOf('--only', 'bot,online,puzzles,layout') || '').split(',').filter(Boolean);
const W = +argOf('--width', 390), H = +argOf('--height', 844);
const HEADFUL = args.includes('--headful');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = process.env.SUPABASE_SERVICE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
const STOCKFISH = 'https://stockfishapi.caprover.al-arcade.com';
const TAG = 'ZZZ-PLAYTEST';
// chess.js for the test's own bookkeeping (the same build the app loads).
const src = fs.readFileSync(new URL('../../public/js/lib/chess.min.js', import.meta.url), 'utf8');
const mod = { exports: {} };
new Function('module', 'exports', 'globalThis', src)(mod, mod.exports, globalThis);
const Chess = globalThis.Chess || mod.exports.Chess || mod.exports;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const failures = [];
const notes = [];
const created = { users: [] };
const shots = [];
function check(label, cond, detail = '') {
if (cond) { console.log(` ok ${label}`); return true; }
failures.push(label + (detail ? ` (${detail})` : ''));
console.log(` FAIL ${label}${detail ? ' -- ' + detail : ''}`);
return false;
}
// ── Supabase: setup and teardown only ────────────────────────────────────
async function sb(p, { method = 'GET', body } = {}) {
const res = 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 res.text();
try { return JSON.parse(text); } catch { return text; }
}
async function createAccount(i) {
const r = await fetch(`${BASE}/api/auth.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'guest' }),
}).then(x => x.json());
if (!r.access_token || !r.profile?.id) throw new Error('account creation failed: ' + JSON.stringify(r).slice(0, 160));
const acc = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
created.users.push(acc);
const names = ['سارة القطب', 'كريم ناصر', 'ليلى السيد'];
await sb(`profiles?id=eq.${acc.id}`, {
method: 'PATCH',
body: { display_name: `${names[i % names.length]}`, username: `${TAG.toLowerCase()}-${acc.id.slice(0, 8)}`, elo_rapid: 1450 + i * 40, elo_classical: 1500 + i * 40, elo_blitz: 1400, coins: 1200, level: 4 },
});
acc.profile = (await sb(`profiles?id=eq.${acc.id}&select=*`))?.[0] || r.profile;
return acc;
}
async function teardown() {
console.log('\n=== teardown ===');
const matchIds = new Set();
for (const u of created.users) {
const own = await sb(`matches?or=(white_player_id.eq.${u.id},black_player_id.eq.${u.id})&select=id,match_type,time_control,status,result,move_count,white_player_id,black_player_id`);
if (process.env.TRACE && Array.isArray(own)) own.forEach(m => console.log(' match', JSON.stringify(m)));
if (Array.isArray(own)) {
for (const m of own) {
await sb(`mp_log?match_id=eq.${m.id}`, { method: 'DELETE' });
await sb(`rating_history?match_id=eq.${m.id}`, { method: 'DELETE' });
await sb(`chat_messages?channel_id=eq.${m.id}`, { method: 'DELETE' });
await sb(`matchmaking_queue?match_id=eq.${m.id}`, { method: 'DELETE' });
matchIds.add(m.id);
}
}
}
for (const u of created.users) {
for (const p of [
`matchmaking_queue?player_id=eq.${u.id}`,
`mp_log?player_id=eq.${u.id}`,
`rating_history?player_id=eq.${u.id}`,
`rating_history?opponent_id=eq.${u.id}`,
`player_achievements?player_id=eq.${u.id}`,
`economy_transactions?player_id=eq.${u.id}`,
`chat_messages?sender_id=eq.${u.id}`,
`notifications?user_id=eq.${u.id}`,
`activity_feed?actor_id=eq.${u.id}`,
`player_game_ratings?player_id=eq.${u.id}`,
`leaderboards?player_id=eq.${u.id}`,
`matches?white_player_id=eq.${u.id}`,
`matches?black_player_id=eq.${u.id}`,
]) await sb(p, { method: 'DELETE' }).catch(() => {});
}
const stuck = [];
for (const u of created.users) {
for (let attempt = 0; attempt < 3; attempt++) {
await sb(`profiles?id=eq.${u.id}`, { method: 'DELETE' });
const left = await sb(`profiles?id=eq.${u.id}&select=id`);
if (!Array.isArray(left) || left.length === 0) break;
if (attempt === 2) stuck.push(u.id);
await sleep(400);
}
await fetch(`${SB}/auth/v1/admin/users/${u.id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
}
// Confirm nothing is left behind.
let leftovers = 0;
for (const u of created.users) {
const m = await sb(`matches?or=(white_player_id.eq.${u.id},black_player_id.eq.${u.id})&select=id`);
const q = await sb(`matchmaking_queue?player_id=eq.${u.id}&select=id`);
const l = await sb(`mp_log?player_id=eq.${u.id}&select=id&limit=1`);
leftovers += (Array.isArray(m) ? m.length : 0) + (Array.isArray(q) ? q.length : 0) + (Array.isArray(l) ? l.length : 0);
}
console.log(` removed ${matchIds.size} match(es), their logs and queue rows, ${created.users.length - stuck.length}/${created.users.length} account(s)`);
if (stuck.length || leftovers) {
failures.push('teardown left rows behind');
console.log(` COULD NOT REMOVE: accounts ${stuck.join(', ') || '-'}; ${leftovers} other row(s)`);
}
}
// ── Browser ─────────────────────────────────────────────────────────────
let browser;
async function openPlayer(acc, label, { width = W, height = H, scriptBot = false } = {}) {
const context = await browser.createBrowserContext();
const page = await context.newPage();
page.label = label;
page.errors = [];
page.where = 'boot';
page.on('console', m => {
const text = m.text();
if (m.type() === 'error') page.errors.push(`[${label}:${page.where}] ${text.slice(0, 240)}`);
if ((m.type() === 'warn' || m.type() === 'warning') && text.includes('[i18n] missing')) page.errors.push(`[${label}:${page.where}] ${text}`);
});
page.on('pageerror', e => page.errors.push(`[${label}:${page.where}] PAGEERROR ${String(e).slice(0, 240)}`));
if (process.env.TRACE) page.on('response', async r => {
if (/matchmaking|game\.php/.test(r.url()) && r.request().method() === 'POST' && !/heartbeat/.test(r.request().postData() || '')) {
let body = ''; try { body = (await r.text()).slice(0, 160); } catch {}
console.log(` [${label}] ${r.status()} ${r.url().split('/api/')[1]} ${(r.request().postData() || '').slice(0, 80)} -> ${body}`);
}
});
page.on('requestfailed', r => {
const u = r.url();
if (u.includes('/api/') && !/aborted/i.test(r.failure()?.errorText || '')) page.errors.push(`[${label}:${page.where}] request failed ${u.slice(0, 120)} ${r.failure()?.errorText}`);
});
await page.setViewport({ width, height, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
// Scripted bot replies (the second bot game only): /api/bots.php answers
// from a queue instead of the engine. The app itself is untouched.
page.botScript = [];
await page.setRequestInterception(true);
page.on('request', req => {
if (req.isInterceptResolutionHandled()) return;
if (page.botScript.length && req.url().includes('/api/bots.php') && req.method() === 'POST') {
let body = {};
try { body = JSON.parse(req.postData() || '{}'); } catch {}
if (body.action === 'move') {
const best = page.botScript.shift();
return req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ best_move: best, evaluation: 0, depth: 1 }) });
}
}
req.continue();
});
await page.goto(BASE + '/public/css/tokens.css');
await page.evaluate((a) => {
localStorage.clear();
localStorage.setItem('el3ab_test', '1');
localStorage.setItem('el3ab_state', JSON.stringify({
auth: { token: a.token, refreshToken: a.refresh, userId: a.id, isGuest: false, guestId: null },
player: a.profile, language: 'ar', activeWorld: 'play', audioEnabled: true,
}));
}, acc);
await page.goto(BASE + '/', { waitUntil: 'networkidle2', timeout: 60000 });
await page.waitForSelector('.hand-card[data-game="chess"]', { timeout: 20000 });
page.where = 'home';
await sleep(800);
return page;
}
async function shot(page, name) {
const file = path.join(OUT, `${name}.png`);
await page.screenshot({ path: file });
shots.push(file);
}
/**
* Tap an element the way a finger would: wait for it to be on screen and done
* moving (cards are dealt in, scenes slide), then press its centre.
*/
async function click(page, selector, { timeout = 15000 } = {}) {
await page.waitForSelector(selector, { visible: true, timeout });
const until = Date.now() + timeout;
let prev = null;
while (Date.now() < until) {
const state = await page.evaluate((sel) => {
const e = document.querySelector(sel);
if (!e) return null;
let r = e.getBoundingClientRect();
// Off screen: the player scrolls to it first.
if (r.bottom > innerHeight - 90 || r.top < 0) { e.scrollIntoView({ block: 'center' }); r = e.getBoundingClientRect(); }
const moving = [e, ...ancestors(e)].some(n => n.getAnimations().some(a => a.playState === 'running'));
function ancestors(n) { const out = []; while ((n = n.parentElement)) out.push(n); return out; }
return { x: r.left + r.width / 2, y: r.top + r.height / 2, moving };
}, selector);
if (state && !state.moving && prev && Math.abs(prev.x - state.x) < 0.5 && Math.abs(prev.y - state.y) < 0.5) {
await page.mouse.click(state.x, state.y);
return;
}
prev = state;
await sleep(80);
}
throw new Error('never settled: ' + selector);
}
async function page_wait(page, selector) {
return page.waitForSelector(selector, { visible: true, timeout: 15000 });
}
async function waitFor(page, fn, { timeout = 20000, interval = 150, args = [] } = {}) {
const until = Date.now() + timeout;
let last;
while (Date.now() < until) {
last = await page.evaluate(fn, ...args).catch(() => null);
if (last) return last;
await sleep(interval);
}
return last;
}
/** Pick chess on the fanned hand (without playing it), then press a lobby button. */
async function homeToChess(page) {
await page.waitForSelector('.hand-card[data-game="chess"]', { timeout: 20000 });
const raised = await page.$eval('.hand-card[data-game="chess"]', c => c.getAttribute('aria-checked') === 'true');
if (!raised) { await click(page, '.hand-card[data-game="chess"]'); await sleep(700); }
}
// ── The board, through real pointer input ───────────────────────────────
async function squareXY(page, sq, hook = '__el3abChess') {
return page.evaluate((sq, hook) => {
const c = document.querySelector('#board-container canvas, #puzzle-board canvas');
const r = c.getBoundingClientRect();
const flipped = window[hook].flipped();
const f = sq.charCodeAt(0) - 97, rk = +sq[1] - 1;
const col = flipped ? 7 - f : f, row = flipped ? rk : 7 - rk;
const s = r.width / 8;
return { x: r.left + (col + 0.5) * s, y: r.top + (row + 0.5) * s };
}, sq, hook);
}
async function dragMove(page, from, to, hook) {
const a = await squareXY(page, from, hook);
const b = await squareXY(page, to, hook);
await page.mouse.move(a.x, a.y);
await page.mouse.down();
for (let i = 1; i <= 10; i++) await page.mouse.move(a.x + (b.x - a.x) * i / 10, a.y + (b.y - a.y) * i / 10);
await sleep(40);
await page.mouse.up();
}
async function tapMove(page, from, to, hook) {
const a = await squareXY(page, from, hook);
const b = await squareXY(page, to, hook);
await page.mouse.click(a.x, a.y);
await sleep(120);
await page.mouse.click(b.x, b.y);
}
/** Play a UCI move as a player: alternate drags and taps; pick a promotion in the picker. */
let moveStyle = 0;
async function play(page, uci, hook = '__el3abChess') {
const from = uci.slice(0, 2), to = uci.slice(2, 4), promo = uci[4];
if (moveStyle++ % 2 === 0) await dragMove(page, from, to, hook); else await tapMove(page, from, to, hook);
if (promo) {
const picker = await page.waitForSelector(`#promo-dialog:not([hidden]) [data-piece="${promo}"]`, { visible: true, timeout: 5000 }).catch(() => null);
check(`promotion picker opens for ${uci}`, !!picker);
if (picker) {
await shot(page, 'promotion-picker');
await click(page, `#promo-dialog [data-piece="${promo}"]`);
}
}
}
async function fenOf(page, hook = '__el3abChess') {
return page.evaluate((h) => window[h]?.fen() || null, hook);
}
async function idle(page, hook = '__el3abChess', timeout = 20000) {
return waitFor(page, (h) => window[h] && !window[h].busy(), { timeout, args: [hook] });
}
/** Our move in a real game: Stockfish's choice, or castling when it is on offer. */
async function engineMove(fen) {
const g = new Chess(fen);
const castle = g.moves({ verbose: true }).find(m => m.flags.includes('k'));
if (castle) return castle.from + castle.to;
try {
const r = await fetch(`${STOCKFISH}/api/chess/analyze`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fen, depth: 10, lines: 1 }),
}).then(x => x.json());
if (r?.lines?.[0]?.move) return r.lines[0].move;
} catch {}
const any = g.moves({ verbose: true })[0];
return any.from + any.to + (any.promotion || '');
}
function collectErrors(...pages) {
return pages.flatMap(p => p?.errors || []);
}
// ===========================================================================
async function testBot(acc) {
console.log('\n=== bot game 1: the real bot, resign, review, analysis ===');
const page = await openPlayer(acc, 'bot');
try {
await homeToChess(page);
await shot(page, 'home-chess');
await click(page, '#btn-single');
page.where = 'bot-select';
await click(page, '.bot-card[data-id="amina"]', { timeout: 20000 });
page.where = 'time-select';
await click(page, '.time-btn[data-key="rapid_10_0"]');
page.where = 'chess-game';
const ready = await waitFor(page, () => !!window.__el3abChess && !!document.querySelector('#board-container canvas'));
check('the game opens from home in three taps', !!ready);
await sleep(900);
check('White is at the bottom (board not flipped)', (await page.evaluate(() => window.__el3abChess.flipped())) === false);
await shot(page, 'bot-start');
// A few real moves against the real bot.
let sawBotReply = false;
for (let i = 0; i < 5; i++) {
await idle(page);
const fen = await fenOf(page);
const ours = await engineMove(fen);
const before = (await page.evaluate(() => window.__el3abChess.history().length));
await play(page, ours);
const moved = await waitFor(page, (n) => window.__el3abChess.history().length > n, { args: [before], timeout: 5000 });
if (!check(`move ${i + 1} (${ours}) is accepted from real input`, !!moved)) break;
const replied = await waitFor(page, (n) => window.__el3abChess.history().length >= n + 2 && window.__el3abChess.turn() === 'w', { args: [before], timeout: 25000 });
if (replied) sawBotReply = true; else { check('the bot answers', false, 'no reply in 25s'); break; }
if (i === 1) {
const c = await page.evaluate(() => window.__el3abChess.clocks());
check('the clock runs after the first moves', c && c.running === 'w' && c.w < 600000, JSON.stringify(c));
await shot(page, 'bot-midgame');
}
if (i === 2) {
// A refresh in the middle of a bot game brings the same game back.
await idle(page);
const before = await page.evaluate(() => ({ fen: window.__el3abChess.fen(), h: window.__el3abChess.history().join(' ') }));
page.where = 'reload';
await page.reload({ waitUntil: 'networkidle2' });
const back = await waitFor(page, () => window.__el3abChess && document.querySelector('#board-container canvas') && !window.__el3abChess.busy() ? { fen: window.__el3abChess.fen(), h: window.__el3abChess.history().join(' ') } : null, { timeout: 20000 });
page.where = 'chess-game';
check('after a refresh the bot game comes back where it was', back && back.fen === before.fen && back.h === before.h, JSON.stringify({ before, back }));
}
}
check('the bot replies with real moves', sawBotReply);
// Flip and flip back.
await click(page, '#btn-flip');
await sleep(300);
const flipped = await page.evaluate(() => window.__el3abChess.flipped());
const platesSwapped = await page.evaluate(() => {
const a = document.querySelector('#plate-player').getBoundingClientRect().top;
const b = document.querySelector('#plate-opponent').getBoundingClientRect().top;
return a < b;
});
check('flip turns the board', flipped === true);
check('flip moves your plate to the top with your pieces', platesSwapped);
await shot(page, 'bot-flipped');
await click(page, '#btn-flip');
await sleep(300);
check('flip back', (await page.evaluate(() => window.__el3abChess.flipped())) === false);
// A draw offer this early is refused.
await idle(page);
await click(page, '#btn-draw');
const declined = await waitFor(page, () => document.querySelector('.cg-note')?.textContent || '', { timeout: 3000 });
check('the bot refuses an early draw offer', !!declined && !(await page.evaluate(() => window.__el3abChess.over())), declined || '');
// Resign.
await idle(page);
await click(page, '#btn-resign');
await click(page, '#modal-confirm', { timeout: 5000 });
page.where = 'chess-result';
const card = await page.waitForSelector('.rs-card', { visible: true, timeout: 15000 }).catch(() => null);
check('the result card appears after resigning', !!card);
const title = await page.$eval('#rs-title', e => e.textContent.trim()).catch(() => '');
check('it says the game was lost', title === 'خسرت', title);
const ribbonRed = await page.$eval('.rs-ribbon', e => !e.classList.contains('green') && !e.classList.contains('blue')).catch(() => false);
check('the ribbon is red for a loss', ribbonRed);
const ratingRow = await page.$('#rs-rating');
check('no invented rating change for an unrated bot game', !ratingRow);
await sleep(1800);
await shot(page, 'result-loss');
// (d) Review and analysis of the finished game.
await click(page, '#btn-analyze');
page.where = 'chess-review';
const reviewed = await page.waitForSelector('#review-content .rv-accuracy', { visible: true, timeout: 90000 }).catch(() => null);
check('the review analyses every move and shows accuracy', !!reviewed);
await sleep(1400); // the rows are dealt in
const rows = await page.$$eval('.rv-classes .rv-class', els => els.filter(e => getComputedStyle(e).opacity === '1').length).catch(() => 0);
check('the move classification rows are on screen', rows >= 8, `${rows} visible`);
await shot(page, 'review');
await click(page, '#btn-full-analysis');
page.where = 'chess-analysis';
const analysed = await waitFor(page, () => document.querySelector('#analysis-lines .an-line') ? document.querySelector('#eval-text').textContent : null, { timeout: 30000 });
check('the analysis shows engine lines and an evaluation', !!analysed, analysed || '');
await click(page, '#an-prev');
await sleep(500);
const display = await page.$eval('#move-display', e => e.textContent.trim());
check('analysis steps back a move', !!display && display !== '');
await sleep(1200);
await shot(page, 'analysis');
await click(page, '.an-head [data-back]');
await sleep(700);
await click(page, '.rv [data-back]');
page.where = 'chess-result';
await page.waitForSelector('#btn-back', { visible: true, timeout: 8000 });
await click(page, '#btn-back');
page.where = 'home';
const home = await page.waitForSelector('.hand-card[data-game="chess"]', { visible: true, timeout: 8000 }).catch(() => null);
check('"back to table" returns home', !!home);
} catch (e) {
check('bot game 1 ran to the end', false, e.message);
await shot(page, 'bot-failure').catch(() => {});
}
console.log('\n=== bot game 2: castling, captures, promotion and mate ===');
try {
// White: e4 Nf3 Bc4 O-O h4 h5 h6 hxg7 gxh8=Q Qxg8 Bxf7#. Black (scripted): the knight shuffles.
const white = ['e2e4', 'g1f3', 'f1c4', 'e1g1', 'h2h4', 'h4h5', 'h5h6', 'h6g7', 'g7h8q', 'h8g8', 'c4f7'];
const black = ['b8c6', 'c6b8', 'b8c6', 'c6b8', 'b8c6', 'c6b8', 'b8c6', 'c6b8', 'b8c6', 'c6b8'];
const g = new Chess();
white.forEach((w, i) => { if (!g.move({ from: w.slice(0, 2), to: w.slice(2, 4), promotion: w[4] })) throw new Error('script white ' + w); if (black[i] && !g.move({ from: black[i].slice(0, 2), to: black[i].slice(2, 4) })) throw new Error('script black ' + black[i]); });
check('the scripted line ends in mate (checked with chess.js)', g.in_checkmate());
page.botScript = [...black];
const purseAtStart = (await sb(`profiles?id=eq.${acc.id}&select=coins`))?.[0]?.coins;
page.where = 'home';
await homeToChess(page);
await click(page, '#btn-single');
await click(page, '.bot-card[data-id="amina"]', { timeout: 20000 });
await click(page, '.time-btn[data-key="blitz_5_3"]');
page.where = 'chess-game';
await waitFor(page, () => !!window.__el3abChess && !!document.querySelector('#board-container canvas'));
await sleep(800);
for (let i = 0; i < white.length; i++) {
await idle(page);
const n = await page.evaluate(() => window.__el3abChess.history().length);
await play(page, white[i]);
const landed = await waitFor(page, (n) => window.__el3abChess.history().length > n, { args: [n], timeout: 6000 });
if (!check(`${white[i]} played`, !!landed)) break;
const san = await page.evaluate(() => window.__el3abChess.history().slice(-1)[0]);
if (white[i] === 'e1g1') check('castling is played by moving the king two squares', san === 'O-O', san);
if (white[i] === 'h6g7') { check('a pawn capture', san.includes('x'), san); await sleep(150); await shot(page, 'capture'); }
if (white[i] === 'g7h8q') {
check('the pawn promotes to a queen through the picker', san === 'gxh8=Q', san);
const fen = await fenOf(page);
check('the queen stands on h8', fen.split(' ')[0].startsWith('rnbqkbnQ') || fen.split(' ')[0].includes('Q'), fen);
}
if (i < black.length) {
const r = await waitFor(page, (n) => window.__el3abChess.history().length >= n + 2, { args: [n], timeout: 12000 });
if (!check(`the bot answers ${black[i]}`, !!r)) break;
}
}
const last = await page.evaluate(() => window.__el3abChess.history().slice(-1)[0]);
check('the game ends with Bxf7#', last === 'Bxf7#', last);
await sleep(400);
await shot(page, 'mate-board');
page.where = 'chess-result';
await page.waitForSelector('.rs-card', { visible: true, timeout: 15000 });
const title = await page.$eval('#rs-title', e => e.textContent.trim());
check('the win card says فزت!', title === 'فزت!', title);
check('the ribbon is green for a win', await page.$eval('.rs-ribbon', e => e.classList.contains('green')));
check('the trophy sticker is on the card', !!(await page.$('.rs-sticker .gi-trophy')));
const reason = await page.$eval('.rs-reason', e => e.textContent);
check('the reason reads checkmate', reason.includes('كش مات'), reason);
await sleep(1100);
await shot(page, 'result-win');
await sleep(1800);
const coinsShown = await page.$eval('#rs-coins', e => e.textContent.trim());
check('the coins count up to +50', coinsShown === '+50', coinsShown);
await sleep(1500);
const local = await page.evaluate(() => JSON.parse(localStorage.getItem('el3ab_state') || '{}').player?.coins || 0);
const server = (await sb(`profiles?id=eq.${acc.id}&select=coins`))?.[0]?.coins;
// The HUD pulls the balance after a game; achievement coins it grants a
// moment later may still be on their way, but the purse must never show
// more than the server holds (a win counted twice).
check('the purse gains the win and never counts it twice', local >= purseAtStart + 50 && local <= server, `start ${purseAtStart}, local ${local}, server ${server}`);
const rematch = await page.$eval('#btn-rematch', e => e.textContent.trim());
check('the rematch button is offered', !!rematch);
await click(page, '#btn-back');
page.where = 'home';
check('"back to table" returns home after a win', !!(await page.waitForSelector('.hand-card[data-game="chess"]', { visible: true, timeout: 8000 }).catch(() => null)));
} catch (e) {
check('bot game 2 ran to the end', false, e.message);
await shot(page, 'bot2-failure').catch(() => {});
}
return page;
}
/**
* Play moves alternately on two live boards (offset 0: White first) and
* require both boards to show the same position and move list after each.
* With `final`, the last move ends the game, so the position is compared
* through the result instead of the board.
*/
async function playSynced(white, black, moves, offset = 0, { final = false } = {}) {
for (let i = 0; i < moves.length; i++) {
const mover = (offset + i) % 2 === 0 ? white : black;
const other = mover === white ? black : white;
await idle(mover);
await waitFor(mover, (c) => window.__el3abChess.turn() === c, { args: [mover === white ? 'w' : 'b'], timeout: 15000 });
const n = await mover.evaluate(() => window.__el3abChess.history().length);
await play(mover, moves[i]);
const ok = await waitFor(mover, (n) => window.__el3abChess.history().length > n, { args: [n], timeout: 6000 });
if (!check(`${mover.label} plays ${moves[i]}`, !!ok)) return false;
if (final && i === moves.length - 1) return true;
const target = await fenOf(mover);
const synced = await waitFor(other, (f) => window.__el3abChess && window.__el3abChess.fen() === f, { args: [target], timeout: 15000 });
const hW = await white.evaluate(() => window.__el3abChess.history().join(' '));
const hB = await black.evaluate(() => window.__el3abChess.history().join(' '));
if (!check(`both boards identical after ${moves[i]}`, !!synced && hW === hB, synced ? `${hW} | ${hB}` : 'other board never caught up')) return false;
}
return true;
}
/** Wait for both players to be seated in the same live game; returns { white, black, matchId }. */
async function seated(pages, accA, accB) {
const colors = await Promise.all(pages.map(p => waitFor(p, () => window.__el3abChess && window.__el3abChess.colorConfirmed() && !window.__el3abChess.over() ? window.__el3abChess.playerColor() : null, { timeout: 30000 })));
pages.forEach(p => { p.where = 'chess-game'; });
if (!check('both players are seated in a new game', !!colors[0] && !!colors[1] && colors[0] !== colors[1], JSON.stringify(colors) + (!!colors[0] !== !!colors[1] ? ': one player is seated while the other still searches (the queue hand-off race in api/matchmaking.php)' : ''))) return null;
const ids = await Promise.all(pages.map(p => p.evaluate(() => window.__el3abChess.matchId())));
const row = (await sb(`matches?id=eq.${ids[0]}&select=white_player_id,black_player_id`))?.[0];
check('same match, the two test accounts', ids[0] === ids[1] && row && [row.white_player_id, row.black_player_id].sort().join() === [accA.id, accB.id].sort().join(), JSON.stringify({ ids, row }));
const white = colors[0] === 'w' ? pages[0] : pages[1];
return { white, black: white === pages[0] ? pages[1] : pages[0], matchId: ids[0] };
}
async function freeClock(candidates) {
for (const tc of candidates) {
const waiting = await sb(`matchmaking_queue?game_key=eq.chess&time_control=eq.${tc}&status=eq.waiting&select=id`);
if (Array.isArray(waiting) && waiting.length === 0) return tc;
}
return null;
}
async function resultOf(page) {
await page.waitForSelector('#rs-title', { visible: true, timeout: 25000 }).catch(() => null);
return page.evaluate(() => ({
title: document.querySelector('#rs-title')?.textContent.trim() || null,
reason: document.querySelector('.rs-reason')?.textContent || '',
ribbon: document.querySelector('.rs-ribbon')?.className || '',
}));
}
/** Rematch from the result cards, a threefold repetition, then a game lost on time. */
async function testOnlineMore(A, B, accA, accB) {
console.log('\n=== online: rematch, threefold repetition, an accepted draw, a flag ===');
const pages = [A, B];
try {
// "Play again" on both result cards is a new search at the same clock.
for (const p of pages) { await click(p, '#btn-rematch'); p.where = 'queue'; await sleep(400); }
const g = await seated(pages, accA, accB);
if (!g) return;
// Knights out and back twice: the start position stands for the third time.
const rep = ['g1f3', 'g8f6', 'f3g1', 'f6g8', 'g1f3', 'g8f6', 'f3g1', 'f6g8'];
await playSynced(g.white, g.black, rep, 0, { final: true });
const [rw, rb] = await Promise.all([resultOf(g.white), resultOf(g.black)]);
check('threefold repetition ends a live game as a draw on both screens', rw.title === 'تعادل' && rb.title === 'تعادل', JSON.stringify([rw.title, rb.title]));
check('the draw card has the blue ribbon', rw.ribbon.includes('blue') && rb.ribbon.includes('blue'));
check('the reason is the repetition', rw.reason.includes('ثلاث') && rb.reason.includes('ثلاث'), rw.reason);
await sleep(1500);
const row = (await sb(`matches?id=eq.${g.matchId}&select=status,result,metadata`))?.[0];
check('the server recorded a draw by repetition', row && row.result === 'draw' && row.metadata?.end_reason === 'threefold_repetition', JSON.stringify(row));
await shot(g.white, 'online-draw');
// Play again, and this time the draw offer is accepted.
for (const p of pages) { await click(p, '#btn-rematch'); p.where = 'queue'; await sleep(400); }
const d = await seated(pages, accA, accB);
if (!d) return;
await playSynced(d.white, d.black, ['d2d4', 'd7d5']);
await click(d.black, '#btn-draw');
const offer = await d.white.waitForSelector('#draw-offer-dialog', { visible: true, timeout: 12000 }).catch(() => null);
check('a draw offer from Black reaches White', !!offer);
if (offer) {
await click(d.white, '#draw-accept');
const [aw, ab] = await Promise.all([resultOf(d.white), resultOf(d.black)]);
check('an accepted draw shows تعادل on both screens', aw.title === 'تعادل' && ab.title === 'تعادل', JSON.stringify([aw.title, ab.title]));
check('the reason is the agreement', aw.reason.includes('بالاتفاق') && ab.reason.includes('بالاتفاق'), aw.reason + ' | ' + ab.reason);
const drow = (await sb(`matches?id=eq.${d.matchId}&select=result,metadata`))?.[0];
check('the server recorded a draw by agreement', drow && drow.result === 'draw' && drow.metadata?.end_reason === 'agreement', JSON.stringify(drow));
}
// A game lost on time: bullet, and White never makes a second move.
const TC = await freeClock(['bullet_1_0', 'bullet_1_1', 'blitz_3_0']);
if (!TC) { notes.push('flag test skipped: strangers waiting on every bullet clock'); return; }
for (const p of pages) {
await click(p, '#btn-back');
p.where = 'home';
await homeToChess(p);
await click(p, '#btn-multi');
await click(p, `.time-btn[data-key="${TC}"]`);
p.where = 'queue';
await sleep(400);
}
const f = await seated(pages, accA, accB);
if (!f) return;
await playSynced(f.white, f.black, ['e2e4', 'e7e5']);
const minutes = Number(TC.split('_')[1]);
console.log(` (White now lets ${minutes} minute(s) run out)`);
await sleep(minutes * 60000 - 8000);
const low = await f.white.evaluate(() => document.querySelector('#clock-player')?.className || '');
check('the clock turns red and beats in the last seconds', low.includes('low-time') && low.includes('running'), low);
await shot(f.white, 'clock-low');
const [lw, lb] = await Promise.all([resultOf(f.white), resultOf(f.black)]);
check('the flagged player sees خسرت on time', lw.title === 'خسرت' && lw.reason.includes('وقتك'), JSON.stringify(lw));
check('the opponent sees فزت! on time', lb.title === 'فزت!' && lb.reason.includes('وقت خصمك'), JSON.stringify(lb));
await sleep(1500);
const frow = (await sb(`matches?id=eq.${f.matchId}&select=status,result,metadata`))?.[0];
const whiteIsA = f.white === A;
check('the server settled it on time for Black', frow && frow.result === 'black_wins' && frow.metadata?.end_reason === 'timeout', JSON.stringify(frow));
await shot(f.black, 'online-timeout-win');
void whiteIsA;
} catch (e) {
check('rematch / repetition / flag ran to the end', false, e.message);
await shot(A, 'online2-failure-A').catch(() => {});
await shot(B, 'online2-failure-B').catch(() => {});
}
}
// ===========================================================================
async function testOnline(accA, accB) {
console.log('\n=== online: two players, one board ===');
const A = await openPlayer(accA, 'A');
const B = await openPlayer(accB, 'B');
const pages = [A, B];
try {
// A rarely used clock with nobody else waiting on it, so a real player
// (or another test) in the queue is never drawn into this game.
let TC = null;
for (const tc of ['classical_90_30', 'classical_60_0', 'rapid_30_0', 'rapid_15_10']) {
const waiting = await sb(`matchmaking_queue?game_key=eq.chess&time_control=eq.${tc}&status=eq.waiting&select=id`);
if (Array.isArray(waiting) && waiting.length === 0) { TC = tc; break; }
}
if (!TC) throw new Error('every candidate clock has strangers waiting in the queue');
console.log(` (queueing on ${TC})`);
for (const p of pages) {
await homeToChess(p);
if (process.env.TRACE) await shot(p, `trace-${p.label}-1-home`);
await click(p, '#btn-multi');
p.where = 'time-select';
await page_wait(p, `.time-btn[data-key="${TC}"]`);
if (process.env.TRACE) await shot(p, `trace-${p.label}-2-time`);
await click(p, `.time-btn[data-key="${TC}"]`);
p.where = 'queue';
await sleep(900);
if (process.env.TRACE) await shot(p, `trace-${p.label}-3-queue`);
}
await shot(A, 'queue');
if (process.env.TRACE) {
console.log(' accounts A', accA.id, 'B', accB.id);
await sleep(3000);
console.log(' queue rows', JSON.stringify(await sb(`matchmaking_queue?or=(player_id.eq.${accA.id},player_id.eq.${accB.id})&select=player_id,status,match_id,matched_with,time_control`)));
}
const joined = await Promise.all(pages.map(p => waitFor(p, () => window.__el3abChess && window.__el3abChess.colorConfirmed() ? window.__el3abChess.playerColor() : null, { timeout: 30000 })));
pages.forEach(p => { p.where = 'chess-game'; });
check('both players are matched into a game', !!joined[0] && !!joined[1], JSON.stringify(joined) + (!!joined[0] !== !!joined[1] ? ': one player is seated while the other still searches (the queue hand-off race in api/matchmaking.php)' : ''));
check('they have opposite colours', joined[0] && joined[1] && joined[0] !== joined[1], JSON.stringify(joined));
const [mA, mB] = await Promise.all(pages.map(p => p.evaluate(() => window.__el3abChess.matchId())));
check('in the same match', mA && mA === mB, `${mA} / ${mB}`);
const row0 = (await sb(`matches?id=eq.${mA}&select=white_player_id,black_player_id`))?.[0];
check('the match pairs exactly the two test accounts', row0 && [row0.white_player_id, row0.black_player_id].sort().join() === [accA.id, accB.id].sort().join(), JSON.stringify(row0));
const white = joined[0] === 'w' ? A : B;
const black = white === A ? B : A;
check('Black sees the board from Black\'s side', await black.evaluate(() => window.__el3abChess.flipped()) === true);
check('White sees it from White\'s side', await white.evaluate(() => window.__el3abChess.flipped()) === false);
await sleep(800);
const line = ['e2e4', 'e7e5', 'g1f3', 'b8c6', 'f1c4', 'f8c5', 'e1g1', 'g8f6', 'c4f7', 'e8f7', 'f3e5', 'c6e5'];
const playLine = (moves, offset) => playSynced(white, black, moves, offset);
let ok = await playLine(line.slice(0, 8), 0);
if (ok) {
const sanW = await white.evaluate(() => window.__el3abChess.history());
check('castling reached the other board', sanW.includes('O-O'));
ok = await playLine(line.slice(8), 8);
}
if (ok) {
const hist = await white.evaluate(() => window.__el3abChess.history());
check('the line has checks and captures', hist.includes('Bxf7+') && hist.includes('Nxe5+') && hist.includes('Kxf7'), hist.join(' '));
await shot(white, 'online-white');
await shot(black, 'online-black');
// Clocks agree between the two screens (server-authoritative, 30s increment).
await sleep(2500);
const [cw, cb] = await Promise.all([white.evaluate(() => window.__el3abChess.clocks()), black.evaluate(() => window.__el3abChess.clocks())]);
check('both screens show the same clocks (within 3s)', Math.abs(cw.w - cb.w) < 3000 && Math.abs(cw.b - cb.b) < 3000, JSON.stringify({ cw, cb }));
const tcp = TC.split('_').map(Number);
check('increments were credited once, not twice', cw.w <= tcp[1] * 60000 + 6 * tcp[2] * 1000 + 1000, JSON.stringify(cw));
// A draw offer, declined.
await click(white, '#btn-draw');
const offered = await black.waitForSelector('#draw-offer-dialog', { visible: true, timeout: 12000 }).catch(() => null);
check('the draw offer reaches the opponent', !!offered);
if (offered) {
await shot(black, 'draw-offer');
await click(black, '#draw-reject');
// The offerer's own "you offered a draw" note shows first; wait for the answer.
const declined = await waitFor(white, () => { const t = [...document.querySelectorAll('.cg-note')].map(n => n.textContent).join(' '); return t.includes('رفض') ? t : null; }, { timeout: 12000 });
check('the offerer hears it was declined', (declined || '').includes('رفض'), declined || '');
}
// Black refreshes mid-game and must come back to the same game, same side.
const fenBefore = await fenOf(white);
black.where = 'reload';
await black.reload({ waitUntil: 'networkidle2' });
const back = await waitFor(black, () => window.__el3abChess && window.__el3abChess.colorConfirmed() && !window.__el3abChess.busy() ? window.__el3abChess.fen() : null, { timeout: 25000 });
black.where = 'chess-game';
check('after a refresh Black rejoins the same position', back === fenBefore, `${back} vs ${fenBefore}`);
check('still as Black, board from Black\'s side', (await black.evaluate(() => window.__el3abChess.playerColor())) === 'b' && (await black.evaluate(() => window.__el3abChess.flipped())));
const hb = await black.evaluate(() => window.__el3abChess.history().join(' '));
const hw = await white.evaluate(() => window.__el3abChess.history().join(' '));
check('the whole move list is restored', hb === hw, `${hb} | ${hw}`);
await shot(black, 'online-rejoined');
ok = await playLine(['d2d4', 'c5d4'], 0);
const stored = await sb(`matches?id=eq.${mA}&select=moves,move_count`);
const movesStored = Array.isArray(stored) && stored[0] ? (Array.isArray(stored[0].moves) ? stored[0].moves.length : 0) : -1;
check('the server keeps the full move list after the rejoin', movesStored === 14, `stored ${movesStored}`);
}
// White resigns; both see the result.
await idle(white);
await click(white, '#btn-resign');
await click(white, '#modal-confirm', { timeout: 5000 });
white.where = black.where = 'chess-result';
const [rw, rb] = await Promise.all([
white.waitForSelector('#rs-title', { visible: true, timeout: 20000 }).then(() => white.$eval('#rs-title', e => e.textContent.trim())).catch(() => null),
black.waitForSelector('#rs-title', { visible: true, timeout: 20000 }).then(() => black.$eval('#rs-title', e => e.textContent.trim())).catch(() => null),
]);
check('the player who resigned sees خسرت', rw === 'خسرت', rw);
check('the opponent sees فزت!', rb === 'فزت!', rb);
const reasonB = await black.$eval('.rs-reason', e => e.textContent).catch(() => '');
check('the winner is told the opponent resigned', reasonB.includes('استسلم'), reasonB);
await sleep(1500);
const row = (await sb(`matches?id=eq.${mA}&select=status,result,metadata,rating_change_white,rating_change_black`))?.[0];
check('the server recorded the resignation for Black', row && row.status === 'completed' && row.result === 'black_wins', JSON.stringify(row));
const ratingShown = await black.$('#rs-rating');
check('a rated game shows the rating change', !!ratingShown && row && row.rating_change_black != null);
await sleep(1200);
await shot(white, 'online-result-loss');
await shot(black, 'online-result-win');
} catch (e) {
check('online game ran to the end', false, e.message);
await shot(A, 'online-failure-A').catch(() => {});
await shot(B, 'online-failure-B').catch(() => {});
}
return pages;
}
// ===========================================================================
async function testPuzzles(acc) {
console.log('\n=== puzzles: one solved, one failed ===');
const page = await openPlayer(acc, 'puzzle');
const served = [];
page.on('response', async r => {
if (r.url().includes('/api/puzzles.php') && r.request().method() === 'GET') {
try { served.push(await r.json()); } catch {}
}
});
try {
await homeToChess(page);
await click(page, '#btn-puzzles');
page.where = 'puzzles';
const ready = await waitFor(page, () => window.__el3abPuzzle && window.__el3abPuzzle.state() === 'play' && document.querySelector('#puzzle-board canvas'), { timeout: 20000 });
check('a puzzle opens from the chess table', !!ready);
await sleep(600);
await shot(page, 'puzzle');
// Solve it: the answer the server sent, played on the board.
let p = served[served.length - 1];
const id = await page.evaluate(() => window.__el3abPuzzle.id());
p = served.find(x => x.id === id) || p;
const moves = String(p.moves).split(' ');
const start = moves.length % 2 === 0 ? 1 : 0;
for (let i = start; i < moves.length; i += 2) {
await waitFor(page, () => window.__el3abPuzzle.state() === 'play', { timeout: 8000 });
await play(page, moves[i], '__el3abPuzzle');
await sleep(700);
}
const solved = await page.waitForSelector('.pz-status.pz-good', { visible: true, timeout: 8000 }).catch(() => null);
check(`puzzle ${id} solved with its answer`, !!solved);
await sleep(500);
await shot(page, 'puzzle-solved');
await click(page, '#pz-next');
const next = await waitFor(page, (prev) => window.__el3abPuzzle.state() === 'play' && window.__el3abPuzzle.id() !== prev ? window.__el3abPuzzle.id() : null, { args: [id], timeout: 20000 });
check('the next puzzle is a different one', !!next, String(next));
// Fail the next one: a legal move that is not the answer.
const p2 = served.find(x => x.id === next) || served[served.length - 1];
const fen = await fenOf(page, '__el3abPuzzle');
const g = new Chess(fen);
const answer = String(p2.moves).split(' ')[String(p2.moves).split(' ').length % 2 === 0 ? 1 : 0];
const wrong = g.moves({ verbose: true }).find(m => (m.from + m.to) !== answer.slice(0, 4) && !m.promotion && (() => { g.move(m); const mate = g.in_checkmate(); g.undo(); return !mate; })());
await play(page, wrong.from + wrong.to, '__el3abPuzzle');
const failed = await page.waitForSelector('.pz-status.pz-bad', { visible: true, timeout: 8000 }).catch(() => null);
check('a wrong move fails the puzzle', !!failed);
await sleep(900);
check('the wrong move is taken back', (await fenOf(page, '__el3abPuzzle')) === fen);
await shot(page, 'puzzle-failed');
await click(page, '#pz-retry');
const again = await waitFor(page, () => window.__el3abPuzzle.state() === 'play', { timeout: 3000 });
check('try again re-opens the same puzzle', !!again && (await page.evaluate(() => window.__el3abPuzzle.id())) === next);
await click(page, '#back-btn');
page.where = 'home';
check('back returns to the table', !!(await page.waitForSelector('.hand-card[data-game="chess"]', { visible: true, timeout: 8000 }).catch(() => null)));
} catch (e) {
check('puzzles ran to the end', false, e.message);
await shot(page, 'puzzle-failure').catch(() => {});
}
return page;
}
// ===========================================================================
/** Nothing spills sideways, the board is whole and the controls are on screen. */
async function measure(page, what) {
return page.evaluate((what) => {
const vw = innerWidth, vh = innerHeight;
const scene = document.querySelector('#scene-container .scene:not(.scene-ghost)');
const wide = [...document.querySelectorAll('#scene-container .scene:not(.scene-ghost) *')]
.filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && (r.right > vw + 1 || r.left < -1); })
.filter(e => !e.closest('.chess-moves, .an-moves, .chips'))
.slice(0, 4).map(e => (e.id ? '#' + e.id : e.className?.toString().slice(0, 40)) + ' ' + Math.round(e.getBoundingClientRect().right));
const canvas = document.querySelector('#board-container canvas, #puzzle-board canvas');
const cr = canvas ? canvas.getBoundingClientRect() : null;
const last = document.querySelector(what === 'game' ? '.cg-controls' : what === 'puzzle' ? '.pz-status' : '.rs-actions');
const lr = last ? last.getBoundingClientRect() : null;
const play = document.querySelector('.tab-bar:not(.hidden) .tab-item[data-world="play"] .tab-icon');
const playTop = play ? Math.round(play.getBoundingClientRect().top) : vh;
return {
playTop,
vw, vh,
scrollX: document.documentElement.scrollWidth > vw + 1 || (scene && scene.scrollWidth > vw + 1),
wide,
board: cr ? { left: Math.round(cr.left), right: Math.round(cr.right), top: Math.round(cr.top), bottom: Math.round(cr.bottom), size: Math.round(cr.width) } : null,
lastBottom: lr ? Math.round(lr.bottom) : null,
sceneScroll: scene ? scene.scrollHeight - scene.clientHeight : 0,
};
}, what);
}
async function testLayout(acc) {
console.log('\n=== layout at three phone sizes ===');
const pages = [];
for (const [w, h] of [[360, 640], [390, 844], [430, 932]]) {
const page = await openPlayer(acc, `layout-${w}`, { width: w, height: h });
pages.push(page);
try {
await homeToChess(page);
await click(page, '#btn-single');
await click(page, '.bot-card[data-id="tarek"]', { timeout: 20000 });
await click(page, '.time-btn[data-key="blitz_3_2"]');
page.where = 'chess-game';
await waitFor(page, () => !!window.__el3abChess && !!document.querySelector('#board-container canvas'));
await sleep(900);
await play(page, 'e2e4');
await waitFor(page, () => window.__el3abChess.history().length >= 2, { timeout: 25000 });
await sleep(600);
let m = await measure(page, 'game');
const fitsWidth = m.board && m.board.left >= 0 && m.board.right <= m.vw;
check(`${w}x${h} game: no sideways overflow`, !m.scrollX && m.wide.length === 0, m.wide.join(', '));
check(`${w}x${h} game: the board is whole on screen`, fitsWidth && m.board.top >= 0 && m.board.bottom <= m.vh, JSON.stringify(m.board));
check(`${w}x${h} game: the board takes the width it can (>= ${w - 60}px)`, m.board && m.board.size >= Math.min(w - 60, m.vh - 300), String(m.board?.size));
check(`${w}x${h} game: the controls are on screen without scrolling`, m.lastBottom != null && m.lastBottom <= m.vh, `${m.lastBottom} / ${m.vh}`);
await shot(page, `layout-${w}x${h}-game`);
await click(page, '#btn-resign');
await click(page, '#modal-confirm', { timeout: 5000 });
page.where = 'chess-result';
await page.waitForSelector('.rs-card', { visible: true, timeout: 15000 });
await sleep(2200);
m = await measure(page, 'result');
check(`${w}x${h} result: no sideways overflow`, !m.scrollX && m.wide.length === 0, m.wide.join(', '));
check(`${w}x${h} result: the card and its buttons fit without scrolling`, m.sceneScroll <= 2, `scrolls ${m.sceneScroll}px`);
check(`${w}x${h} result: nothing hides under the tab bar's play button`, m.lastBottom <= m.playTop, `${m.lastBottom} vs ${m.playTop}`);
await shot(page, `layout-${w}x${h}-result`);
await click(page, '#btn-back');
page.where = 'home';
await homeToChess(page);
await click(page, '#btn-puzzles');
page.where = 'puzzles';
await waitFor(page, () => window.__el3abPuzzle && window.__el3abPuzzle.state() === 'play', { timeout: 20000 });
await sleep(700);
m = await measure(page, 'puzzle');
check(`${w}x${h} puzzle: no sideways overflow`, !m.scrollX && m.wide.length === 0, m.wide.join(', '));
check(`${w}x${h} puzzle: board and status on screen`, m.board && m.board.bottom <= m.vh && m.lastBottom <= m.vh, `${JSON.stringify(m.board)} status ${m.lastBottom}`);
await shot(page, `layout-${w}x${h}-puzzle`);
} catch (e) {
check(`layout ${w}x${h} ran`, false, e.message);
await shot(page, `layout-${w}-failure`).catch(() => {});
}
}
return pages;
}
// ===========================================================================
fs.mkdirSync(OUT, { recursive: true });
const allPages = [];
try {
browser = await puppeteer.launch({
executablePath: CHROME,
headless: HEADFUL ? false : 'new',
args: ['--no-sandbox', '--font-render-hinting=none', '--autoplay-policy=no-user-gesture-required'],
});
console.log('=== accounts ===');
const a = await createAccount(0);
const b = ONLY.includes('online') ? await createAccount(1) : null;
console.log(` ${created.users.length} throwaway account(s) created`);
if (ONLY.includes('bot')) allPages.push(await testBot(a));
if (ONLY.includes('online')) {
const [pa, pb] = await testOnline(a, b);
allPages.push(pa, pb);
if (!ONLY.includes('quick')) await testOnlineMore(pa, pb, a, b);
}
if (ONLY.includes('puzzles')) allPages.push(await testPuzzles(a));
if (ONLY.includes('layout')) allPages.push(...await testLayout(a));
console.log('\n=== console ===');
const errors = collectErrors(...allPages);
const chessErrors = errors.filter(e => !/module (backgammon|domino|ludo) failed to load/.test(e));
check('no console errors in chess and puzzles', chessErrors.length === 0, chessErrors.slice(0, 12).join('\n '));
const foreign = errors.filter(e => !chessErrors.includes(e));
if (foreign.length) notes.push(...new Set(foreign.map(e => e.replace(/^\[[^\]]+\]\s*/, ''))));
} catch (e) {
failures.push('harness: ' + e.message);
console.log('HARNESS ERROR', e);
} finally {
try { await browser?.close(); } catch {}
await teardown();
}
if (notes.length) console.log('\nnot from chess (reported, not failed):\n ' + notes.join('\n '));
console.log(`\nscreenshots: ${OUT}`);
console.log(failures.length ? `\n${failures.length} FAILED:\n - ${failures.join('\n - ')}` : '\nALL PASSED');
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