Commit a3a24229 authored by Mahmoud Aglan's avatar Mahmoud Aglan

wip(chess): premoves, unfinished and untested

Partial work from the chess agent, stopped by the owner before it was
tested. Parked here so main stays deployable; not for merging as is.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent f56437fc
...@@ -34,6 +34,7 @@ function palette() { ...@@ -34,6 +34,7 @@ function palette() {
PAL.cardEdge = getColor('card_edge', '#CFC9DA'); PAL.cardEdge = getColor('card_edge', '#CFC9DA');
PAL.lipDark = getColor('back_950', '#0D0C12'); PAL.lipDark = getColor('back_950', '#0D0C12');
PAL.gold = getColor('gold', '#FFC531'); PAL.gold = getColor('gold', '#FFC531');
PAL.premove = getColor('board_premove', getColor('sky', '#4DA3FF'));
PAL.font = getColor('font_display', "'El3ab Display', 'Rubik', system-ui, sans-serif"); PAL.font = getColor('font_display', "'El3ab Display', 'Rubik', system-ui, sans-serif");
return PAL; return PAL;
} }
...@@ -244,6 +245,8 @@ export class ChessBoard { ...@@ -244,6 +245,8 @@ export class ChessBoard {
this.onMove = options.onMove || null; this.onMove = options.onMove || null;
this.onPick = options.onPick || null; // a piece was picked up this.onPick = options.onPick || null; // a piece was picked up
this.onIllegal = options.onIllegal || null; // dropped where it cannot go this.onIllegal = options.onIllegal || null; // dropped where it cannot go
this.onEmptyTap = options.onEmptyTap || null; // a tap on an empty square that made no move
this.onCancel = options.onCancel || null; // right-click or long-press: put everything down
this.coordinates = options.coordinates !== false; this.coordinates = options.coordinates !== false;
this.maxSize = options.maxSize || 560; this.maxSize = options.maxSize || 560;
this.maxHeight = options.maxHeight || null; // () => px the board may take vertically this.maxHeight = options.maxHeight || null; // () => px the board may take vertically
...@@ -251,7 +254,7 @@ export class ChessBoard { ...@@ -251,7 +254,7 @@ export class ChessBoard {
this.squareSize = 0; this.squareSize = 0;
this.size = 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, premove: null, premoveDots: false };
this.dragging = null; this.dragging = null;
this.hover = null; this.hover = null;
this.anims = []; this.anims = [];
...@@ -479,7 +482,14 @@ export class ChessBoard { ...@@ -479,7 +482,14 @@ export class ChessBoard {
} }
clearHighlights() { clearHighlights() {
this.highlights = { lastMove: null, selected: null, legalMoves: [], check: null }; this.highlights = { lastMove: null, selected: null, legalMoves: [], check: null, premove: null, premoveDots: false };
this.draw();
}
/** A queued premove ({ from, to } or null), shown as a tinted pair of squares. */
setPremove(pm) {
this.highlights.premove = pm ? { from: pm.from, to: pm.to } : null;
this._drop = null;
this.draw(); this.draw();
} }
...@@ -600,6 +610,11 @@ export class ChessBoard { ...@@ -600,6 +610,11 @@ export class ChessBoard {
ctx.strokeRect(x + ctx.lineWidth / 2, y + ctx.lineWidth / 2, sq - ctx.lineWidth, sq - ctx.lineWidth); ctx.strokeRect(x + ctx.lineWidth / 2, y + ctx.lineWidth / 2, sq - ctx.lineWidth, sq - ctx.lineWidth);
} }
// A queued premove: both squares tinted in the premove colour.
if (hl.premove) {
for (const s of [hl.premove.from, hl.premove.to]) tint(s, P.premove, 0.5);
}
// Check: the king's square burns red, pulsing when it first happens. // Check: the king's square burns red, pulsing when it first happens.
if (hl.check) { if (hl.check) {
const a = this.anims.find(x => x.type === 'check'); const a = this.anims.find(x => x.type === 'check');
...@@ -644,8 +659,8 @@ export class ChessBoard { ...@@ -644,8 +659,8 @@ export class ChessBoard {
// Legal moves sit over the pieces they would take: a dot on an empty // Legal moves sit over the pieces they would take: a dot on an empty
// square, a ring of corner notches around a capture. // square, a ring of corner notches around a capture.
if (hl.legalMoves.length) { if (hl.legalMoves.length) {
ctx.fillStyle = P.ink; ctx.fillStyle = hl.premoveDots ? P.premove : P.ink;
ctx.globalAlpha = 0.26; ctx.globalAlpha = hl.premoveDots ? 0.55 : 0.26;
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);
if (this.pieces[move.to]) { if (this.pieces[move.to]) {
...@@ -769,6 +784,13 @@ export class ChessBoard { ...@@ -769,6 +784,13 @@ export class ChessBoard {
this.canvas.addEventListener('pointermove', (e) => this.onPointerMove(e)); this.canvas.addEventListener('pointermove', (e) => this.onPointerMove(e));
this.canvas.addEventListener('pointerup', (e) => this.onPointerUp(e)); this.canvas.addEventListener('pointerup', (e) => this.onPointerUp(e));
this.canvas.addEventListener('pointercancel', () => this.cancelDrag()); this.canvas.addEventListener('pointercancel', () => this.cancelDrag());
// Right-click (or the long-press menu on a phone) puts everything down.
this.canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
// A phone's long-press menu only matters when a premove is waiting;
// a mouse right-click always puts everything down.
if (this.highlights.premove || this._pointerType === 'mouse') this.cancelAll();
});
this.canvas.addEventListener('lostpointercapture', () => { if (this.dragging) this.onPointerUp(null); }); this.canvas.addEventListener('lostpointercapture', () => { if (this.dragging) this.onPointerUp(null); });
} }
...@@ -779,6 +801,15 @@ export class ChessBoard { ...@@ -779,6 +801,15 @@ export class ChessBoard {
if (!square) return; if (!square) return;
const piece = this.pieces[square]; const piece = this.pieces[square];
// A long press anywhere cancels a waiting premove, as a right-click does.
this._pointerType = e.pointerType;
clearTimeout(this._press);
if (this.highlights.premove) {
this._press = setTimeout(() => {
if (!this.dragging || !this.dragging.moved) this.cancelAll();
}, 550);
}
if (piece && this.canSelect(piece) && !(this.highlights.selected && this.highlights.legalMoves.some(m => m.to === square))) { if (piece && this.canSelect(piece) && !(this.highlights.selected && this.highlights.legalMoves.some(m => m.to === square))) {
const reselect = this.highlights.selected === square; const reselect = this.highlights.selected === square;
this.highlights.selected = square; this.highlights.selected = square;
...@@ -789,10 +820,24 @@ export class ChessBoard { ...@@ -789,10 +820,24 @@ export class ChessBoard {
if (!reselect) this.onPick?.(square); if (!reselect) this.onPick?.(square);
this.kick(); this.kick();
} else if (this.highlights.selected) { } else if (this.highlights.selected) {
this.tryMove(this.highlights.selected, square, null); const moved = this.tryMove(this.highlights.selected, square, null);
if (!moved && !piece) this.onEmptyTap?.(square);
} else if (!piece) {
this.onEmptyTap?.(square);
} }
} }
/** Drop the selection, any drag, and (through onCancel) a queued premove. */
cancelAll() {
clearTimeout(this._press);
this.dragging = null;
this.hover = null;
this.highlights.selected = null;
this.highlights.legalMoves = [];
this.onCancel?.();
this.draw();
}
onPointerMove(e) { onPointerMove(e) {
if (!this.dragging) return; if (!this.dragging) return;
const { x, y } = this.pointerXY(e); const { x, y } = this.pointerXY(e);
...@@ -804,6 +849,7 @@ export class ChessBoard { ...@@ -804,6 +849,7 @@ export class ChessBoard {
} }
onPointerUp(e) { onPointerUp(e) {
clearTimeout(this._press);
if (!this.dragging) return; if (!this.dragging) return;
const d = this.dragging; const d = this.dragging;
const { x, y } = e ? this.pointerXY(e) : d; const { x, y } = e ? this.pointerXY(e) : d;
...@@ -837,7 +883,7 @@ export class ChessBoard { ...@@ -837,7 +883,7 @@ export class ChessBoard {
if (isLegal && this.onMove) { if (isLegal && this.onMove) {
this.onMove(from, to); this.onMove(from, to);
this.draw(); this.draw();
return; return true;
} }
this._drop = null; this._drop = null;
if (dropXY) { if (dropXY) {
...@@ -851,6 +897,7 @@ export class ChessBoard { ...@@ -851,6 +897,7 @@ export class ChessBoard {
this.onIllegal?.(from, to); this.onIllegal?.(from, to);
} }
this.draw(); this.draw();
return false;
} }
canSelect() { return true; } canSelect() { return true; }
......
// Pre-move system for bullet/blitz chess // Premoves: a move queued while it is the opponent's turn.
// Allows queuing a move during opponent's turn — executes instantly when it's your turn //
// What the opponent will play is unknown, so the squares offered are where the
// piece could go by its own movement pattern, blockers ignored (a pawn may aim
// at either diagonal: something may be taken there by then). Whether the move
// is legal is decided only when the turn arrives; an illegal one is dropped.
let premove = null; const FILES = 'abcdefgh';
let onPremoveExecuted = null;
export function set(from, to, promotion) { function sq(f, r) {
premove = { from, to, promotion }; return f >= 0 && f < 8 && r >= 0 && r < 8 ? FILES[f] + (r + 1) : null;
} }
export function get() { /**
return premove; * Destinations for a premove of `piece` (FEN letter) standing on `square`,
} * as [{ from, to }] (the shape the board's legal-move dots use).
*/
export function clear() { export function premoveDests(piece, square) {
premove = null; const f = square.charCodeAt(0) - 97;
} const r = parseInt(square[1], 10) - 1;
const white = piece === piece.toUpperCase();
export function hasPremove() { const type = piece.toLowerCase();
return premove !== null; const out = new Set();
} const add = (s) => { if (s && s !== square) out.add(s); };
const ray = (df, dr) => { for (let i = 1; i < 8; i++) { const s = sq(f + df * i, r + dr * i); if (!s) break; add(s); } };
export function tryExecute(engine, playerColor) {
if (!premove) return null; if (type === 'p') {
if (engine.turn() !== playerColor) return null; const dir = white ? 1 : -1;
add(sq(f, r + dir));
const { from, to, promotion } = premove; if ((white && r === 1) || (!white && r === 6)) add(sq(f, r + 2 * dir));
premove = null; add(sq(f - 1, r + dir));
add(sq(f + 1, r + dir));
// Verify the premove is still legal in the current position } else if (type === 'n') {
const legalMoves = engine.legalMoves(from); for (const [a, b] of [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]]) add(sq(f + a, r + b));
const isLegal = legalMoves.some(m => m.from === from && m.to === to); } else if (type === 'k') {
for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) add(sq(f + a, r + b));
if (isLegal) { // Castling from the home square.
const m = engine.move(from, to, promotion); const home = white ? 0 : 7;
return m; if (f === 4 && r === home) { add(sq(6, home)); add(sq(2, home)); }
} else {
const lines = type === 'r' ? [[1, 0], [-1, 0], [0, 1], [0, -1]]
: type === 'b' ? [[1, 1], [1, -1], [-1, 1], [-1, -1]]
: [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
for (const [a, b] of lines) ray(a, b);
} }
return [...out].map(to => ({ from: square, to }));
return null; // Premove was illegal in current position — discard silently
}
export function setCallback(fn) {
onPremoveExecuted = fn;
} }
export function isPremoveSquare(square) { /** Would this premove be a promotion (a pawn reaching the last rank)? */
if (!premove) return false; export function isPromotion(piece, to) {
return square === premove.from || square === premove.to; if (!piece || piece.toLowerCase() !== 'p') return false;
return (piece === 'P' && to[1] === '8') || (piece === 'p' && to[1] === '1');
} }
...@@ -32,6 +32,7 @@ import * as matchLive from '../../../core/match-live.js'; ...@@ -32,6 +32,7 @@ import * as matchLive from '../../../core/match-live.js';
import { icon, gicon } from '../../../core/icons.js'; import { icon, gicon } from '../../../core/icons.js';
import { esc } from '../../../core/ui.js'; import { esc } from '../../../core/ui.js';
import { pickPromotion, PROMO_STYLE } from '../components/promotion.js'; import { pickPromotion, PROMO_STYLE } from '../components/promotion.js';
import { premoveDests, isPromotion } from '../logic/premove.js';
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
...@@ -162,6 +163,7 @@ export function mountGame(el, params) { ...@@ -162,6 +163,7 @@ export function mountGame(el, params) {
drawOfferAt: 0, drawOfferAt: 0,
flagClaim: false, flagClaim: false,
lastTickSecond: null, lastTickSecond: null,
premove: null,
}; };
const token = gameState.token; const token = gameState.token;
...@@ -262,16 +264,22 @@ export function mountGame(el, params) { ...@@ -262,16 +264,22 @@ export function mountGame(el, params) {
onMove: (from, to) => onBoardMove(el, from, to), onMove: (from, to) => onBoardMove(el, from, to),
onPick: () => { audio.play('tap', 'game', { volume: 0.7 }); haptic('light'); }, onPick: () => { audio.play('tap', 'game', { volume: 0.7 }); haptic('light'); },
onIllegal: () => { audio.play('error'); haptic('error'); }, onIllegal: () => { audio.play('error'); haptic('error'); },
onEmptyTap: () => cancelPremove(),
onCancel: () => cancelPremove(),
}); });
// Every check reads gameState.playerColor, so a corrected colour applies at once. // Every check reads gameState.playerColor, so a corrected colour applies at
// once. Your own pieces can be picked up on the opponent's turn too: that
// queues a premove.
board.canSelect = (piece) => { board.canSelect = (piece) => {
if (!canPlayerAct()) return false; if (!canPremoveOrAct()) return false;
const pieceColor = piece === piece.toUpperCase() ? 'w' : 'b'; const pieceColor = piece === piece.toUpperCase() ? 'w' : 'b';
return pieceColor === gameState.playerColor; return pieceColor === gameState.playerColor;
}; };
board.showLegalMoves = (square) => { board.showLegalMoves = (square) => {
board.highlights.legalMoves = engine.legalMoves(square); const premove = engine.turn() !== gameState.playerColor;
board.highlights.premoveDots = premove;
board.highlights.legalMoves = premove ? premoveDests(board.pieces[square], square) : engine.legalMoves(square);
}; };
board.setPosition(engine.fen()); board.setPosition(engine.fen());
...@@ -361,10 +369,68 @@ function canPlayerAct() { ...@@ -361,10 +369,68 @@ function canPlayerAct() {
return engine.turn() === gameState.playerColor; return engine.turn() === gameState.playerColor;
} }
/** Your move now, or a premove while the opponent is on move. */
function canPremoveOrAct() {
if (!gameState || gameState.gameOver || gameState.promoPending || gameState.syncing || !gameState.colorConfirmed) return false;
if (engine.turn() === gameState.playerColor) return canPlayerAct();
return true;
}
// ── Premoves ─────────────────────────────────────────────────────────────
// One move can wait while the opponent thinks. It plays the instant the turn
// arrives if it is still legal, and is dropped without a word if not. It uses
// the player's own clock like any move (one press, one increment).
function queuePremove(el, from, to) {
const piece = board.pieces[from];
const set = (promotion) => {
if (!gameState || gameState.gameOver) return;
gameState.premove = { from, to, promotion: promotion || null };
board.setPremove(gameState.premove);
audio.play('tap', 'game', { volume: 0.6 });
haptic('light');
// The turn may have arrived while the promotion piece was being chosen.
if (engine.turn() === gameState.playerColor) tryPremove(el);
};
if (isPromotion(piece, to)) {
const token = gameState.token;
gameState.promoPending = true;
pickPromotion(el.querySelector('#promo-dialog'), piece === 'P' ? 'w' : 'b').then(choice => {
if (!alive(token)) return;
gameState.promoPending = false;
if (choice) set(choice); else board.draw();
});
return;
}
set(null);
}
function cancelPremove() {
if (!gameState || !gameState.premove) return;
gameState.premove = null;
board?.setPremove(null);
audio.play('swish');
}
/** The opponent has moved: play the waiting premove, or drop it. */
function tryPremove(el) {
if (!gameState || !gameState.premove) return;
const pm = gameState.premove;
gameState.premove = null;
board.setPremove(null);
if (!canPlayerAct()) return;
const legal = engine.legalMoves(pm.from).some(m => m.to === pm.to && (!m.promotion || !pm.promotion || m.promotion === pm.promotion));
if (!legal) return;
commitPlayerMove(el, pm.from, pm.to, pm.promotion || undefined);
}
// =========================================================================== // ===========================================================================
// Moves // Moves
// =========================================================================== // ===========================================================================
function onBoardMove(el, from, to) { function onBoardMove(el, from, to) {
if (gameState && !gameState.gameOver && engine.turn() !== gameState.playerColor && canPremoveOrAct()) {
queuePremove(el, from, to);
return;
}
if (!canPlayerAct()) return; if (!canPlayerAct()) return;
const piece = engine.get().get(from); const piece = engine.get().get(from);
if (piece && piece.type === 'p' && (to[1] === '8' || to[1] === '1')) { if (piece && piece.type === 'p' && (to[1] === '8' || to[1] === '1')) {
...@@ -507,6 +573,7 @@ async function requestBotMove(el, attempt = 0) { ...@@ -507,6 +573,7 @@ async function requestBotMove(el, attempt = 0) {
showThinking(el, false); showThinking(el, false);
afterMove(el, m, 'opp'); afterMove(el, m, 'opp');
if (engine.isGameOver()) finishFromBoard(); if (engine.isGameOver()) finishFromBoard();
else tryPremove(el);
} catch (e) { } catch (e) {
if (!alive(token)) return; if (!alive(token)) return;
// Back off, then give up gracefully rather than retrying forever. // Back off, then give up gracefully rather than retrying forever.
...@@ -811,6 +878,7 @@ function ingestServerMoves(el, data) { ...@@ -811,6 +878,7 @@ function ingestServerMoves(el, data) {
gameState.drawOfferAt = 0; gameState.drawOfferAt = 0;
el.querySelector('#btn-draw')?.removeAttribute('aria-disabled'); el.querySelector('#btn-draw')?.removeAttribute('aria-disabled');
if (engine.isGameOver()) finishFromBoard(); if (engine.isGameOver()) finishFromBoard();
else tryPremove(el);
} }
// ── Draw offers ────────────────────────────────────────────────────────── // ── Draw offers ──────────────────────────────────────────────────────────
...@@ -1001,6 +1069,8 @@ function endGame(result, reason, { serverAlreadyCompleted = false, serverData = ...@@ -1001,6 +1069,8 @@ function endGame(result, reason, { serverAlreadyCompleted = false, serverData =
if (!gameState || gameState.gameOver) return; if (!gameState || gameState.gameOver) return;
const token = gameState.token; const token = gameState.token;
gameState.gameOver = true; gameState.gameOver = true;
gameState.premove = null;
board?.setPremove(null);
clock?.stop(); clock?.stop();
if (board) board.interactive = false; if (board) board.interactive = false;
showThinking(rootEl, false); showThinking(rootEl, false);
...@@ -1283,6 +1353,7 @@ function exposeTestHook() { ...@@ -1283,6 +1353,7 @@ function exposeTestHook() {
busy: () => !!(gameState && (gameState.botThinking || gameState.sending || gameState.syncing || board?.isAnimating())), busy: () => !!(gameState && (gameState.botThinking || gameState.sending || gameState.syncing || board?.isAnimating())),
matchId: () => gameState?.matchId || null, matchId: () => gameState?.matchId || null,
clocks: () => clock ? { w: Math.round(clock.white), b: Math.round(clock.black), running: clock.running } : null, clocks: () => clock ? { w: Math.round(clock.white), b: Math.round(clock.black), running: clock.running } : null,
premove: () => gameState?.premove ? { ...gameState.premove } : null,
}; };
} }
......
...@@ -197,7 +197,10 @@ async function openPlayer(acc, label, { width = W, height = H, scriptBot = false ...@@ -197,7 +197,10 @@ async function openPlayer(acc, label, { width = W, height = H, scriptBot = false
try { body = JSON.parse(req.postData() || '{}'); } catch {} try { body = JSON.parse(req.postData() || '{}'); } catch {}
if (body.action === 'move') { if (body.action === 'move') {
const best = page.botScript.shift(); const best = page.botScript.shift();
return req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ best_move: best, evaluation: 0, depth: 1 }) }); const answer = () => req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ best_move: best, evaluation: 0, depth: 1 }) }).catch(() => {});
// A slow bot leaves time to queue a premove while it "thinks".
if (page.botDelay) { setTimeout(answer, page.botDelay); return; }
return answer();
} }
} }
req.continue(); req.continue();
...@@ -489,13 +492,55 @@ async function testBot(acc) { ...@@ -489,13 +492,55 @@ async function testBot(acc) {
await waitFor(page, () => !!window.__el3abChess && !!document.querySelector('#board-container canvas')); await waitFor(page, () => !!window.__el3abChess && !!document.querySelector('#board-container canvas'));
await sleep(800); await sleep(800);
// Premoves: Nf3 and gxh8=Q are queued while the bot thinks; a castling
// premove is queued and then cancelled with a tap on an empty square.
page.botDelay = 2200;
const PREMOVE = new Set(['g1f3', 'g7h8q']);
const CANCEL = new Set(['e1g1']);
const hist = () => page.evaluate(() => window.__el3abChess.history().length);
let premoved = null;
for (let i = 0; i < white.length; i++) { for (let i = 0; i < white.length; i++) {
await idle(page); let n = await hist();
const n = await page.evaluate(() => window.__el3abChess.history().length); const wasPremove = premoved === white[i];
await play(page, white[i]); if (wasPremove) {
const landed = await waitFor(page, (n) => window.__el3abChess.history().length > n, { args: [n], timeout: 6000 }); // Played by itself when the bot's reply arrived: n already counts it.
if (!check(`${white[i]} played`, !!landed)) break; premoved = null;
n -= 1;
} else {
await idle(page);
n = await hist();
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]); const san = await page.evaluate(() => window.__el3abChess.history().slice(-1)[0]);
const next = white[i + 1];
if (i < black.length && next && PREMOVE.has(next)) {
await sleep(200);
const before = await hist();
await play(page, next);
const queued = await page.evaluate(() => window.__el3abChess.premove());
check(`${next} is queued as a premove while the bot thinks`, !!queued && queued.from + queued.to === next.slice(0, 4) && (next.length < 5 || queued.promotion === next[4]), JSON.stringify(queued));
if (next === 'g7h8q') await shot(page, 'premove-queued');
const fired = await waitFor(page, (n) => window.__el3abChess.history().length >= n + 2, { args: [before], timeout: 15000 });
const last2 = await page.evaluate(() => window.__el3abChess.history().slice(-2));
check(`the premove ${next} plays the instant the bot has answered`, !!fired && last2.length === 2, JSON.stringify(last2));
const c = await page.evaluate(() => window.__el3abChess.clocks());
check('after a premove the bot is on move and its clock runs', c && c.running === 'b', JSON.stringify(c));
premoved = next;
} else if (i < black.length && next && CANCEL.has(next)) {
await sleep(200);
const before = await hist();
await play(page, next);
check('a premove can be queued for castling', !!(await page.evaluate(() => window.__el3abChess.premove())));
const empty = await squareXY(page, 'a5');
await page.mouse.click(empty.x, empty.y);
await sleep(150);
check('a tap on an empty square cancels the premove', !(await page.evaluate(() => window.__el3abChess.premove())));
await waitFor(page, (n) => window.__el3abChess.history().length >= n + 1, { args: [before], timeout: 15000 });
await sleep(700);
check('a cancelled premove never plays', (await hist()) === before + 1 && (await page.evaluate(() => window.__el3abChess.turn())) === 'w');
}
if (white[i] === 'e1g1') check('castling is played by moving the king two squares', san === 'O-O', san); 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] === 'h6g7') { check('a pawn capture', san.includes('x'), san); await sleep(150); await shot(page, 'capture'); }
if (white[i] === 'g7h8q') { if (white[i] === 'g7h8q') {
...@@ -503,11 +548,12 @@ async function testBot(acc) { ...@@ -503,11 +548,12 @@ async function testBot(acc) {
const fen = await fenOf(page); const fen = await fenOf(page);
check('the queen stands on h8', fen.split(' ')[0].startsWith('rnbqkbnQ') || fen.split(' ')[0].includes('Q'), fen); check('the queen stands on h8', fen.split(' ')[0].startsWith('rnbqkbnQ') || fen.split(' ')[0].includes('Q'), fen);
} }
if (i < black.length) { if (i < black.length && !premoved) {
const r = await waitFor(page, (n) => window.__el3abChess.history().length >= n + 2, { args: [n], timeout: 12000 }); 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; if (!check(`the bot answers ${black[i]}`, !!r)) break;
} }
} }
page.botDelay = 0;
const last = await page.evaluate(() => window.__el3abChess.history().slice(-1)[0]); const last = await page.evaluate(() => window.__el3abChess.history().slice(-1)[0]);
check('the game ends with Bxf7#', last === 'Bxf7#', last); check('the game ends with Bxf7#', last === 'Bxf7#', last);
await sleep(400); await sleep(400);
...@@ -624,7 +670,46 @@ async function testOnlineMore(A, B, accA, accB) { ...@@ -624,7 +670,46 @@ async function testOnlineMore(A, B, accA, accB) {
for (const p of pages) { await click(p, '#btn-rematch'); p.where = 'queue'; await sleep(400); } for (const p of pages) { await click(p, '#btn-rematch'); p.where = 'queue'; await sleep(400); }
const d = await seated(pages, accA, accB); const d = await seated(pages, accA, accB);
if (!d) return; if (!d) return;
await playSynced(d.white, d.black, ['d2d4', 'd7d5']); await playSynced(d.white, d.black, ['d2d4', 'd7d5', 'c2c4']);
// Premoves in a live game. White queues Nc3 while Black thinks; it plays
// the moment Black's move arrives.
const W = d.white, Bk = d.black;
const hlen = (p) => p.evaluate(() => window.__el3abChess.history().length);
let n0 = await hlen(W);
await play(W, 'b1c3');
check('White queues Nc3 as a premove on Black\'s turn', !!(await W.evaluate(() => window.__el3abChess.premove())));
await playSynced(W, Bk, ['e7e6'], 1, { final: true });
const auto = await waitFor(Bk, (n) => window.__el3abChess.history().length >= n + 2 ? window.__el3abChess.history().slice(-1)[0] : null, { args: [n0], timeout: 15000 });
check('the live premove plays as soon as Black has moved, and Black sees it', auto === 'Nc3', String(auto));
const hw = await W.evaluate(() => window.__el3abChess.history().join(' '));
const hb = await Bk.evaluate(() => window.__el3abChess.history().join(' '));
check('both boards agree after the premove', hw === hb, `${hw} | ${hb}`);
await sleep(2500);
const srv = (await sb(`matches?id=eq.${d.matchId}&select=white_time_remaining_ms,black_time_remaining_ms,increment_ms`))?.[0];
const wc = await W.evaluate(() => window.__el3abChess.clocks());
check('after a premove the clocks still agree with the server (one increment)', srv && Math.abs(srv.white_time_remaining_ms - wc.w) < 3000, JSON.stringify({ srv, wc }));
// A premove that has become illegal is dropped without a word.
n0 = await hlen(W);
await play(W, 'c4c5');
await playSynced(W, Bk, ['c7c5'], 1, { final: true });
await sleep(2500);
check('a premove made illegal by the reply (c5 now taken) is dropped', (await hlen(W)) === n0 + 1 && !(await W.evaluate(() => window.__el3abChess.premove())) && (await W.evaluate(() => window.__el3abChess.turn())) === 'w');
// A right-click on the board cancels a premove.
await playSynced(W, Bk, ['g1f3'], 0);
n0 = await hlen(W);
await play(W, 'e2e3');
check('another premove is queued', !!(await W.evaluate(() => window.__el3abChess.premove())));
const rc = await squareXY(W, 'h4');
await W.mouse.click(rc.x, rc.y, { button: 'right' });
await sleep(150);
check('a right-click cancels the premove', !(await W.evaluate(() => window.__el3abChess.premove())));
await playSynced(W, Bk, ['b8c6'], 1, { final: true });
await sleep(2500);
check('the cancelled premove never plays', (await hlen(W)) === n0 + 1);
await click(d.black, '#btn-draw'); await click(d.black, '#btn-draw');
const offer = await d.white.waitForSelector('#draw-offer-dialog', { visible: true, timeout: 12000 }).catch(() => null); const offer = await d.white.waitForSelector('#draw-offer-dialog', { visible: true, timeout: 12000 }).catch(() => null);
check('a draw offer from Black reaches White', !!offer); check('a draw offer from Black reaches White', !!offer);
......
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