/** * 체스 (docs/games/chess.md): standard and Chess960 on one perft-verified move generator (`movegen.ts`, §8.0). */ import { z } from 'zod'; import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine'; import { boardArray, buildPgn, cannotMate, halfmoveClock, insufficientMaterial, isSquare, kingSquare, legalMoves, load, materialDiff, other, fullFen, keyOf, positionKey, pseudoReachable, startFenFor, threatenedSquares, toSide, type Color, type LegalMove, type Position, } from './rules'; export type { Color, LegalMove, Promo } from './rules'; const COLOR_KO: Record = { white: '백', black: '흑' }; export const chessOptionsSchema = z.object({ variant: z .enum(['standard', 'chess960']) .default('standard') .meta({ title: '변형', labels: { standard: '일반', chess960: '체스960(첫 줄 무작위)' } }), timeControl: z .object({ kind: z .enum(['fischer', 'none']) .default('fischer') .meta({ title: '시간 방식', labels: { fischer: '기본 시간 + 수당 추가', none: '시간 제한 없음' } }), baseMin: z.number().int().min(1).max(90).default(10).meta({ title: '기본 시간(분)' }), incSec: z.number().int().min(0).max(60).default(5).meta({ title: '한 수마다 추가(초)' }), }) .default({ kind: 'fischer', baseMin: 10, incSec: 5 }) .meta({ title: '시간 제한' }), drawMode: z .enum(['auto', 'fide']) .default('auto') .meta({ title: '반복·50수 무승부', labels: { auto: '자동 무승부(3회 반복·50수)', fide: 'FIDE(주장해야 무승부, 5회 반복·75수는 자동)' }, }), colorAssignment: z .enum(['random', 'hostWhite', 'hostBlack', 'alternate']) .default('random') .meta({ title: '백흑 정하기', labels: { random: '무작위', hostWhite: '방장이 백', hostBlack: '방장이 흑', alternate: '판마다 번갈아' } }), autoQueen: z.boolean().default(false).meta({ title: '승진하면 퀸으로 자동 선택' }), undo: z .enum(['off', '1', '3', 'unlimited']) .default('1') .meta({ title: '무르기(1인당, 상대 동의)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }), drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }), drawReofferMoves: z.number().int().min(0).max(50).default(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }), firstMoveSec: z .union([z.literal(15), z.literal(30), z.literal(60)]) .default(30) .meta({ title: '첫 수 제한(넘기면 대국 취소)', labels: { 15: '15초', 30: '30초', 60: '60초' } }), idleLimitSec: z .union([z.literal(300), z.literal(600)]) .default(600) .meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }), hints: z .enum(['off', 'moves']) .default('moves') .meta({ title: '초보 도움말', labels: { off: '끄기', moves: '갈 수 있는 칸 표시' } }), threatHints: z.boolean().default(false).meta({ title: '위협 가이드(관리자 전용): 공격받는 내 기물 표시' }), }); export type ChessOptions = z.infer; const sq = z.string().regex(/^[a-h][1-8]$/); export const chessActionSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('move'), from: sq, to: sq, promotion: z.enum(['q', 'r', 'b', 'n']).optional() }), z.object({ type: z.literal('castle'), side: z.enum(['king', 'queen']) }), z.object({ type: z.literal('claimDraw') }), z.object({ type: z.literal('requestUndo') }), z.object({ type: z.literal('respondUndo'), accept: z.boolean() }), z.object({ type: z.literal('offerDraw') }), z.object({ type: z.literal('respondDraw'), accept: z.boolean() }), z.object({ type: z.literal('cancelOffer') }), z.object({ type: z.literal('resign') }), ]); /** Client-sendable actions plus the system-only timeout action (not in the schema). */ export type ChessAction = z.infer | { type: 'timeout' }; export type ChessReason = | 'checkmate' | 'resign' | 'timeLoss' | 'stalemate' | 'insufficientMaterial' | 'threefold' | 'fivefold' | 'fiftyMove' | 'seventyFiveMove' | 'drawAgreed' | 'timeoutVsInsufficient' | 'aborted'; export interface ChessResult { winner: Color | null; reason: ChessReason; /** Full moves played. */ moveCount: number; pgn: string; summary: string; } export interface ChessMoveRecord { uci: string; san: string; color: Color; at: number; fenAfter: string; /** Normalised repetition key of fenAfter. */ key: string; captured?: string; } export interface ChessState { options: ChessOptions; players: Record; /** Shredder-FEN for Chess960 (castling rights as rook files, e.g. `HAha`). */ startFen: string; /** Chess960 Scharnagl number 0..959 (518 = standard set-up); null for standard chess. */ chess960Index: number | null; /** Current position (single source of truth). */ fen: string; moves: ChessMoveRecord[]; /** positionKey → occurrences, including the start position. */ repetition: Record; phase: 'playing' | 'finished'; clock: { remainingMs: Record; turnStartedAt: number; started: Record }; undo: { usedBy: Record; pending: { by: Color; plies: 1 | 2 } | null }; draw: { pendingBy: Color | null; lastOfferPly: Record }; /** drawMode=fide: what the side to move may claim right now. */ claimable: 'threefold' | 'fiftyMove' | null; result: ChessResult | null; } export interface ChessView { fen: string; /** 64 FEN letters (uppercase = white), a8 first, then b8 … h1. */ board: (string | null)[]; players: Record; /** Chess960 number, null for standard chess. */ chess960Index: number | null; myColor: Color | null; turn: Color; phase: ChessState['phase']; moves: { san: string; uci: string; color: Color }[]; lastMove: { from: string; to: string } | null; /** King square of the side to move when it is in check. */ checkSquare: string | null; timeControl: ChessOptions['timeControl']; firstMoveSec: number; idleLimitSec: number; remainingMs: Record; turnStartedAt: number; started: Record; /** Pieces captured *by* each colour (lowercase letters). */ captured: Record; /** White material minus black material. */ materialDiff: number; undo: { allowed: boolean; remaining: Record; pending: { by: Color; plies: number } | null }; draw: { allowed: boolean; pendingBy: Color | null }; drawMode: ChessOptions['drawMode']; claimable: ChessState['claimable']; autoQueen: boolean; hints: ChessOptions['hints']; /** Only for the player to move. */ legalMoves: LegalMove[] | null; /** Only for the player to move with threatHints (admin-only option). */ threatened: string[] | null; result: ChessResult | null; } const undoLimit = (o: ChessOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo)); const colorOf = (s: ChessState, p: PlayerId): Color | null => s.players.white === p ? 'white' : s.players.black === p ? 'black' : null; const turnOf = (s: ChessState): Color => (s.fen.split(' ')[1] === 'b' ? 'black' : 'white'); /** `!= null`: finished games saved before v2 are never migrated and have no `chess960Index`. */ const is960 = (s: ChessState): boolean => s.chess960Index != null; /** Load a position of this game (Chess960 rules even after both sides lost their castling rights). */ const position = (s: ChessState, fen = s.fen): Position => load(fen, is960(s)); const REASON_KO: Record = { checkmate: '체크메이트', resign: '기권', timeLoss: '시간패', stalemate: '스테일메이트', insufficientMaterial: '기물 부족', threefold: '3회 반복', fivefold: '5회 반복', fiftyMove: '50수 규칙', seventyFiveMove: '75수 규칙', drawAgreed: '합의', timeoutVsInsufficient: '시간 초과, 상대 기물 부족', aborted: '대국 취소', }; function finish(s: ChessState, winner: Color | null, reason: ChessReason, events: GameEvent[], who?: Color): void { s.phase = 'finished'; s.undo.pending = null; s.draw.pendingBy = null; s.claimable = null; const moveCount = Math.ceil(s.moves.length / 2); let summary: string; if (reason === 'aborted') summary = `대국 취소 (${COLOR_KO[who ?? turnOf(s)]}이 첫 수를 두지 않았어요)`; else if (winner) summary = `${COLOR_KO[winner]} 승 (${REASON_KO[reason]}, ${moveCount}수)`; else summary = `무승부 (${REASON_KO[reason]}, ${moveCount}수)`; const tag = reason === 'aborted' ? '*' : winner === 'white' ? '1-0' : winner === 'black' ? '0-1' : '1/2-1/2'; const pgn = buildPgn( s.startFen, s.moves.map((m) => m.san), { ...(is960(s) ? { Variant: 'Chess960' } : {}), White: s.players.white, Black: s.players.black, Termination: REASON_KO[reason] }, tag, ); s.result = { winner, reason, moveCount, pgn, summary }; events.push({ type: 'gameEnded', winner, reason }); } /** Rebuild repetition counts from the start position and the stored positions after each move. */ function rebuildRepetition(s: ChessState): void { s.repetition = {}; for (const k of [keyOf(position(s, s.startFen)), ...s.moves.map((m) => m.key)]) s.repetition[k] = (s.repetition[k] ?? 0) + 1; } /** §8.2 end order: checkmate > stalemate > insufficient > (75/5-fold) > (auto: 50/3-fold) → claimable. */ function judge(s: ChessState, c: Position, events: GameEvent[]): void { const mover = other(turnOf(s)); if (c.isCheckmate()) { events.push({ type: 'checkmate', color: mover }); return finish(s, mover, 'checkmate', events); } if (c.isStalemate()) { events.push({ type: 'stalemate' }); return finish(s, null, 'stalemate', events); } if (insufficientMaterial(c)) return finish(s, null, 'insufficientMaterial', events); const key = keyOf(c); const reps = s.repetition[key] ?? 0; const half = halfmoveClock(s.fen); if (s.options.drawMode === 'fide') { if (half >= 150) return finish(s, null, 'seventyFiveMove', events); if (reps >= 5) return finish(s, null, 'fivefold', events); } else { if (half >= 100) return finish(s, null, 'fiftyMove', events); if (reps >= 3) return finish(s, null, 'threefold', events); } refreshClaimable(s, key); if (s.claimable) events.push({ type: 'drawClaimable', reason: s.claimable }); } function refreshClaimable(s: ChessState, key: string): void { if (s.phase !== 'playing' || s.options.drawMode !== 'fide') { s.claimable = null; return; } const reps = s.repetition[key] ?? 0; s.claimable = reps >= 3 ? 'threefold' : halfmoveClock(s.fen) >= 100 ? 'fiftyMove' : null; } /** Time ran out for `loser`: loss, unless the opponent cannot possibly mate (FIDE 6.9). */ function flag(s: ChessState, loser: Color, events: GameEvent[]): void { if (s.options.timeControl.kind === 'fischer') s.clock.remainingMs[loser] = 0; events.push({ type: 'timeLoss', color: loser }); const winner = other(loser); if (cannotMate(position(s), toSide(winner))) finish(s, null, 'timeoutVsInsufficient', events); else finish(s, winner, 'timeLoss', events); } /** * Copy for `apply`. Move records, options, players, pending requests and results are never mutated once stored, so * they are shared; everything `apply` mutates in place is copied. (structuredClone of a long game cost ~0.5ms a move.) */ function cloneState(p: ChessState): ChessState { return { ...p, moves: p.moves.slice(), repetition: { ...p.repetition }, clock: { remainingMs: { ...p.clock.remainingMs }, turnStartedAt: p.clock.turnStartedAt, started: { ...p.clock.started } }, undo: { usedBy: { ...p.undo.usedBy }, pending: p.undo.pending }, draw: { pendingBy: p.draw.pendingBy, lastOfferPly: { ...p.draw.lastOfferPly } }, }; } /** From/to squares of a stored move. Chess960 castling UCI is king → rook; the king actually lands on c or g. */ function lastMoveOf(m: ChessMoveRecord): { from: string; to: string } { const from = m.uci.slice(0, 2); if (!m.san.startsWith('O-O')) return { from, to: m.uci.slice(2, 4) }; return { from, to: (m.san.startsWith('O-O-O') ? 'c' : 'g') + from[1] }; } function pendingResponder(s: ChessState): Color | null { if (s.undo.pending) return other(s.undo.pending.by); if (s.draw.pendingBy) return other(s.draw.pendingBy); return null; } export const chess: GameDefinition = { id: 'chess', nameKo: '체스', minPlayers: 2, maxPlayers: 2, stateVersion: 2, defaultOptions: chessOptionsSchema.parse({}), optionsSchema: chessOptionsSchema, adminOnlyOptions: ['threatHints'], actionSchema: chessActionSchema as z.ZodType, setup({ players, options, rng, now, gameNo }) { const [host, guest] = players as [PlayerId, PlayerId]; let hostWhite: boolean; switch (options.colorAssignment) { case 'hostWhite': hostWhite = true; break; case 'hostBlack': hostWhite = false; break; case 'alternate': hostWhite = (gameNo ?? 1) % 2 === 1; break; default: hostWhite = rng.int(2) === 0; } const base = options.timeControl.baseMin * 60_000; const chess960Index = options.variant === 'chess960' ? rng.int(960) : null; const startFen = startFenFor(chess960Index); return { options, players: hostWhite ? { white: host, black: guest } : { white: guest, black: host }, startFen, chess960Index, fen: startFen, moves: [], repetition: { [positionKey(startFen)]: 1 }, phase: 'playing', clock: { remainingMs: { white: base, black: base }, turnStartedAt: now, started: { white: false, black: false } }, undo: { usedBy: { white: 0, black: 0 }, pending: null }, draw: { pendingBy: null, lastOfferPly: { white: null, black: null } }, claimable: null, result: null, }; }, validate(s, actor, a) { const me = colorOf(s, actor); if (!me) return fail('이 게임의 플레이어가 아니에요.'); if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.'); const turn = turnOf(s); switch (a.type) { case 'move': case 'castle': { if (turn !== me) return fail('지금은 상대 차례예요.'); const c = position(s); if (a.type === 'castle') { return legalMoves(c).some((m) => m.castle === a.side) ? ok : fail('지금은 그쪽으로 캐슬링할 수 없어요.'); } if (!isSquare(a.from) || !isSquare(a.to)) return fail('판 밖이에요.'); const piece = c.get(a.from); if (!piece || piece.color !== toSide(me)) return fail('내 기물을 골라 주세요.'); const m = legalMoves(c, a.from).find((x) => x.to === a.to); if (!m) { const target = c.get(a.to); const fileGap = Math.abs(a.from.charCodeAt(0) - a.to.charCodeAt(0)); const castleTry = c.chess960 ? (target?.type === 'r' && target.color === piece.color) || (fileGap >= 2 && 'cg'.includes(a.to[0]!)) : fileGap === 2; if (piece.type === 'k' && a.from[1] === a.to[1] && castleTry) { return fail('지금은 캐슬링할 수 없어요.'); } if (pseudoReachable(c, a.from, a.to)) { return fail(c.inCheck() ? '체크를 먼저 막아야 해요.' : '킹이 공격받게 되어 둘 수 없어요.'); } return fail('그 기물은 그렇게 움직일 수 없어요.'); } if (m.promotion && !a.promotion && !s.options.autoQueen) return fail('승진할 기물을 골라 주세요.'); return ok; } case 'claimDraw': if (s.options.drawMode !== 'fide') return fail('이 방은 반복·50수 무승부가 자동이에요.'); if (turn !== me) return fail('무승부 주장은 내 차례에만 할 수 있어요.'); if (!s.claimable) return fail('지금은 무승부를 주장할 수 없어요.'); return ok; case 'requestUndo': if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.'); if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.'); if (s.undo.usedBy[me] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.'); if (!s.moves.some((m) => m.color === me)) return fail('아직 무를 수가 없어요.'); return ok; case 'respondUndo': if (!s.undo.pending || s.undo.pending.by === me) return fail('응답할 무르기 요청이 없어요.'); return ok; case 'offerDraw': { if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.'); if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.'); const last = s.draw.lastOfferPly[me]; const gap = s.options.drawReofferMoves; if (last !== null && s.moves.slice(last).filter((m) => m.color === me).length < gap) { return fail(`무승부 제안은 ${gap}수를 더 둔 뒤에 다시 할 수 있어요.`); } return ok; } case 'respondDraw': if (!s.draw.pendingBy || s.draw.pendingBy === me) return fail('응답할 무승부 제안이 없어요.'); return ok; case 'cancelOffer': if (s.undo.pending?.by !== me && s.draw.pendingBy !== me) return fail('취소할 요청이 없어요.'); return ok; case 'resign': return ok; case 'timeout': if (turn !== me) return fail('지금은 상대 차례예요.'); return ok; } }, apply(prev, actor, a, { now }) { const s = cloneState(prev); const me = colorOf(s, actor)!; const events: GameEvent[] = []; switch (a.type) { case 'move': case 'castle': { // 1) Clock. A colour's first move only has the first-move limit; its main clock is not charged. const elapsed = now - s.clock.turnStartedAt; if (!s.clock.started[me]) { if (elapsed > s.options.firstMoveSec * 1000) { finish(s, null, 'aborted', events, me); break; } s.clock.started[me] = true; } else if (s.options.timeControl.kind === 'fischer') { s.clock.remainingMs[me] -= elapsed; if (s.clock.remainingMs[me] <= 0) { flag(s, me, events); break; } s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000; } // 2) Move. const c = position(s); const legal = c.moves(); const mv = a.type === 'castle' ? c.castleMove(a.side, legal)! : c.findMove(a.from, a.to, a.promotion, legal)!; const done = c.play(mv, legal); s.fen = fullFen(c); const key = keyOf(c); s.moves.push({ uci: done.uci, san: done.san, color: me, at: now, fenAfter: s.fen, key, ...(done.captured ? { captured: done.captured } : {}), }); s.repetition[key] = (s.repetition[key] ?? 0) + 1; s.clock.turnStartedAt = now; s.undo.pending = null; if (s.draw.pendingBy && s.draw.pendingBy !== me) { s.draw.pendingBy = null; events.push({ type: 'drawDeclined', auto: true }); } const check = c.inCheck(); events.push({ type: 'moved', color: me, san: done.san, from: done.from, to: done.to, captured: done.captured ?? null, check }); if (done.promotion) events.push({ type: 'promoted', color: me, piece: done.promotion }); if (done.castle) events.push({ type: 'castled', color: me, side: done.castle }); if (done.enPassant) events.push({ type: 'enPassant', color: me }); if (check) events.push({ type: 'check', color: other(me) }); // 3) End of game / claimable. judge(s, c, events); break; } case 'timeout': if (!s.clock.started[me]) finish(s, null, 'aborted', events, me); else flag(s, me, events); break; case 'claimDraw': events.push({ type: 'drawClaimed', color: me, reason: s.claimable }); finish(s, null, s.claimable!, events); break; case 'requestUndo': { const last = s.moves[s.moves.length - 1]!; s.undo.pending = { by: me, plies: last.color === me ? 1 : 2 }; events.push({ type: 'undoRequested', color: me, plies: s.undo.pending.plies }); break; } case 'respondUndo': { const { by, plies } = s.undo.pending!; s.undo.pending = null; if (!a.accept) { events.push({ type: 'undoDeclined', color: by }); break; } // The clock is not rewound, but the side to move is charged for the time it spent until now. const onTurn = turnOf(s); if (s.clock.started[onTurn] && s.options.timeControl.kind === 'fischer') { s.clock.remainingMs[onTurn] = Math.max(0, s.clock.remainingMs[onTurn] - (now - s.clock.turnStartedAt)); } s.moves.splice(s.moves.length - plies, plies); s.fen = s.moves.length ? s.moves[s.moves.length - 1]!.fenAfter : s.startFen; rebuildRepetition(s); s.undo.usedBy[by]++; s.clock.turnStartedAt = now; refreshClaimable(s, s.moves.length ? s.moves[s.moves.length - 1]!.key : keyOf(position(s, s.startFen))); events.push({ type: 'undoAccepted', color: by, plies }); break; } case 'offerDraw': s.draw.pendingBy = me; s.draw.lastOfferPly[me] = s.moves.length; events.push({ type: 'drawOffered', color: me }); break; case 'respondDraw': s.draw.pendingBy = null; if (a.accept) { events.push({ type: 'drawAccepted' }); finish(s, null, 'drawAgreed', events); } else events.push({ type: 'drawDeclined' }); break; case 'cancelOffer': if (s.undo.pending?.by === me) s.undo.pending = null; if (s.draw.pendingBy === me) s.draw.pendingBy = null; events.push({ type: 'offerCancelled', color: me }); break; case 'resign': // Before your own first move a resignation just cancels the game (no result recorded). if (!s.clock.started[me]) finish(s, null, 'aborted', events, me); else { events.push({ type: 'resigned', color: me }); finish(s, other(me), 'resign', events); } break; } return { state: s, events }; }, view(s, viewer) { const myColor = viewer ? colorOf(s, viewer) : null; const turn = turnOf(s); const c = position(s); const mover = s.phase === 'playing' && myColor !== null && myColor === turn; const limit = undoLimit(s.options); const last = s.moves[s.moves.length - 1]; const captured: Record = { white: [], black: [] }; for (const m of s.moves) if (m.captured) captured[m.color].push(m.captured); return { fen: s.fen, board: boardArray(s.fen), players: s.players, chess960Index: s.chess960Index ?? null, myColor, turn, phase: s.phase, moves: s.moves.map((m) => ({ san: m.san, uci: m.uci, color: m.color })), lastMove: last ? lastMoveOf(last) : null, checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null, timeControl: s.options.timeControl, firstMoveSec: s.options.firstMoveSec, idleLimitSec: s.options.idleLimitSec, remainingMs: s.clock.remainingMs, turnStartedAt: s.clock.turnStartedAt, started: s.clock.started, captured, materialDiff: materialDiff(c), undo: { allowed: s.options.undo !== 'off', remaining: { white: Number.isFinite(limit) ? limit - s.undo.usedBy.white : null, black: Number.isFinite(limit) ? limit - s.undo.usedBy.black : null, }, pending: s.undo.pending, }, draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy }, drawMode: s.options.drawMode, claimable: s.claimable, autoQueen: s.options.autoQueen, hints: s.options.hints, legalMoves: mover ? legalMoves(c) : null, threatened: mover && s.options.threatHints ? threatenedSquares(c) : null, result: s.result, }; }, activePlayers(s) { if (s.phase !== 'playing') return []; const turn = turnOf(s); const out = [s.players[turn]]; const responder = pendingResponder(s); if (responder && responder !== turn) out.push(s.players[responder]); return out; }, deadline(s) { if (s.phase !== 'playing') return null; const turn = turnOf(s); const start = s.clock.turnStartedAt; if (!s.clock.started[turn]) return start + s.options.firstMoveSec * 1000; if (s.options.timeControl.kind === 'fischer') return start + s.clock.remainingMs[turn]; return start + s.options.idleLimitSec * 1000; }, timeoutPlayers(s) { return s.phase === 'playing' ? [s.players[turnOf(s)]] : []; }, onTimeout() { return { type: 'timeout' }; }, onLeave() { return { type: 'resign' }; }, result(s): GameResult | null { if (!s.result) return null; const { winner, reason, summary } = s.result; let ranking: PlayerId[][]; if (reason === 'aborted') ranking = []; else if (winner) ranking = [[s.players[winner]], [s.players[other(winner)]]]; else ranking = [[s.players.white, s.players.black]]; const map: Record = { checkmate: 'normal', stalemate: 'normal', insufficientMaterial: 'normal', threefold: 'normal', fivefold: 'normal', fiftyMove: 'normal', seventyFiveMove: 'normal', resign: 'resign', timeLoss: 'timeout', timeoutVsInsufficient: 'timeout', drawAgreed: 'draw-agreed', aborted: 'abandoned', }; return { ranking, summary, reason: map[reason] }; }, /** v1 (standard only) → v2: Chess960 option and `chess960Index`. */ migrate(old, fromVersion) { const s = old as ChessState; if (fromVersion < 2) return { ...s, options: chessOptionsSchema.parse(s.options), chess960Index: null }; return s; }, legalActions(s, p) { const me = colorOf(s, p); if (!me || s.phase !== 'playing') return []; const responder = pendingResponder(s); if (responder === me) { return s.undo.pending ? [ { type: 'respondUndo', accept: true }, { type: 'respondUndo', accept: false }, ] : [{ type: 'respondDraw', accept: false }]; } if (turnOf(s) !== me) return []; const out: ChessAction[] = []; const castles = new Set<'king' | 'queen'>(); for (const m of legalMoves(position(s))) { if (m.promotion) for (const promotion of ['q', 'r', 'b', 'n'] as const) out.push({ type: 'move', from: m.from, to: m.to, promotion }); else out.push({ type: 'move', from: m.from, to: m.to }); if (m.castle) castles.add(m.castle); } for (const side of castles) out.push({ type: 'castle', side }); if (s.claimable) out.push({ type: 'claimDraw' }); for (const extra of [{ type: 'requestUndo' }, { type: 'offerDraw' }] as const) { if (chess.validate(s, p, extra).ok) out.push(extra); } return out; }, };