M9: 체스 자체 수 생성기(체스960 포함), perft·chess.js 비교 테스트
- movegen.ts: 0x88 생성기, 캐슬링 권리를 룩 칸으로 저장(standard·960 공용), Shredder-FEN/X-FEN - chess.js는 테스트 전용 devDependency로 이동(perft·무작위 대국 비교) - variant 옵션(일반/체스960), chess960Index, stateVersion 2 + v1 마이그레이션 - 960 캐슬링 규칙·입력(킹→룩 칸, 킹→g/c칸)·무작위 대국 테스트 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* 체스 (docs/games/chess.md). Standard chess only: chess.js 1.4.0 has no Chess960 castling support, so the
|
||||
* `variant` option is left out until a 960-capable generator is chosen (§8.0).
|
||||
* 체스 (docs/games/chess.md): standard and Chess960 on one perft-verified move generator (`movegen.ts`, §8.0).
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { Chess, Square } from 'chess.js';
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
|
||||
import {
|
||||
START_FEN,
|
||||
boardArray,
|
||||
buildPgn,
|
||||
cannotMate,
|
||||
@@ -22,17 +19,22 @@ import {
|
||||
keyOf,
|
||||
positionKey,
|
||||
pseudoReachable,
|
||||
startFenFor,
|
||||
threatenedSquares,
|
||||
toSide,
|
||||
type Color,
|
||||
type LegalMove,
|
||||
type Promo,
|
||||
type Position,
|
||||
} from './rules';
|
||||
|
||||
export type { Color, LegalMove, Promo } from './rules';
|
||||
const COLOR_KO: Record<Color, string> = { 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
|
||||
@@ -129,7 +131,10 @@ export interface ChessMoveRecord {
|
||||
export interface ChessState {
|
||||
options: ChessOptions;
|
||||
players: Record<Color, PlayerId>;
|
||||
/** 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[];
|
||||
@@ -149,6 +154,8 @@ export interface ChessView {
|
||||
/** 64 FEN letters (uppercase = white), a8 first, then b8 … h1. */
|
||||
board: (string | null)[];
|
||||
players: Record<Color, PlayerId>;
|
||||
/** Chess960 number, null for standard chess. */
|
||||
chess960Index: number | null;
|
||||
myColor: Color | null;
|
||||
turn: Color;
|
||||
phase: ChessState['phase'];
|
||||
@@ -183,6 +190,10 @@ const undoLimit = (o: ChessOptions): number => (o.undo === 'off' ? 0 : 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<ChessReason, string> = {
|
||||
checkmate: '체크메이트',
|
||||
@@ -213,7 +224,7 @@ function finish(s: ChessState, winner: Color | null, reason: ChessReason, events
|
||||
const pgn = buildPgn(
|
||||
s.startFen,
|
||||
s.moves.map((m) => m.san),
|
||||
{ White: s.players.white, Black: s.players.black, Termination: REASON_KO[reason] },
|
||||
{ ...(is960(s) ? { Variant: 'Chess960' } : {}), White: s.players.white, Black: s.players.black, Termination: REASON_KO[reason] },
|
||||
tag,
|
||||
);
|
||||
s.result = { winner, reason, moveCount, pgn, summary };
|
||||
@@ -223,11 +234,11 @@ function finish(s: ChessState, winner: Color | null, reason: ChessReason, events
|
||||
/** 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(load(s.startFen)), ...s.moves.map((m) => m.key)]) s.repetition[k] = (s.repetition[k] ?? 0) + 1;
|
||||
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: Chess, events: GameEvent[]): void {
|
||||
function judge(s: ChessState, c: Position, events: GameEvent[]): void {
|
||||
const mover = other(turnOf(s));
|
||||
if (c.isCheckmate()) {
|
||||
events.push({ type: 'checkmate', color: mover });
|
||||
@@ -266,15 +277,30 @@ 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(load(s.fen), toSide(winner))) finish(s, null, 'timeoutVsInsufficient', events);
|
||||
if (cannotMate(position(s), toSide(winner))) finish(s, null, 'timeoutVsInsufficient', events);
|
||||
else finish(s, winner, 'timeLoss', events);
|
||||
}
|
||||
|
||||
function resolveMove(s: ChessState, a: ChessAction): { from: Square; to: Square; promotion?: Promo } | null {
|
||||
if (a.type === 'move') return { from: a.from as Square, to: a.to as Square, promotion: a.promotion };
|
||||
if (a.type !== 'castle') return null;
|
||||
const m = legalMoves(load(s.fen)).find((x) => x.castle === a.side);
|
||||
return m ? { from: m.from as Square, to: m.to as Square } : null;
|
||||
/**
|
||||
* 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 {
|
||||
@@ -288,7 +314,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
nameKo: '체스',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
stateVersion: 1,
|
||||
stateVersion: 2,
|
||||
defaultOptions: chessOptionsSchema.parse({}),
|
||||
optionsSchema: chessOptionsSchema,
|
||||
actionSchema: chessActionSchema as z.ZodType<ChessAction>,
|
||||
@@ -310,13 +336,16 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
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: START_FEN,
|
||||
fen: START_FEN,
|
||||
startFen,
|
||||
chess960Index,
|
||||
fen: startFen,
|
||||
moves: [],
|
||||
repetition: { [positionKey(START_FEN)]: 1 },
|
||||
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 },
|
||||
@@ -335,7 +364,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
case 'move':
|
||||
case 'castle': {
|
||||
if (turn !== me) return fail('지금은 상대 차례예요.');
|
||||
const c = load(s.fen);
|
||||
const c = position(s);
|
||||
if (a.type === 'castle') {
|
||||
return legalMoves(c).some((m) => m.castle === a.side) ? ok : fail('지금은 그쪽으로 캐슬링할 수 없어요.');
|
||||
}
|
||||
@@ -344,7 +373,12 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
if (!piece || piece.color !== toSide(me)) return fail('내 기물을 골라 주세요.');
|
||||
const m = legalMoves(c, a.from).find((x) => x.to === a.to);
|
||||
if (!m) {
|
||||
if (piece.type === 'k' && Math.abs(a.from.charCodeAt(0) - a.to.charCodeAt(0)) === 2 && a.from[1] === a.to[1]) {
|
||||
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)) {
|
||||
@@ -394,7 +428,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { now }) {
|
||||
const s = structuredClone(prev);
|
||||
const s = cloneState(prev);
|
||||
const me = colorOf(s, actor)!;
|
||||
const events: GameEvent[] = [];
|
||||
switch (a.type) {
|
||||
@@ -417,14 +451,14 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000;
|
||||
}
|
||||
// 2) Move.
|
||||
const mv = resolveMove(s, a)!;
|
||||
const c = load(s.fen);
|
||||
const isPromo = c.get(mv.from)?.type === 'p' && (mv.to[1] === '8' || mv.to[1] === '1');
|
||||
const done = c.move({ from: mv.from, to: mv.to, promotion: isPromo ? (mv.promotion ?? 'q') : undefined });
|
||||
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.lan,
|
||||
uci: done.uci,
|
||||
san: done.san,
|
||||
color: me,
|
||||
at: now,
|
||||
@@ -441,11 +475,9 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
}
|
||||
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.isPromotion()) events.push({ type: 'promoted', color: me, piece: done.promotion });
|
||||
if (done.isKingsideCastle() || done.isQueensideCastle()) {
|
||||
events.push({ type: 'castled', color: me, side: done.isKingsideCastle() ? 'king' : 'queen' });
|
||||
}
|
||||
if (done.isEnPassant()) events.push({ type: 'enPassant', color: me });
|
||||
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);
|
||||
@@ -482,7 +514,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
rebuildRepetition(s);
|
||||
s.undo.usedBy[by]++;
|
||||
s.clock.turnStartedAt = now;
|
||||
refreshClaimable(s, s.moves.length ? s.moves[s.moves.length - 1]!.key : keyOf(load(s.startFen)));
|
||||
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;
|
||||
}
|
||||
@@ -518,7 +550,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
view(s, viewer) {
|
||||
const myColor = viewer ? colorOf(s, viewer) : null;
|
||||
const turn = turnOf(s);
|
||||
const c = load(s.fen);
|
||||
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];
|
||||
@@ -528,11 +560,12 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
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 ? { from: last.uci.slice(0, 2), to: last.uci.slice(2, 4) } : null,
|
||||
lastMove: last ? lastMoveOf(last) : null,
|
||||
checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null,
|
||||
timeControl: s.options.timeControl,
|
||||
firstMoveSec: s.options.firstMoveSec,
|
||||
@@ -615,6 +648,13 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
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 [];
|
||||
@@ -629,10 +669,13 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
}
|
||||
if (turnOf(s) !== me) return [];
|
||||
const out: ChessAction[] = [];
|
||||
for (const m of legalMoves(load(s.fen))) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user