- 엔진: GameDefinition.adminOnlyOptions — 방은 항상 관리자 값 사용(잠금처럼 강제, 잠금 안내는 안 띄움) - 오목·체스·장기 threatHints, 바둑 atariWarning: 기본 false, 대기실 설정에서 뺌, 관리자 화면 게임 기본 규칙에서만 켬 - 체스·장기 hints는 off / moves만 남김 - 최고관리자 기본값에 1352267557213573160 추가(.env.example·문서 포함) - 상단 계정 메뉴: 관리자·최고관리자면 [🛠 관리자 메뉴] → /admin - 테스트: admin.test(관리자 전용 옵션 강제, 두 번째 최고관리자), admin.e2e(계정 메뉴, 위협 가이드 켜기 저장) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
689 lines
27 KiB
TypeScript
689 lines
27 KiB
TypeScript
/**
|
|
* 체스 (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<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
|
|
.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<typeof chessOptionsSchema>;
|
|
|
|
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<typeof chessActionSchema> | { 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<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[];
|
|
/** positionKey → occurrences, including the start position. */
|
|
repetition: Record<string, number>;
|
|
phase: 'playing' | 'finished';
|
|
clock: { remainingMs: Record<Color, number>; turnStartedAt: number; started: Record<Color, boolean> };
|
|
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: 1 | 2 } | null };
|
|
draw: { pendingBy: Color | null; lastOfferPly: Record<Color, number | null> };
|
|
/** 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<Color, PlayerId>;
|
|
/** 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<Color, number>;
|
|
turnStartedAt: number;
|
|
started: Record<Color, boolean>;
|
|
/** Pieces captured *by* each colour (lowercase letters). */
|
|
captured: Record<Color, string[]>;
|
|
/** White material minus black material. */
|
|
materialDiff: number;
|
|
undo: { allowed: boolean; remaining: Record<Color, number | null>; 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<ChessReason, string> = {
|
|
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<ChessState, ChessAction, ChessView, ChessOptions> = {
|
|
id: 'chess',
|
|
nameKo: '체스',
|
|
minPlayers: 2,
|
|
maxPlayers: 2,
|
|
stateVersion: 2,
|
|
defaultOptions: chessOptionsSchema.parse({}),
|
|
optionsSchema: chessOptionsSchema,
|
|
adminOnlyOptions: ['threatHints'],
|
|
actionSchema: chessActionSchema as z.ZodType<ChessAction>,
|
|
|
|
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<Color, string[]> = { 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<ChessReason, GameResult['reason']> = {
|
|
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;
|
|
},
|
|
};
|
|
|