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() {
PAL.cardEdge = getColor('card_edge', '#CFC9DA');
PAL.lipDark = getColor('back_950', '#0D0C12');
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");
return PAL;
}
......@@ -244,6 +245,8 @@ export class ChessBoard {
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.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.maxSize = options.maxSize || 560;
this.maxHeight = options.maxHeight || null; // () => px the board may take vertically
......@@ -251,7 +254,7 @@ export class ChessBoard {
this.squareSize = 0;
this.size = 0;
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.hover = null;
this.anims = [];
......@@ -479,7 +482,14 @@ export class ChessBoard {
}
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();
}
......@@ -600,6 +610,11 @@ export class ChessBoard {
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.
if (hl.check) {
const a = this.anims.find(x => x.type === 'check');
......@@ -644,8 +659,8 @@ export class ChessBoard {
// 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) {
ctx.fillStyle = P.ink;
ctx.globalAlpha = 0.26;
ctx.fillStyle = hl.premoveDots ? P.premove : P.ink;
ctx.globalAlpha = hl.premoveDots ? 0.55 : 0.26;
for (const move of hl.legalMoves) {
const { x, y } = this.squareToXY(move.to);
if (this.pieces[move.to]) {
......@@ -769,6 +784,13 @@ export class ChessBoard {
this.canvas.addEventListener('pointermove', (e) => this.onPointerMove(e));
this.canvas.addEventListener('pointerup', (e) => this.onPointerUp(e));
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); });
}
......@@ -779,6 +801,15 @@ export class ChessBoard {
if (!square) return;
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))) {
const reselect = this.highlights.selected === square;
this.highlights.selected = square;
......@@ -789,10 +820,24 @@ export class ChessBoard {
if (!reselect) this.onPick?.(square);
this.kick();
} 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) {
if (!this.dragging) return;
const { x, y } = this.pointerXY(e);
......@@ -804,6 +849,7 @@ export class ChessBoard {
}
onPointerUp(e) {
clearTimeout(this._press);
if (!this.dragging) return;
const d = this.dragging;
const { x, y } = e ? this.pointerXY(e) : d;
......@@ -837,7 +883,7 @@ export class ChessBoard {
if (isLegal && this.onMove) {
this.onMove(from, to);
this.draw();
return;
return true;
}
this._drop = null;
if (dropXY) {
......@@ -851,6 +897,7 @@ export class ChessBoard {
this.onIllegal?.(from, to);
}
this.draw();
return false;
}
canSelect() { return true; }
......
// Pre-move system for bullet/blitz chess
// Allows queuing a move during opponent's turn — executes instantly when it's your turn
// Premoves: a move queued while it is the opponent's 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;
let onPremoveExecuted = null;
const FILES = 'abcdefgh';
export function set(from, to, promotion) {
premove = { from, to, promotion };
function sq(f, r) {
return f >= 0 && f < 8 && r >= 0 && r < 8 ? FILES[f] + (r + 1) : null;
}
export function get() {
return premove;
}
export function clear() {
premove = null;
}
export function hasPremove() {
return premove !== null;
}
export function tryExecute(engine, playerColor) {
if (!premove) return null;
if (engine.turn() !== playerColor) return null;
const { from, to, promotion } = premove;
premove = null;
// Verify the premove is still legal in the current position
const legalMoves = engine.legalMoves(from);
const isLegal = legalMoves.some(m => m.from === from && m.to === to);
if (isLegal) {
const m = engine.move(from, to, promotion);
return m;
/**
* 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 premoveDests(piece, square) {
const f = square.charCodeAt(0) - 97;
const r = parseInt(square[1], 10) - 1;
const white = piece === piece.toUpperCase();
const type = piece.toLowerCase();
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); } };
if (type === 'p') {
const dir = white ? 1 : -1;
add(sq(f, r + dir));
if ((white && r === 1) || (!white && r === 6)) add(sq(f, r + 2 * dir));
add(sq(f - 1, r + dir));
add(sq(f + 1, r + dir));
} else if (type === 'n') {
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));
} else if (type === 'k') {
for (let a = -1; a <= 1; a++) for (let b = -1; b <= 1; b++) add(sq(f + a, r + b));
// Castling from the home square.
const home = white ? 0 : 7;
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 null; // Premove was illegal in current position — discard silently
}
export function setCallback(fn) {
onPremoveExecuted = fn;
return [...out].map(to => ({ from: square, to }));
}
export function isPremoveSquare(square) {
if (!premove) return false;
return square === premove.from || square === premove.to;
/** Would this premove be a promotion (a pawn reaching the last rank)? */
export function isPromotion(piece, 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';
import { icon, gicon } from '../../../core/icons.js';
import { esc } from '../../../core/ui.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';
......@@ -162,6 +163,7 @@ export function mountGame(el, params) {
drawOfferAt: 0,
flagClaim: false,
lastTickSecond: null,
premove: null,
};
const token = gameState.token;
......@@ -262,16 +264,22 @@ export function mountGame(el, params) {
onMove: (from, to) => onBoardMove(el, from, to),
onPick: () => { audio.play('tap', 'game', { volume: 0.7 }); haptic('light'); },
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) => {
if (!canPlayerAct()) return false;
if (!canPremoveOrAct()) return false;
const pieceColor = piece === piece.toUpperCase() ? 'w' : 'b';
return pieceColor === gameState.playerColor;
};
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());
......@@ -361,10 +369,68 @@ function canPlayerAct() {
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
// ===========================================================================
function onBoardMove(el, from, to) {
if (gameState && !gameState.gameOver && engine.turn() !== gameState.playerColor && canPremoveOrAct()) {
queuePremove(el, from, to);
return;
}
if (!canPlayerAct()) return;
const piece = engine.get().get(from);
if (piece && piece.type === 'p' && (to[1] === '8' || to[1] === '1')) {
......@@ -507,6 +573,7 @@ async function requestBotMove(el, attempt = 0) {
showThinking(el, false);
afterMove(el, m, 'opp');
if (engine.isGameOver()) finishFromBoard();
else tryPremove(el);
} catch (e) {
if (!alive(token)) return;
// Back off, then give up gracefully rather than retrying forever.
......@@ -811,6 +878,7 @@ function ingestServerMoves(el, data) {
gameState.drawOfferAt = 0;
el.querySelector('#btn-draw')?.removeAttribute('aria-disabled');
if (engine.isGameOver()) finishFromBoard();
else tryPremove(el);
}
// ── Draw offers ──────────────────────────────────────────────────────────
......@@ -1001,6 +1069,8 @@ function endGame(result, reason, { serverAlreadyCompleted = false, serverData =
if (!gameState || gameState.gameOver) return;
const token = gameState.token;
gameState.gameOver = true;
gameState.premove = null;
board?.setPremove(null);
clock?.stop();
if (board) board.interactive = false;
showThinking(rootEl, false);
......@@ -1283,6 +1353,7 @@ function exposeTestHook() {
busy: () => !!(gameState && (gameState.botThinking || gameState.sending || gameState.syncing || board?.isAnimating())),
matchId: () => gameState?.matchId || 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
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 }) });
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();
......@@ -489,13 +492,55 @@ async function testBot(acc) {
await waitFor(page, () => !!window.__el3abChess && !!document.querySelector('#board-container canvas'));
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++) {
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;
let n = await hist();
const wasPremove = premoved === white[i];
if (wasPremove) {
// Played by itself when the bot's reply arrived: n already counts it.
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 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] === 'h6g7') { check('a pawn capture', san.includes('x'), san); await sleep(150); await shot(page, 'capture'); }
if (white[i] === 'g7h8q') {
......@@ -503,11 +548,12 @@ async function testBot(acc) {
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) {
if (i < black.length && !premoved) {
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;
}
}
page.botDelay = 0;
const last = await page.evaluate(() => window.__el3abChess.history().slice(-1)[0]);
check('the game ends with Bxf7#', last === 'Bxf7#', last);
await sleep(400);
......@@ -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); }
const d = await seated(pages, accA, accB);
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');
const offer = await d.white.waitForSelector('#draw-offer-dialog', { visible: true, timeout: 12000 }).catch(() => null);
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