/** * Chess960 E2E helper: for a start number, a short legal line in which white clears its back rank and castles * (whichever side is ready first) while black only shuffles a knight between its start square and the 6th rank. * Black never attacks white's back rank that way, and white only develops to ranks 2–3 without capturing, so the * line exists for every one of the 960 positions (checked for all of them when this was written). */ import { Position, chess960Fen, type Move } from '../packages/games/src/chess/movegen'; const PAWN = 1; const KNIGHT = 2; export interface Ply { /** UCI as the server records it (Chess960 castling: king square + rook square). */ uci: string; san: string; castle: boolean; } /** Squares (0x88) that must be empty, except for the king and rook themselves, for white castling on one side. */ function needEmpty(p: Position, i: 0 | 1): number[] { const k = p.king[0]!; const r = p.castle[i]!; if (r < 0) return []; const out = new Set(); for (const [a, z] of [ [k, i === 0 ? 6 : 2], [r, i === 0 ? 5 : 3], ] as const) { for (let s = Math.min(a, z); s <= Math.max(a, z); s++) if (s !== k && s !== r) out.add(s); } return [...out]; } function blockers(p: Position, i: 0 | 1): number { return needEmpty(p, i).filter((s) => p.b[s]).length; } /** Legal moves of the side *not* to move (null move), for "can this blocker leave next turn?". */ function nextTurnMoves(p: Position): Move[] { const ep = p.ep; p.ep = -1; p.side ^= 1; const out = p.moves(); p.side ^= 1; p.ep = ep; return out; } export function plan960(index: number, maxPlies = 40): Ply[] { const p = Position.fromFen(chess960Fen(index)); const plies: Ply[] = []; const push = (m: Move, legal: Move[]) => { const done = p.play(m, legal); plies.push({ uci: done.uci, san: done.san, castle: !!done.castle }); }; let knightHome = -1; while (plies.length < maxPlies) { const legal = p.moves(); if (p.side === 1) { // Black: one knight out to the 6th rank and back. const out = knightHome < 0; const m = out ? legal.find((x) => x.piece === KNIGHT && x.from >> 4 === 7 && x.to >> 4 === 5 && !x.captured)! : legal.find((x) => x.piece === KNIGHT && x.to === knightHome)!; knightHome = out ? m.from : -1; push(m, legal); continue; } const castle = legal.find((x) => x.rook >= 0); if (castle) { push(castle, legal); return plies; } // White: the quiet move that best frees either castling side (fewest blockers left, then most blockers able to // leave). Only pawns step forward (to rank 3) and blockers step out of the way (to ranks 1–3, never into either // side's squares), so nothing can repeat and the line always makes progress. const zones = ([0, 1] as const).filter((i) => p.castle[i]! >= 0).map((i) => ({ i, zone: new Set(needEmpty(p, i)) })); const anyZone = (sq: number) => zones.some((z) => z.zone.has(sq)); let best: Move | null = null; let bestScore = Infinity; for (const m of legal) { if (m.captured || m.to >> 4 > 2 || anyZone(m.to) || !(anyZone(m.from) || m.piece === PAWN)) continue; p.make(m); const next = nextTurnMoves(p); for (const { i, zone } of zones) { if (p.castle[i]! < 0) continue; const leavers = new Set(next.filter((x) => zone.has(x.from) && !anyZone(x.to) && x.to >> 4 <= 2 && !x.captured).map((x) => x.from)).size; const score = blockers(p, i) * 1000 - leavers * 10 + (m.to >> 4) + m.to / 1000; if (score < bestScore) { bestScore = score; best = m; } } p.unmake(); } if (!best) throw new Error(`960 #${index}: no developing move after ${plies.map((x) => x.san).join(' ')}`); push(best, legal); } throw new Error(`960 #${index}: no castling within ${maxPlies} plies`); }