M2: 체스·장기 추가
- 체스(chess.js 1.4.0, BSD-2): 캐슬링·앙파상·승진·체크메이트·스테일메이트·3회 반복·50/75수·기물 부족, 피셔 시계(상대가 메이트 불가면 시간패 대신 무승부), 무르기·무승부·기권, 직접 그린 SVG 기물, 클릭·드래그 이동, 승진 선택, 판 돌리기 (테스트 40개, perft 포함) - 장기: 궁성 대각·포다리·멱·장군 합법성·빅장(KJF/KJA/끔)·반복 수 처리·한수쉼·점수 판정(한 덤 1.5), 상차림 단계(순서/동시 비공개), 초읽기, 팔각 기물(한자+한글), 한 쪽이면 판 뒤집기 (테스트 46개) - 게임 공통 E2E에 체스·장기 추가(PC 방장 + 휴대폰 친구, 상차림→첫 수) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
232
packages/games/src/chess/rules.ts
Normal file
232
packages/games/src/chess/rules.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* chess.js adapter (docs/games/chess.md §8.0). Move generation/validation comes from chess.js (BSD-2-Clause,
|
||||
* Copyright (c) 2025 Jeff Hlywa); position keys, draw rules, insufficient material and the FIDE 6.9
|
||||
* time-out exception are decided here with our own rules.
|
||||
*/
|
||||
import { Chess, DEFAULT_POSITION, type Move, type PieceSymbol, type Square } from 'chess.js';
|
||||
|
||||
export type Color = 'white' | 'black';
|
||||
export type Promo = 'q' | 'r' | 'b' | 'n';
|
||||
export const START_FEN = DEFAULT_POSITION;
|
||||
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<PieceSymbol, number> = { 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). */
|
||||
export interface LegalMove {
|
||||
from: string;
|
||||
to: string;
|
||||
promotion?: true;
|
||||
castle?: 'king' | 'queen';
|
||||
}
|
||||
|
||||
export const isSquare = (s: string): s is Square => /^[a-h][1-8]$/.test(s);
|
||||
|
||||
export function load(fen: string): Chess {
|
||||
return new Chess(fen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repetition key: placement, side to move, castling rights, en-passant square. chess.js's fen() already 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: Chess): 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: Chess): 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');
|
||||
|
||||
function toLegal(m: Move): LegalMove {
|
||||
const out: LegalMove = { from: m.from, to: m.to };
|
||||
if (m.isPromotion()) out.promotion = true;
|
||||
if (m.isKingsideCastle()) out.castle = 'king';
|
||||
if (m.isQueensideCastle()) out.castle = 'queen';
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Legal moves for the side to move (optionally from one square), promotions collapsed. */
|
||||
export function legalMoves(c: Chess, square?: Square): LegalMove[] {
|
||||
const raw = square ? c.moves({ square, verbose: true }) : c.moves({ verbose: true });
|
||||
const out: LegalMove[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of raw) {
|
||||
const k = m.from + m.to;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(toLegal(m));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Count leaf nodes through the adapter (legalMoves → expand promotions → chess.move). */
|
||||
export function adapterPerft(fen: string, depth: number): number {
|
||||
const c = load(fen);
|
||||
const walk = (d: number): number => {
|
||||
const moves = legalMoves(c);
|
||||
let n = 0;
|
||||
for (const m of moves) {
|
||||
for (const promotion of m.promotion ? (['q', 'r', 'b', 'n'] as const) : [undefined]) {
|
||||
if (d === 1) {
|
||||
n++;
|
||||
continue;
|
||||
}
|
||||
c.move({ from: m.from, to: m.to, promotion });
|
||||
n += walk(d - 1);
|
||||
c.undo();
|
||||
}
|
||||
}
|
||||
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: Chess, from: Square, to: Square): 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}` as Square;
|
||||
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;
|
||||
}
|
||||
|
||||
interface PieceAt {
|
||||
square: Square;
|
||||
color: 'w' | 'b';
|
||||
type: PieceSymbol;
|
||||
}
|
||||
|
||||
export function pieces(c: Chess): PieceAt[] {
|
||||
const out: PieceAt[] = [];
|
||||
for (const row of c.board()) for (const p of row) if (p) out.push(p);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Chess): 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) => c.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: Chess, 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) => c.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: Chess): 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: Chess, side: 'w' | 'b'): Square | null {
|
||||
return c.findPiece({ type: 'k', color: side })[0] ?? null;
|
||||
}
|
||||
|
||||
/** White material minus black material (Q9 R5 B3 N3 P1). */
|
||||
export function materialDiff(c: Chess): 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<string, string>, result: string): string {
|
||||
const head: Record<string, string> = { 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(' ')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user