/** * Chess rules adapter (docs/games/chess.md §8.0). Move generation/validation comes from our own generator * (`movegen.ts`, standard + Chess960, perft-verified); position keys, draw rules, insufficient material and the * FIDE 6.9 time-out exception are decided here with our own rules. */ import { Position, chess960Fen, moveInfo, type Move, type PieceType } from './movegen'; export { Position, chess960BackRank, chess960Fen } from './movegen'; export type Color = 'white' | 'black'; export type Promo = 'q' | 'r' | 'b' | 'n'; export const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; /** Chess960 number of the standard set-up (docs/games/chess.md §4). */ export const STANDARD_960_INDEX = 518; export const toSide = (c: Color): 'w' | 'b' => (c === 'white' ? 'w' : 'b'); export const fromSide = (s: 'w' | 'b'): Color => (s === 'w' ? 'white' : 'black'); export const other = (c: Color): Color => (c === 'white' ? 'black' : 'white'); export const PIECE_VALUE: Record = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 0 }; /** * One entry per (from, to); promotions are a single entry with `promotion: true` (client shows a picker). * Castling: standard lists the king's two-step (e1→g1). Chess960 lists the king onto its castling rook and, when that * is not also a plain king move, onto its destination square (both entries carry `castle`). */ export interface LegalMove { from: string; to: string; promotion?: true; castle?: 'king' | 'queen'; } export const isSquare = (s: string): boolean => /^[a-h][1-8]$/.test(s); /** Load a FEN. Chess960 is recognised by its Shredder-FEN castling letters unless `chess960` is given. */ export function load(fen: string, chess960?: boolean): Position { return Position.fromFen(fen, chess960); } /** Start FEN for a variant: standard, or the Chess960 position with that Scharnagl number. */ export const startFenFor = (chess960Index: number | null): string => (chess960Index === null ? START_FEN : chess960Fen(chess960Index)); /** * Repetition key: placement, side to move, castling rights, en-passant square. `fen()` prints the e.p. square only * when a legal e.p. capture exists, which is exactly the FIDE 9.2 normalisation. */ export function positionKey(fen: string): string { return fen.split(' ').slice(0, 4).join(' '); } export const keyOf = (c: Position): string => positionKey(c.fen()); /** Stored FEN keeps the e.p. square whenever a double step just happened (for accurate rejection messages). */ export const fullFen = (c: Position): string => c.fen({ forceEnpassantSquare: true }); export const halfmoveClock = (fen: string): number => Number(fen.split(' ')[4] ?? 0); export const turnOf = (fen: string): Color => (fen.split(' ')[1] === 'b' ? 'black' : 'white'); /** Legal moves for the side to move (optionally from one square), promotions collapsed. */ export function legalMoves(c: Position, square?: string): LegalMove[] { const out: LegalMove[] = []; const seen = new Set(); const push = (m: LegalMove) => { const k = m.from + m.to; if (seen.has(k)) return; seen.add(k); out.push(m); }; const all = c.moves(); const castles: Move[] = []; for (const m of all) { const from = moveInfo.from(m); if (square && from !== square) continue; const castle = moveInfo.isCastle(m); if (castle) castles.push(m); else push(m.promo ? { from, to: moveInfo.to(m), promotion: true } : { from, to: moveInfo.to(m) }); } for (const m of castles) { const castle = moveInfo.isCastle(m)!; const from = moveInfo.from(m); const to = moveInfo.to(m); if (!c.chess960) { push({ from, to, castle }); continue; } push({ from, to: moveInfo.rook(m), castle }); if (to !== from && !seen.has(from + to)) push({ from, to, castle }); } return out; } /** Count leaf nodes through the adapter (legalMoves → expand promotions → findMove → make). */ export function adapterPerft(fen: string, depth: number): number { const c = load(fen); const walk = (d: number): number => { let n = 0; for (const m of legalMoves(c)) { // Chess960 lists some castles twice (rook square and king destination); count each move once. if (m.castle && c.chess960 && c.get(m.to)?.type !== 'r') continue; for (const promotion of m.promotion ? (['q', 'r', 'b', 'n'] as const) : [undefined]) { if (d === 1) { n++; continue; } c.make(c.findMove(m.from, m.to, promotion)!); n += walk(d - 1); c.unmake(); } } return n; }; return depth === 0 ? 1 : walk(depth); } /** * Can `from → to` be reached by the piece's movement pattern, ignoring checks? Used only to pick the rejection * message ("킹이 공격받게 되어…" vs "그렇게 움직일 수 없어요"). Castling is handled separately. */ export function pseudoReachable(c: Position, from: string, to: string): boolean { const p = c.get(from); if (!p || from === to) return false; const target = c.get(to); if (target && target.color === p.color) return false; const fx = from.charCodeAt(0) - 97; const fy = Number(from[1]) - 1; const tx = to.charCodeAt(0) - 97; const ty = Number(to[1]) - 1; const dx = tx - fx; const dy = ty - fy; const adx = Math.abs(dx); const ady = Math.abs(dy); const sq = (x: number, y: number) => `${String.fromCharCode(97 + x)}${y + 1}`; const clear = () => { const sx = Math.sign(dx); const sy = Math.sign(dy); for (let x = fx + sx, y = fy + sy; x !== tx || y !== ty; x += sx, y += sy) if (c.get(sq(x, y))) return false; return true; }; switch (p.type) { case 'n': return (adx === 1 && ady === 2) || (adx === 2 && ady === 1); case 'k': return Math.max(adx, ady) === 1; case 'r': return (dx === 0 || dy === 0) && clear(); case 'b': return adx === ady && clear(); case 'q': return (dx === 0 || dy === 0 || adx === ady) && clear(); case 'p': { const dir = p.color === 'w' ? 1 : -1; if (dx === 0) { if (target) return false; if (dy === dir) return true; return dy === 2 * dir && fy === (p.color === 'w' ? 1 : 6) && !c.get(sq(fx, fy + dir)); } if (adx !== 1 || dy !== dir) return false; return !!target || c.fen({ forceEnpassantSquare: true }).split(' ')[3] === to; } } return false; } export const pieces = (c: Position) => c.pieces(); /** 'light' | 'dark' square colour (a1 is dark). */ const squareColor = (sq: string): 'light' | 'dark' => ((sq.charCodeAt(0) - 97 + Number(sq[1]) - 1) % 2 === 0 ? 'dark' : 'light'); /** * Automatic dead-position draws (§6 무승부 2): K vs K, KB vs K, KN vs K, and kings plus bishops only where every * bishop stands on the same square colour. KNN vs K is *not* included. */ export function insufficientMaterial(c: Position): boolean { const minors = pieces(c).filter((p) => p.type !== 'k'); if (minors.length === 0) return true; if (minors.length === 1 && (minors[0]!.type === 'b' || minors[0]!.type === 'n')) return true; if (minors.every((p) => p.type === 'b')) { const colors = new Set(minors.map((p) => squareColor(p.square))); return colors.size === 1; } return false; } /** * FIDE 6.9 `cannotMate(side)` (§6 무승부 6): true only for the conservative, certain cases. Everything else is * treated as "mate is possible" (so the flagged player loses). */ export function cannotMate(c: Position, side: 'w' | 'b'): boolean { const all = pieces(c).filter((p) => p.type !== 'k'); const mine = all.filter((p) => p.color === side); const theirs = all.filter((p) => p.color !== side); if (mine.length === 0) return true; if (mine.length === 1 && theirs.length === 0 && (mine[0]!.type === 'n' || mine[0]!.type === 'b')) return true; if (all.every((p) => p.type === 'b')) { return new Set(all.map((p) => squareColor(p.square))).size === 1; } return false; } /** The side to move's non-king pieces that are attacked and not defended (hints=movesAndThreats). */ export function threatenedSquares(c: Position): string[] { const us = c.turn(); const them = us === 'w' ? 'b' : 'w'; return pieces(c) .filter((p) => p.color === us && p.type !== 'k' && c.isAttacked(p.square, them) && !c.isAttacked(p.square, us)) .map((p) => p.square); } export function kingSquare(c: Position, side: 'w' | 'b'): string { return c.kingSquare(side); } /** White material minus black material (Q9 R5 B3 N3 P1). */ export function materialDiff(c: Position): number { let d = 0; for (const p of pieces(c)) d += (p.color === 'w' ? 1 : -1) * PIECE_VALUE[p.type]; return d; } /** 64 FEN piece letters (uppercase = white), index = (8 - rank) * 8 + file, i.e. a8 first. */ export function boardArray(fen: string): (string | null)[] { const out: (string | null)[] = []; for (const ch of fen.split(' ')[0]!) { if (ch === '/') continue; if (/\d/.test(ch)) for (let i = 0; i < Number(ch); i++) out.push(null); else out.push(ch); } return out; } /** PGN text from stored SAN moves (no replay needed). */ export function buildPgn(startFen: string, sans: string[], tags: Record, result: string): string { const head: Record = { Event: '같이 놀자 보드게임', Variant: 'Standard', ...tags, Result: result }; if (startFen !== START_FEN) { head.SetUp = '1'; head.FEN = startFen; } const lines = Object.entries(head).map(([k, v]) => `[${k} "${v.replace(/["\\]/g, '')}"]`); const [, side, , , , full] = startFen.split(' '); let no = Number(full ?? 1); let black = side === 'b'; const parts: string[] = []; sans.forEach((san, i) => { if (!black) parts.push(`${no}. ${san}`); else parts.push(i === 0 ? `${no}... ${san}` : san); if (black) no++; black = !black; }); parts.push(result); return `${lines.join('\n')}\n\n${parts.join(' ')}`; }