Merge branch 'm9/chess960' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-06 13:35:23 +09:00
13 changed files with 1390 additions and 107 deletions

View File

@@ -277,7 +277,8 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
onPointerDown={(e) => { onPointerDown={(e) => {
setKbd(false); setKbd(false);
const sq = cellAt(e); const sq = cellAt(e);
if (!sq || !ownMovable(sq) || pending) return; // Chess960: with the king picked, my own rook is a castling target, not a new selection.
if (!sq || !ownMovable(sq) || pending || (selected && targets.some((m) => m.to === sq))) return;
drag.current = { from: sq, wasSelected: selected === sq, sx: e.clientX, sy: e.clientY, moved: false }; drag.current = { from: sq, wasSelected: selected === sq, sx: e.clientX, sy: e.clientY, moved: false };
setSelected(sq); setSelected(sq);
svgRef.current?.setPointerCapture(e.pointerId); svgRef.current?.setPointerCapture(e.pointerId);
@@ -361,6 +362,18 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
{showDots && {showDots &&
targets.map((m) => { targets.map((m) => {
const c = cell(m.to); const c = cell(m.to);
if (m.castle) {
// Castling target (king's square or, in 960, the own rook): gold frame + label, so it is never mistaken for a capture.
return (
<g key={`m${m.to}`} style={{ pointerEvents: 'none' }}>
<rect x={c.x + 0.06} y={c.y + 0.06} width={0.88} height={0.88} rx={0.12} fill="rgba(234,179,8,0.25)" stroke="#a16207" strokeWidth={0.07} />
<rect x={c.x + 0.1} y={c.y + 0.68} width={0.8} height={0.24} rx={0.08} fill="#a16207" />
<text x={c.x + 0.5} y={c.y + 0.8} textAnchor="middle" dominantBaseline="central" fontSize={0.16} fontWeight={800} fill="#fff">
캐슬링
</text>
</g>
);
}
const capture = !!pieceAt(v, m.to) || (pieceAt(v, m.from)?.toLowerCase() === 'p' && m.from[0] !== m.to[0]); const capture = !!pieceAt(v, m.to) || (pieceAt(v, m.from)?.toLowerCase() === 'p' && m.from[0] !== m.to[0]);
return capture ? ( return capture ? (
<circle key={`m${m.to}`} cx={c.x + 0.5} cy={c.y + 0.5} r={0.45} fill="none" stroke="rgba(20,40,10,0.45)" strokeWidth={0.08} style={{ pointerEvents: 'none' }} /> <circle key={`m${m.to}`} cx={c.x + 0.5} cy={c.y + 0.5} r={0.45} fill="none" stroke="rgba(20,40,10,0.45)" strokeWidth={0.08} style={{ pointerEvents: 'none' }} />
@@ -386,6 +399,15 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
<p className="sr-only" aria-live="polite"> <p className="sr-only" aria-live="polite">
{announce} {announce}
</p> </p>
{myTurn && selected && targets.some((m) => m.castle) && (showDots || v.chess960Index !== null) && (
<p className="small" style={{ margin: 0, textAlign: 'center' }}>
{v.chess960Index === null
? '캐슬링하려면 노란 테두리 칸을 누르세요.'
: showDots
? '캐슬링하려면 노란 테두리 칸(내 룩, 또는 킹이 갈 g·c열 칸)을 누르세요.'
: '캐슬링하려면 내 룩이나 킹이 갈 g·c열 칸을 누르세요.'}
</p>
)}
{!v.myColor && ( {!v.myColor && (
<div className="row" style={{ justifyContent: 'center' }}> <div className="row" style={{ justifyContent: 'center' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setFlip(!flip)}> <button className="btn btn-secondary btn-sm" onClick={() => setFlip(!flip)}>
@@ -450,8 +472,15 @@ function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<
const bottom: Color = mine ?? 'white'; const bottom: Color = mine ?? 'white';
const beforeFirst = !!mine && !v.started[mine]; const beforeFirst = !!mine && !v.started[mine];
const canClaim = v.drawMode === 'fide' && !!v.claimable && !!mine && v.turn === mine; const canClaim = v.drawMode === 'fide' && !!v.claimable && !!mine && v.turn === mine;
// Chess960: castling also as buttons (the king-onto-rook tap is easy to miss).
const castles = v.chess960Index !== null ? (['king', 'queen'] as const).filter((side) => v.legalMoves?.some((m) => m.castle === side)) : [];
return ( return (
<> <>
{v.chess960Index !== null && (
<div className="card small" style={{ padding: '8px 12px' }}>
<strong>체스960</strong> · 시작 배치 {v.chess960Index}번
</div>
)}
<PlayerBar view={v} color={top} players={players} deadline={deadline} me={me} /> <PlayerBar view={v} color={top} players={players} deadline={deadline} me={me} />
<PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} /> <PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} />
{v.phase === 'playing' && mine && ( {v.phase === 'playing' && mine && (
@@ -476,6 +505,15 @@ function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<
</button> </button>
</div> </div>
)} )}
{castles.length > 0 && (
<div className="row" style={{ gap: 8 }}>
{castles.map((side) => (
<button key={side} className="btn btn-secondary btn-sm" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'castle', side })}>
{side === 'king' ? '캐슬링: 킹 쪽 (O-O)' : '캐슬링: 퀸 쪽 (O-O-O)'}
</button>
))}
</div>
)}
{canClaim && ( {canClaim && (
<button className="btn btn-primary" onClick={() => setConfirm('claim')}> <button className="btn btn-primary" onClick={() => setConfirm('claim')}>
⚖ 무승부 주장 ({v.claimable === 'threefold' ? '3회 반복' : '50수 규칙'}) ⚖ 무승부 주장 ({v.claimable === 'threefold' ? '3회 반복' : '50수 규칙'})
@@ -691,6 +729,11 @@ function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
const num = (val: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(val) || min)); const num = (val: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(val) || min));
return ( return (
<div className="stack" style={{ gap: 16 }}> <div className="stack" style={{ gap: 16 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>규칙</strong>
<Seg value={o.variant ?? 'standard'} options={[['standard', '일반 체스'], ['chess960', '체스960 (첫 줄 무작위)']]} onChange={(variant) => set({ variant })} />
{o.variant === 'chess960' && <span className="muted small">뒷줄 기물 배치가 960가지 중 하나로 무작위로 정해져요. 외운 수보다 실력으로 겨뤄요.</span>}
</div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>시간 (기본 분 + 한 수마다 추가 초)</strong> <strong>시간 (기본 분 + 한 수마다 추가 초)</strong>
<Seg <Seg
@@ -788,7 +831,7 @@ function timeLabel(tc: ChessOptions['timeControl']): string {
function optionsSummary(o: ChessOptions): string { function optionsSummary(o: ChessOptions): string {
const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`; const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`;
return ['표준', timeLabel(o.timeControl), undo].join(' · '); return [o.variant === 'chess960' ? '체스960' : '표준', timeLabel(o.timeControl), undo].join(' · ');
} }
// ---------------------------------------------------------------- rules // ---------------------------------------------------------------- rules
@@ -804,8 +847,15 @@ const PIECE_RULES: [PieceType, string][] = [
function Rules({ options }: { options?: ChessOptions }) { function Rules({ options }: { options?: ChessOptions }) {
const fide = options?.drawMode === 'fide'; const fide = options?.drawMode === 'fide';
const c960 = options?.variant === 'chess960';
return ( return (
<div className="stack"> <div className="stack">
{c960 && (
<p>
<strong>체스960</strong>: 뒷줄 기물(킹·퀸·룩·비숍·나이트) 배치가 960가지 중 하나로 무작위로 정해져요. 흑은 백과 똑같이 마주 보게 놓여요. 두 비숍은 서로 다른 색 칸에, 킹은
두 룩 사이에 있어요. 그 밖의 규칙은 일반 체스와 같아요.
</p>
)}
<p> <p>
백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 <strong>킹</strong>을 공격해서(체크) 어디로도 피할 수 없게 만들면(<strong>체크메이트</strong>) 이겨요. 백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 <strong>킹</strong>을 공격해서(체크) 어디로도 피할 수 없게 만들면(<strong>체크메이트</strong>) 이겨요.
내 킹이 공격받게 되는 수는 둘 수 없어요. 내 킹이 공격받게 되는 수는 둘 수 없어요.
@@ -820,10 +870,18 @@ function Rules({ options }: { options?: ChessOptions }) {
</div> </div>
))} ))}
</div> </div>
{c960 ? (
<p>
<strong>캐슬링(체스960)</strong>: 끝나는 자리는 일반 체스와 같아요. 킹 쪽(O-O)은 킹이 g열, 룩이 f열로, 퀸 쪽(O-O-O)은 킹이 c열, 룩이 d열로 가요. 킹과 그 룩이 한 번도
움직이지 않았고, 둘이 지나가는 칸이 (둘 자신 말고는) 모두 비어 있어야 해요. 체크 중이거나 킹이 지나가는 칸·도착 칸이 공격받으면 안 돼요. 킹을 누른 뒤 노란 테두리 칸(내 룩이나
g·c열 칸)을 누르거나, [캐슬링] 버튼을 눌러요.
</p>
) : (
<p> <p>
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이 <strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요. 공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.
</p> </p>
)}
<p> <p>
<strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요. <strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요.
</p> </p>

View File

@@ -55,9 +55,11 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@bg/engine": "workspace:*", "@bg/engine": "workspace:*",
"chess.js": "1.4.0",
"zod": "4.6.5", "zod": "4.6.5",
}, },
"devDependencies": {
"chess.js": "1.4.0",
},
}, },
"packages/shared": { "packages/shared": {
"name": "@bg/shared", "name": "@bg/shared",

View File

@@ -82,5 +82,5 @@ Bun 서버 (단일 프로세스)
## 6. 의존성 원칙 ## 6. 의존성 원칙
- 런타임 의존성은 최소화. 추가 전에 "직접 50줄로 되는가?"를 먼저 따진다. - 런타임 의존성은 최소화. 추가 전에 "직접 50줄로 되는가?"를 먼저 따진다.
- 허용 목록(초기): hono, zod, react, react-dom, react-router, zustand, qrcode(작은 QR 생성기). 체스는 `chess.js` 사용 여부를 `games/chess.md`에서 결정. - 허용 목록(초기): hono, zod, react, react-dom, react-router, zustand, qrcode(작은 QR 생성기). 체스는 자체 수 생성기를 쓰고 `chess.js`는 테스트 비교용 devDependency(`games/chess.md` §8.0).
- 버전은 lockfile로 고정하고, 업데이트는 테스트 통과 후에만. - 버전은 lockfile로 고정하고, 업데이트는 테스트 통과 후에만.

View File

@@ -26,7 +26,7 @@
|---|---|---| |---|---|---|
| 오목 | 한국식(흑백 모두 쌍삼 금지, 6목은 승리 아님) | 렌주룰, 자유룰(5목 이상 승 / 정확히 5목) | | 오목 | 한국식(흑백 모두 쌍삼 금지, 6목은 승리 아님) | 렌주룰, 자유룰(5목 이상 승 / 정확히 5목) |
| 바둑 | 위치 기준 슈퍼코, 덤 6.5(계가) | 삼패 무승부, 중국식 계가(덤 7.5) | | 바둑 | 위치 기준 슈퍼코, 덤 6.5(계가) | 삼패 무승부, 중국식 계가(덤 7.5) |
| 체스 | 3회 반복·50수는 자동 무승부, 라이브러리 chess.js | 엄격한 FIDE 신청 방식. 체스960은 자체 구현으로 후순위 | | 체스 | 3회 반복·50수는 자동 무승부, 수 생성은 자체 구현(perft·chess.js 비교로 검증, chess.js는 테스트 전용) | 엄격한 FIDE 신청 방식, 체스960(첫 줄 무작위) |
| 장기 | 점수 판정·빅장 사용, 같은 국면 4번째 금지 | 동시 비공개 상차림 | | 장기 | 점수 판정·빅장 사용, 같은 국면 4번째 금지 | 동시 비공개 상차림 |
| 포커 | 7포커 베팅은 하프까지, 백스트레이트는 마운틴 다음, 하이로우의 로우는 한게임 방식 | 풀 베팅, 넷마블식 로우, 8탑 제한 | | 포커 | 7포커 베팅은 하프까지, 백스트레이트는 마운틴 다음, 하이로우의 로우는 한게임 방식 | 풀 베팅, 넷마블식 로우, 8탑 제한 |
| 섯다 | 땡잡이는 3광+7열끗, 동점은 재경기, 다음 선은 승자 | 나눠 갖기, 시계 방향 선 | | 섯다 | 땡잡이는 3광+7열끗, 동점은 재경기, 다음 선은 승자 | 나눠 갖기, 시계 방향 선 |

View File

@@ -51,13 +51,20 @@
- 앙파상(en passant): 상대 폰이 직전 수에 처음 위치에서 2칸 전진해 내 폰 바로 옆에 섰을 때, 바로 다음 수에 한해 그 폰이 1칸만 전진한 것처럼 대각선으로 잡을 수 있다. - 앙파상(en passant): 상대 폰이 직전 수에 처음 위치에서 2칸 전진해 내 폰 바로 옆에 섰을 때, 바로 다음 수에 한해 그 폰이 1칸만 전진한 것처럼 대각선으로 잡을 수 있다.
- 승진(promotion): 폰이 마지막 행에 도달하면 같은 색 퀸·룩·비숍·나이트 중 하나로 반드시 바꾼다(기존 기물 수와 무관). - 승진(promotion): 폰이 마지막 행에 도달하면 같은 색 퀸·룩·비숍·나이트 중 하나로 반드시 바꾼다(기존 기물 수와 무관).
- 캐슬링(standard): 킹과 룩이 모두 한 번도 움직이지 않았고, 사이 칸이 모두 비었으며, 킹이 현재 체크 상태가 아니고, 킹이 지나가는 칸과 도착 칸이 공격받지 않을 때. 킹이 룩 쪽으로 2칸 이동하고 룩이 킹이 넘어간 칸으로 이동. 킹사이드: Ke1→g1, Rh1→f1. 퀸사이드: Ke1→c1, Ra1→d1(b1은 비어야 하지만 공격받아도 무관). - 캐슬링(standard): 킹과 룩이 모두 한 번도 움직이지 않았고, 사이 칸이 모두 비었으며, 킹이 현재 체크 상태가 아니고, 킹이 지나가는 칸과 도착 칸이 공격받지 않을 때. 킹이 룩 쪽으로 2칸 이동하고 룩이 킹이 넘어간 칸으로 이동. 킹사이드: Ke1→g1, Rh1→f1. 퀸사이드: Ke1→c1, Ra1→d1(b1은 비어야 하지만 공격받아도 무관).
- 캐슬링(chess960): 결과 위치는 표준과 동일(킹사이드: 킹 g, 룩 f / 퀸사이드: 킹 c, 룩 d). 조건: 해당 킹·룩 미이동, 킹의 현재 칸~도착 칸 사이 모든 칸(양 끝 포함)과 룩의 현재 칸~도착 칸 사이 모든 칸이 그 킹과 룩을 제외하고 비어 있음, 킹이 현재 체크 아님, 킹이 지나가는 칸·도착 칸이 공격받지 않음. 킹이나 룩이 이미 도착 칸에 있을 수 있다(이동 0칸). - 캐슬링(chess960): 결과 위치는 표준과 동일(킹사이드: 킹 g, 룩 f / 퀸사이드: 킹 c, 룩 d). 조건: 해당 킹·룩 미이동, 킹의 현재 칸~도착 칸 사이 모든 칸(양 끝 포함)과 룩의 현재 칸~도착 칸 사이 모든 칸이 그 킹과 룩을 제외하고 비어 있음, 킹이 현재 체크 아님, 킹이 지나가는 칸·도착 칸이 공격받지 않음. 킹이나 룩이 이미 도착 칸에 있을 수 있다(이동 0칸). 캐슬링 룩이 킹 도착 칸을 가리고 있던 경우(룩이 비키면 킹이 공격받음)도 불법.
- 체크: 킹이 공격받는 상태. 자기 킹을 공격받게 두는 수(핀 된 기물 이동, 공격받는 칸으로 킹 이동 포함)는 불법. - 체크: 킹이 공격받는 상태. 자기 킹을 공격받게 두는 수(핀 된 기물 이동, 공격받는 칸으로 킹 이동 포함)는 불법.
### 5.3 턴 구조 ### 5.3 턴 구조
- 차례인 플레이어는 합법 수 하나를 둔다. 패스 없음. - 차례인 플레이어는 합법 수 하나를 둔다. 패스 없음.
- 합법 수가 없으면: 체크 상태면 체크메이트(패배), 아니면 스테일메이트(무승부). - 합법 수가 없으면: 체크 상태면 체크메이트(패배), 아니면 스테일메이트(무승부).
### 5.4 Chess960 입력과 표기
- 입력: (1) 킹 → 자기 캐슬링 룩 칸(960 방식, UCI_Chess960과 같음), (2) 킹 → 도착 칸(g/c열). 단 (2)는 그 칸이 보통의 킹 1칸 이동이기도 하면 보통 이동으로 본다(예: 킹 b1에서 c1은 Kc1). 그때는 (1)이나 `castle` 액션(버튼)으로 캐슬링한다. 킹이 이미 도착 칸에 있으면(킹 g1, 룩 h1) (1)·`castle`만 가능.
- standard에서는 킹 → 룩 칸은 캐슬링이 아니다(거부: "그 기물은 그렇게 움직일 수 없어요").
- 기보: SAN은 `O-O` / `O-O-O`. 저장 UCI는 킹 칸 + 룩 칸(예 `b1h1`). PGN 태그 `Variant "Chess960"`, `SetUp "1"`, `FEN`(Shredder-FEN).
- 시작 번호 n은 `setup`에서 색 정하기 다음에 시드 RNG로 뽑는다(그래서 standard 대국의 RNG 순서는 예전과 같다). n = 518은 표준 배치지만 960 규칙(룩 칸 캐슬링 입력, Shredder-FEN)으로 진행한다.
- 저장 형식: `stateVersion` 2. v1(옵션에 `variant` 없음, `chess960Index` 없음)은 `migrate`가 `variant: 'standard'`, `chess960Index: null`로 채운다. 끝난 대국은 마이그레이션되지 않으므로 코드는 `chess960Index`가 없으면 standard로 읽는다.
## 6. 승패와 점수 계산 ## 6. 승패와 점수 계산
- 승리: 체크메이트, 상대 기권, 상대 시간패(단 아래 예외), 상대 연결 끊김 시간 초과. - 승리: 체크메이트, 상대 기권, 상대 시간패(단 아래 예외), 상대 연결 끊김 시간 초과.
- 무승부: - 무승부:
@@ -84,9 +91,10 @@
| chessops (TypeScript, lichess 개발자 제작) | Chess960 포함 각종 변형 지원, lichess에서 실사용 | GPL-3.0 이상. 클라이언트 번들에 포함하면 클라이언트 코드 공개 의무 발생 가능. 서버 전용으로 쓰면 배포가 아니므로 일반적으로 문제 없으나 법적 판단 필요 | | chessops (TypeScript, lichess 개발자 제작) | Chess960 포함 각종 변형 지원, lichess에서 실사용 | GPL-3.0 이상. 클라이언트 번들에 포함하면 클라이언트 코드 공개 의무 발생 가능. 서버 전용으로 쓰면 배포가 아니므로 일반적으로 문제 없으나 법적 판단 필요 |
| 자체 구현 | 의존성·라이선스 없음, 960 포함 완전 통제 | 수 생성 버그 위험. perft 테스트로 검증 필수, 개발 시간 증가 | | 자체 구현 | 의존성·라이선스 없음, 960 포함 완전 통제 | 수 생성 버그 위험. perft 테스트로 검증 필수, 개발 시간 증가 |
- 권장: v1(standard)은 **chess.js를 서버에서만** 사용해 판정하고, 클라이언트는 서버 view의 `legalMoves`로 하이라이트(클라이언트 라이브러리 불필요 → 번들 경량화, 판정 이중화 불일치 방지). 대신 아래를 어댑터에서 우리 규칙으로 직접 판정한다: 반복 국면 키(앙파상 정규화), 50/75수, 기물 부족 목록, 시간패 예외 `cannotMate`. - 결정(M9): **자체 구현**(`packages/games/src/chess/movegen.ts`)으로 standard와 Chess960을 한 생성기로 처리한다. M2~M8은 chess.js를 서버에서만 썼고, 지금 chess.js는 테스트 전용 devDependency(비교 검증용)다. 클라이언트는 그대로 서버 view의 `legalMoves`로만 하이라이트한다(클라이언트에 규칙 코드 없음). 반복 국면 키(앙파상 정규화), 50/75수, 기물 부족 목록, 시간패 예외 `cannotMate`는 어댑터(`rules.ts`)에서 우리 규칙으로 판정한다.
- Chess960(후속 마일스톤): (a) chessops를 서버 전용으로 도입(GPL 적용 범위를 사용자/법률 검토 후) 또는 (b) 자체 구현 + perft 검증. 사용자가 오픈소스 공개를 원치 않고 법률 검토도 부담이면 (b). 이 문서의 상태·액션 정의는 두 방식 모두에 맞도록 FEN(960은 Shredder-FEN: 캐슬링 권리를 룩의 열 문자로 표기, 예 `HAha`)을 기준으로 한다. - 생성기 구조: 0x88 판, make/unmake. 캐슬링 권리를 "색·방향별 룩 칸"으로 저장해 standard와 960이 같은 코드를 탄다. FEN 입력은 `KQkq`(X-FEN: 가장 바깥 룩)와 Shredder-FEN(`HAha`)을 모두 받고, 출력은 standard는 `KQkq`, 960은 Shredder-FEN.
- 어느 쪽이든 perft로 검증: 초기 국면 깊이 1~5 = 20 / 400 / 8,902 / 197,281 / 4,865,609, "Kiwipete" 국면(`r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1`) 깊이 1~4 = 48 / 2,039 / 97,862 / 4,085,603. 960은 공개된 chess960 perft 세트 사용. - 예전 저장 대국 호환: 앙파상 칸 저장·출력, SAN(모호성 표기, `+`/`#`)을 chess.js 1.4와 똑같이 맞췄다. 그래서 chess.js 시절에 저장된 `fen`·`fenAfter`·반복 키가 글자 하나까지 같다(비교 테스트로 확인).
- 검증(`chess.perft.test.ts`): perft — 초기 국면 깊이 1~4 = 20 / 400 / 8,902 / 197,281(깊은 실행에서 5~6 = 4,865,609 / 119,060,324), Kiwipete 1~3 = 48 / 2,039 / 97,862(깊은 실행에서 4 = 4,085,603), chessprogramming 3~6번 국면, 핀·앙파상 함정 국면, 960은 공개 Chess960 perft 세트(fischer.epd) 12개 국면과 X-FEN 5개 국면 깊이 3~4(깊은 실행 4~5). 깊은 실행은 `PERFT_DEEP=1 bun test chess.perft`(약 1~2분). 또 무작위 대국에서 매 수 chess.js와 합법 수 집합·SAN·FEN·체크/메이트/스테일메이트·기물 부족·50수·3회 반복 판정을 비교한다.
### 8.1 상태(State) ### 8.1 상태(State)
```ts ```ts
@@ -173,7 +181,7 @@ interface ChessResult {
- 모바일 세로: 상단 상대(잡은 기물, 시계), 가운데 정사각 판(폰 360px에서 칸 45px — 탭 정확도를 위해 칸 전체를 터치 영역으로), 하단 내 시계와 버튼 줄(무르기, 무승부, 기권, 기보, 규칙 보기). 판은 내 색이 아래로 오게 자동 회전. - 모바일 세로: 상단 상대(잡은 기물, 시계), 가운데 정사각 판(폰 360px에서 칸 45px — 탭 정확도를 위해 칸 전체를 터치 영역으로), 하단 내 시계와 버튼 줄(무르기, 무승부, 기권, 기보, 규칙 보기). 판은 내 색이 아래로 오게 자동 회전.
- 조작: 기물 탭 → 이동 가능 칸 점 표시(잡기 칸은 테두리 링) → 도착 칸 탭. 드래그도 지원. 다른 내 기물 탭 시 선택 변경. - 조작: 기물 탭 → 이동 가능 칸 점 표시(잡기 칸은 테두리 링) → 도착 칸 탭. 드래그도 지원. 다른 내 기물 탭 시 선택 변경.
- 승진: 도착 시 큰 4개 버튼(퀸, 룩, 비숍, 나이트) 모달. - 승진: 도착 시 큰 4개 버튼(퀸, 룩, 비숍, 나이트) 모달.
- 캐슬링: 킹 선택 시 캐슬링 도착 칸(standard: g1/c1, 960: 룩 칸도 탭 가능) 표시. - 캐슬링: 킹 선택 시 캐슬링 칸을 노란 테두리 + "캐슬링" 글씨로 표시(잡기 링과 헷갈리지 않게). standard: g1/c1. 960: 내 룩 칸, 그리고 킹이 갈 g/c열 칸(그 칸이 보통 킹 이동과 겹치지 않을 때)을 모두 표시하고, 판 아래에 "노란 테두리 칸을 누르세요" 안내. 960은 옆 패널에 [캐슬링: 킹 쪽 (O-O)] / [캐슬링: 퀸 쪽 (O-O-O)] 버튼도 둔다. 960 대국은 옆 패널에 "체스960 · 시작 배치 n번"을 보여 준다.
- 체크 시 킹 칸 빨간 배경 + "체크!" 말풍선, 체크메이트·스테일메이트 결과 모달에 이유 설명(초보용: "킹이 피할 곳이 없어요"). - 체크 시 킹 칸 빨간 배경 + "체크!" 말풍선, 체크메이트·스테일메이트 결과 모달에 이유 설명(초보용: "킹이 피할 곳이 없어요").
- 마지막 수 출발·도착 칸 강조, 이동 애니메이션 150ms 내외(저사양 고려). - 마지막 수 출발·도착 칸 강조, 이동 애니메이션 150ms 내외(저사양 고려).
- 규칙 보기: 기물별 움직임 애니메이션 카드, 특수 규칙(앙파상·캐슬링·승진) 그림 설명. - 규칙 보기: 기물별 움직임 애니메이션 카드, 특수 규칙(앙파상·캐슬링·승진) 그림 설명.
@@ -192,7 +200,7 @@ interface ChessResult {
- [ ] 시간패 예외: 시간 초과한 쪽 상대가 킹만 → 무승부, 상대 킹+나이트이고 시간 초과한 쪽이 킹+퀸 → 상대 승. - [ ] 시간패 예외: 시간 초과한 쪽 상대가 킹만 → 무승부, 상대 킹+나이트이고 시간 초과한 쪽이 킹+퀸 → 상대 승.
- [ ] 첫 수 제한 초과 → `aborted`, 랭킹 비어 있음. - [ ] 첫 수 제한 초과 → `aborted`, 랭킹 비어 있음.
- [ ] 피셔 증가분: 수를 둔 뒤 가산, 0 이하로 도착한 수는 거부. - [ ] 피셔 증가분: 수를 둔 뒤 가산, 0 이하로 도착한 수는 거부.
- [ ] Chess960: n=518이 표준 배치, 무작위 1000회 생성 시 비숍 반대 색·킹이 두 룩 사이 조건 항상 만족, 킹이 g열에 이미 있는 배치에서 킹사이드 캐슬링(킹 0칸 이동). - [ ] Chess960: n=518이 표준 배치, 960개 번호 모두 비숍 반대 색·킹이 두 룩 사이 조건 만족·서로 다름, 킹이 g열에 이미 있는 배치에서 킹사이드 캐슬링(킹 0칸 이동), 룩 경로·킹 경로·도착 칸 막힘/공격, 룩이 도착 칸을 가리던 경우, 권리 상실, 무르기 후 권리 복원, 무작위 960 대국(`chess.test.ts`), perft(`chess.perft.test.ts`).
- [ ] 무르기: 1/2 플라이 복원 후 `repetition` 카운트 정확, 거절 시 무변화. - [ ] 무르기: 1/2 플라이 복원 후 `repetition` 카운트 정확, 거절 시 무변화.
- [ ] 연결 끊김 유예 후 시간패 처리. - [ ] 연결 끊김 유예 후 시간패 처리.
- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves`/`threatenedSquares` 없음, 모든 view에 `rng` 없음. - [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves`/`threatenedSquares` 없음, 모든 view에 `rng` 없음.

100
e2e/chess960-plan.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* Chess960 E2E helper: for a start number, a short legal line in which white clears its back rank and castles
* (whichever side is ready first) while black only shuffles a knight between its start square and the 6th rank.
* Black never attacks white's back rank that way, and white only develops to ranks 2–3 without capturing, so the
* line exists for every one of the 960 positions (checked for all of them when this was written).
*/
import { Position, chess960Fen, type Move } from '../packages/games/src/chess/movegen';
const PAWN = 1;
const KNIGHT = 2;
export interface Ply {
/** UCI as the server records it (Chess960 castling: king square + rook square). */
uci: string;
san: string;
castle: boolean;
}
/** Squares (0x88) that must be empty, except for the king and rook themselves, for white castling on one side. */
function needEmpty(p: Position, i: 0 | 1): number[] {
const k = p.king[0]!;
const r = p.castle[i]!;
if (r < 0) return [];
const out = new Set<number>();
for (const [a, z] of [
[k, i === 0 ? 6 : 2],
[r, i === 0 ? 5 : 3],
] as const) {
for (let s = Math.min(a, z); s <= Math.max(a, z); s++) if (s !== k && s !== r) out.add(s);
}
return [...out];
}
function blockers(p: Position, i: 0 | 1): number {
return needEmpty(p, i).filter((s) => p.b[s]).length;
}
/** Legal moves of the side *not* to move (null move), for "can this blocker leave next turn?". */
function nextTurnMoves(p: Position): Move[] {
const ep = p.ep;
p.ep = -1;
p.side ^= 1;
const out = p.moves();
p.side ^= 1;
p.ep = ep;
return out;
}
export function plan960(index: number, maxPlies = 40): Ply[] {
const p = Position.fromFen(chess960Fen(index));
const plies: Ply[] = [];
const push = (m: Move, legal: Move[]) => {
const done = p.play(m, legal);
plies.push({ uci: done.uci, san: done.san, castle: !!done.castle });
};
let knightHome = -1;
while (plies.length < maxPlies) {
const legal = p.moves();
if (p.side === 1) {
// Black: one knight out to the 6th rank and back.
const out = knightHome < 0;
const m = out
? legal.find((x) => x.piece === KNIGHT && x.from >> 4 === 7 && x.to >> 4 === 5 && !x.captured)!
: legal.find((x) => x.piece === KNIGHT && x.to === knightHome)!;
knightHome = out ? m.from : -1;
push(m, legal);
continue;
}
const castle = legal.find((x) => x.rook >= 0);
if (castle) {
push(castle, legal);
return plies;
}
// White: the quiet move that best frees either castling side (fewest blockers left, then most blockers able to
// leave). Only pawns step forward (to rank 3) and blockers step out of the way (to ranks 1–3, never into either
// side's squares), so nothing can repeat and the line always makes progress.
const zones = ([0, 1] as const).filter((i) => p.castle[i]! >= 0).map((i) => ({ i, zone: new Set(needEmpty(p, i)) }));
const anyZone = (sq: number) => zones.some((z) => z.zone.has(sq));
let best: Move | null = null;
let bestScore = Infinity;
for (const m of legal) {
if (m.captured || m.to >> 4 > 2 || anyZone(m.to) || !(anyZone(m.from) || m.piece === PAWN)) continue;
p.make(m);
const next = nextTurnMoves(p);
for (const { i, zone } of zones) {
if (p.castle[i]! < 0) continue;
const leavers = new Set(next.filter((x) => zone.has(x.from) && !anyZone(x.to) && x.to >> 4 <= 2 && !x.captured).map((x) => x.from)).size;
const score = blockers(p, i) * 1000 - leavers * 10 + (m.to >> 4) + m.to / 1000;
if (score < bestScore) {
bestScore = score;
best = m;
}
}
p.unmake();
}
if (!best) throw new Error(`960 #${index}: no developing move after ${plies.map((x) => x.san).join(' ')}`);
push(best, legal);
}
throw new Error(`960 #${index}: no castling within ${maxPlies} plies`);
}

View File

@@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { loadConfig } from '../apps/server/src/config'; import { loadConfig } from '../apps/server/src/config';
import { startServer } from '../apps/server/src/server'; import { startServer } from '../apps/server/src/server';
import { plan960 } from './chess960-plan';
const PORT = Number(process.env.E2E_PORT ?? 4519); const PORT = Number(process.env.E2E_PORT ?? 4519);
const ORIGIN = `http://localhost:${PORT}`; const ORIGIN = `http://localhost:${PORT}`;
@@ -17,6 +18,8 @@ mkdirSync(SHOTS, { recursive: true });
/** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */ /** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */
interface Plan { interface Plan {
/** Game id when the plan key is a variant scenario (e.g. `chess960` → `chess`). */
game?: string;
/** Total players including the host (default 2). Extra players join on phones. */ /** Total players including the host (default 2). Extra players join on phones. */
players?: number; players?: number;
/** Host changes room options before starting (inline settings form on PC). */ /** Host changes room options before starting (inline settings form on PC). */
@@ -1049,6 +1052,55 @@ const PLANS: Record<string, Plan> = {
// cursor starts on the e-file pawn of whoever is at the bottom: white e2-e4, black d7-d5 (board flipped) // cursor starts on the e-file pawn of whoever is at the bottom: white e2-e4, black d7-d5 (board flipped)
moves: [['Enter', 'ArrowUp', 'ArrowUp', 'Enter'], ['Enter', 'ArrowUp', 'ArrowUp', 'Enter']], moves: [['Enter', 'ArrowUp', 'ArrowUp', 'Enter'], ['Enter', 'ArrowUp', 'ArrowUp', 'Enter']],
}, },
chess960: {
// Chess960 via the lobby option, host = white. Reads the start number from the side panel, then plays a line from
// e2e/chess960-plan.ts by clicking squares (black shuffles a knight, white clears its back rank) up to white
// castling, entered by tapping the king and then its own rook (the 960 way). Checks the castling hints on the way.
game: 'chess',
lobby: async (host) => {
await host.getByRole('button', { name: '체스960 (첫 줄 무작위)' }).click();
await host.getByRole('button', { name: '방장이 백', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/체스960 · /).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const tap = async (p: Page, sq: string, bottom: 'white' | 'black') => {
const board = p.locator('svg[role="grid"]').first();
await board.scrollIntoViewIfNeeded();
const box = (await board.boundingBox())!;
const f = sq.charCodeAt(0) - 97;
const r = Number(sq[1]) - 1;
const [x, y] = bottom === 'white' ? [f, 7 - r] : [7 - f, r];
await p.mouse.click(box.x + ((x + 0.52) / 8.04) * box.width, box.y + ((y + 0.52) / 8.04) * box.height);
};
const myTurn = (p: Page) => p.waitForFunction(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 10_000 });
const label = (await host.getByText(/시작 배치 \d+번/).first().innerText()).match(/시작 배치 (\d+)번/)!;
const index = Number(label[1]);
await guest.getByText(`시작 배치 ${index}번`).first().waitFor();
const plies = plan960(index);
for (const [n, ply] of plies.entries()) {
const white = n % 2 === 0;
const p = white ? host : guest;
const bottom = white ? 'white' : 'black';
await myTurn(p);
const [from, to] = [ply.uci.slice(0, 2), ply.uci.slice(2, 4)];
if (ply.castle) {
await p.getByRole('button', { name: /^캐슬링: / }).first().waitFor();
await tap(p, from, bottom);
await p.getByText(/노란 테두리 칸/).waitFor();
await p.locator('svg[role="grid"] text', { hasText: '캐슬링' }).first().waitFor();
await p.screenshot({ path: join(SHOTS, 'chess960-castle-hint-pc.png') });
} else await tap(p, from, bottom);
await tap(p, to, bottom);
await p.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
}
const last = plies[plies.length - 1]!;
if (!last.castle) throw new Error('plan did not end with castling');
for (const p of pages) await p.getByText(last.san, { exact: true }).first().waitFor();
console.log(` chess960: #${index}, ${plies.map((x) => x.san).join(' ')}`);
},
},
baduk: { baduk: {
// cursor starts at 천원: black plays there, white plays one point to the left // cursor starts at 천원: black plays there, white plays one point to the left
moves: [['Enter'], ['ArrowLeft', 'Enter']], moves: [['Enter'], ['ArrowLeft', 'Enter']],
@@ -2015,7 +2067,7 @@ for (const id of games) {
const guest = guests[0]!; const guest = guests[0]!;
for (const p of [host, ...guests]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`)); for (const p of [host, ...guests]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`));
try { try {
await host.goto(`${ORIGIN}/g/${id}`); await host.goto(`${ORIGIN}/g/${plan.game ?? id}`);
await host.getByRole('button', { name: /친구랑 하기/ }).click(); await host.getByRole('button', { name: /친구랑 하기/ }).click();
await host.getByLabel('내 이름(닉네임)').fill('방장'); await host.getByLabel('내 이름(닉네임)').fill('방장');
await host.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await host.getByRole('button', { name: '이 이름으로 시작하기' }).click();

View File

@@ -11,7 +11,9 @@
}, },
"dependencies": { "dependencies": {
"@bg/engine": "workspace:*", "@bg/engine": "workspace:*",
"chess.js": "1.4.0",
"zod": "4.6.5" "zod": "4.6.5"
},
"devDependencies": {
"chess.js": "1.4.0"
} }
} }

View File

@@ -0,0 +1,235 @@
/**
* Move generator verification (docs/games/chess.md §8.0): perft node counts against published numbers, Chess960
* start positions, and a differential random-game test against chess.js (dev dependency, standard chess only).
* The default run takes a few seconds; `PERFT_DEEP=1 bun test chess.perft` adds the deep counts (~1–2 minutes).
*/
import { describe, expect, test } from 'bun:test';
import { Chess, type Move as JsMove } from 'chess.js';
import { SeededRng } from '@bg/engine';
import { Position, chess960BackRank, chess960Fen } from './movegen';
import { START_FEN, adapterPerft, insufficientMaterial, positionKey } from './rules';
const DEEP = !!process.env.PERFT_DEEP;
const DEEP_TIMEOUT = 600_000;
interface Case {
name: string;
fen: string;
/** Node counts for depth 1, 2, 3, … */
counts: number[];
/** Depths run by default; the rest only with PERFT_DEEP. */
fast: number;
}
// https://www.chessprogramming.org/Perft_Results (positions 1–6) and python-chess examples/perft/tricky.perft.
const STANDARD: Case[] = [
{ name: 'start', fen: START_FEN, counts: [20, 400, 8902, 197281, 4865609, 119060324], fast: 4 },
{ name: 'Kiwipete', fen: 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1', counts: [48, 2039, 97862, 4085603], fast: 3 },
{ name: 'position 3', fen: '8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1', counts: [14, 191, 2812, 43238, 674624, 11030083], fast: 5 },
{ name: 'position 4', fen: 'r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1', counts: [6, 264, 9467, 422333, 15833292], fast: 3 },
{ name: 'position 4 mirrored', fen: 'r2q1rk1/pP1p2pp/Q4n2/bbp1p3/Np6/1B3NBn/pPPP1PPP/R3K2R b KQ - 0 1', counts: [6, 264, 9467, 422333], fast: 3 },
{ name: 'position 5', fen: 'rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8', counts: [44, 1486, 62379, 2103487], fast: 3 },
{ name: 'position 6', fen: 'r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10', counts: [46, 2079, 89890, 3894594], fast: 3 },
{ name: 'align-diag-1', fen: '3R4/8/q4k2/2B5/1NK5/3b4/8/8 w - - 0 1', counts: [4, 125, 2854], fast: 3 },
{ name: 'align-horizontal', fen: '5R2/2P5/8/4k3/8/3rK2r/8/8 w - - 0 1', counts: [2, 56, 1030], fast: 3 },
{ name: 'align-ep (e.p. capture exposes the king)', fen: '8/8/8/1k6/3Pp3/8/8/4KQ2 b - d3 0 1', counts: [6, 121, 711], fast: 3 },
{ name: 'align-ep-pinned', fen: '1b1k4/8/8/1rPpK3/8/8/8/8 w - d6 0 1', counts: [5, 100, 555], fast: 3 },
{ name: 'ep-unrelated-check', fen: 'rnbqk1nr/bb3p1p/1q2r3/2pPp3/3P4/7P/1PP1NpPP/R1BQKBNR w KQkq c6 0 1', counts: [2, 92, 2528], fast: 3 },
{ name: 'gotta-love-perft-2 (many queens)', fen: '8/6kR/8/8/8/bq6/1rqqqqqq/K1nqnbrq b - - 0 1', counts: [7, 52, 4593, 50268], fast: 4 },
];
// Chess960: https://www.chessprogramming.org/Chess960_Perft_Results (fischer.epd ids) and the X-FEN set from
// talkchess t=55274 (KQkq letters meaning the outermost rook), both via python-chess examples/perft.
const CHESS960: Case[] = [
{ name: '#0', fen: 'bqnb1rkr/pp3ppp/3ppn2/2p5/5P2/P2P4/NPP1P1PP/BQ1BNRKR w HFhf - 0 1', counts: [21, 528, 12189, 326672, 8146062], fast: 3 },
{ name: '#1', fen: '2nnrbkr/p1qppppp/8/1ppb4/6PP/3PP3/PPP2P2/BQNNRBKR w HEhe - 0 1', counts: [21, 807, 18002, 667366], fast: 3 },
{ name: '#2', fen: 'b1q1rrkb/pppppppp/3nn3/8/P7/1PPP4/4PPPP/BQNNRKRB w GE - 0 1', counts: [20, 479, 10471, 273318, 6417013], fast: 3 },
{ name: '#3', fen: 'qbbnnrkr/2pp2pp/p7/1p2pp2/8/P3PP2/1PPP1KPP/QBBNNR1R w hf - 0 1', counts: [22, 593, 13440, 382958], fast: 3 },
{ name: '#117', fen: 'nqbr1bkr/p1p1ppp1/1p1n4/3pN2p/1P6/8/P1PPPPPP/NQBR1BKR w HDhd - 0 1', counts: [29, 898, 26532, 809605], fast: 3 },
{ name: '#245', fen: '1rbkqbr1/ppp1pppp/1n5n/3p4/3P4/1PP3P1/P3PP1P/NRBKQBNR w HBb - 0 1', counts: [27, 752, 20686, 606783], fast: 3 },
{ name: '#389', fen: 'qrb2bkr/1pp1pppp/2np1n2/pN6/3P4/4B3/PPP1PPPP/QR2NBKR w HBhb - 0 1', counts: [27, 730, 20534, 585091], fast: 3 },
{ name: '#518', fen: 'r1bqk1rb/pppnpppp/5n2/3p4/2P3PP/2N5/PP1PPP2/R1BQKNRB w GAga - 0 1', counts: [32, 821, 27121, 733155], fast: 3 },
{ name: '#640', fen: 'b1rbkrqn/ppp2ppp/1n2p3/3p4/6P1/2PP4/PP2PP1P/BRNBKRQN w FBf - 0 1', counts: [21, 463, 10610, 253204, 6307276], fast: 3 },
{ name: '#777', fen: 'qrknbbrn/ppp1ppp1/8/7p/2Bp4/4PPP1/PPPP3P/QRKNB1RN w GBgb - 0 1', counts: [27, 593, 16168, 376808], fast: 3 },
{ name: '#902', fen: 'rkb1rn1b/ppppqppp/4p3/8/1P2n1P1/5Q2/P1PP1P1P/RKB1RNNB w EAea - 0 1', counts: [37, 1158, 40114, 1234768], fast: 3 },
{ name: '#959', fen: 'bbq1nr1r/pppppk1p/2n2p2/6p1/P4P2/4P1P1/1PPP3P/BBQNNRKR w HF - 0 1', counts: [23, 589, 14744, 387556], fast: 3 },
{ name: 'xfen-00', fen: 'r1k1r2q/p1ppp1pp/8/8/8/8/P1PPP1PP/R1K1R2Q w KQkq - 0 1', counts: [23, 522, 12333, 285754], fast: 3 },
{ name: 'xfen-01', fen: 'r1k2r1q/p1ppp1pp/8/8/8/8/P1PPP1PP/R1K2R1Q w KQkq - 0 1', counts: [28, 738, 20218, 541480], fast: 3 },
{ name: 'xfen-02', fen: '8/8/8/4B2b/6nN/8/5P2/2R1K2k w Q - 0 1', counts: [34, 318, 9002, 118388], fast: 4 },
{ name: 'xfen-03 (king d1, rook e1)', fen: '2r5/8/8/8/8/8/6PP/k2KR3 w K - 0 1', counts: [17, 242, 3931, 57700], fast: 4 },
{ name: 'xfen-04 (rook shields the king destination)', fen: '4r3/3k4/8/8/8/8/6PP/qR1K1R2 w KQ - 0 1', counts: [19, 628, 12858, 405636], fast: 3 },
];
function perftCases(cases: Case[], chess960: boolean) {
for (const c of cases) {
test(`${c.name} depth 1-${c.fast}`, () => {
const p = Position.fromFen(c.fen, chess960);
expect(c.counts.slice(0, c.fast).map((_, i) => p.perft(i + 1))).toEqual(c.counts.slice(0, c.fast));
// make/unmake leaves the position untouched.
expect(p.fen()).toBe(Position.fromFen(c.fen, chess960).fen());
});
if (c.counts.length > c.fast) {
test.skipIf(!DEEP)(
`${c.name} depth ${c.fast + 1}-${c.counts.length} (PERFT_DEEP)`,
() => {
const p = Position.fromFen(c.fen, chess960);
for (let d = c.fast + 1; d <= c.counts.length; d++) expect([d, p.perft(d)]).toEqual([d, c.counts[d - 1]!]);
},
DEEP_TIMEOUT,
);
}
}
}
describe('perft: standard', () => perftCases(STANDARD, false));
describe('perft: Chess960', () => perftCases(CHESS960, true));
describe('perft through the adapter (legalMoves → findMove → make)', () => {
test('standard and Chess960 counts match the generator', () => {
expect(adapterPerft(STANDARD[1]!.fen, 3)).toBe(97862);
expect(adapterPerft(STANDARD[3]!.fen, 3)).toBe(9467);
// Shredder-FEN, so the adapter sees Chess960 and takes the king-onto-rook castling input.
for (const c of CHESS960.slice(0, 12)) expect([c.name, adapterPerft(c.fen, 3)]).toEqual([c.name, c.counts[2]!]);
});
});
describe('Chess960 start positions (docs/games/chess.md §4)', () => {
test('known numbers', () => {
expect(chess960BackRank(0)).toBe('BBQNNRKR');
expect(chess960BackRank(1)).toBe('BQNBNRKR');
expect(chess960BackRank(4)).toBe('QBBNNRKR');
expect(chess960BackRank(518)).toBe('RNBQKBNR');
expect(chess960BackRank(959)).toBe('RKRNNQBB');
expect(chess960Fen(518)).toBe('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w HAha - 0 1');
expect(() => chess960BackRank(960)).toThrow();
expect(() => chess960BackRank(-1)).toThrow();
});
test('all 960 are distinct, bishops on opposite colours, king between the rooks', () => {
const seen = new Set<string>();
for (let i = 0; i < 960; i++) {
const r = chess960BackRank(i);
seen.add(r);
expect([...r].sort().join('')).toBe('BBKNNQRR');
const [b1, b2] = [r.indexOf('B'), r.lastIndexOf('B')];
expect((b1 + b2) % 2).toBe(1);
expect(r.indexOf('R') < r.indexOf('K') && r.indexOf('K') < r.lastIndexOf('R')).toBe(true);
}
expect(seen.size).toBe(960);
});
test('every start position: 2 castling rights per side, FEN round trip, perft(2) as symmetric as expected', () => {
for (let i = 0; i < 960; i++) {
const fen = chess960Fen(i);
const p = Position.fromFen(fen);
expect(p.chess960).toBe(true);
expect(p.castle.every((r) => r >= 0)).toBe(true);
expect(p.fen()).toBe(fen);
// Mirrored sides and no first move can touch the other side's options (even an immediate K/R-swap castle):
// perft(2) = perft(1)².
const n1 = p.perft(1);
expect(p.perft(2)).toBe(n1 * n1);
}
// Standard set-up through the 960 path gives the standard numbers.
expect([1, 2, 3, 4].map((d) => Position.fromFen(chess960Fen(518)).perft(d))).toEqual([20, 400, 8902, 197281]);
});
});
// ---------------------------------------------------------------- differential test against chess.js
const uciOf = (m: JsMove) => m.from + m.to + (m.promotion ?? '');
describe('differential vs chess.js 1.4 (standard chess, random games)', () => {
test('legal moves, SAN, FEN, check/mate/stalemate/draw detection agree move by move', () => {
const rng = SeededRng.fromSeed([9, 6, 0, 1]);
let plies = 0;
let mates = 0;
let stalemates = 0;
let castles = 0;
let enPassants = 0;
let promotions = 0;
let insufficient = 0;
let threefolds = 0;
for (let game = 0; game < (DEEP ? 200 : 24); game++) {
const ref = new Chess();
const p = Position.fromFen(START_FEN);
const seen = new Map<string, number>([[positionKey(p.fen()), 1]]);
for (let ply = 0; ply < 300; ply++) {
// Same position text: fen() normalises e.p. like chess.js, forceEnpassantSquare keeps it.
expect(p.fen()).toBe(ref.fen());
expect(p.fen({ forceEnpassantSquare: true })).toBe(ref.fen({ forceEnpassantSquare: true }));
const refMoves = ref.moves({ verbose: true });
const legal = p.moves();
const mine = new Map(legal.map((m) => [p.uci(m), m]));
expect([...mine.keys()].sort()).toEqual(refMoves.map(uciOf).sort());
expect(p.inCheck()).toBe(ref.inCheck());
expect(p.isCheckmate()).toBe(ref.isCheckmate());
expect(p.isStalemate()).toBe(ref.isStalemate());
expect(insufficientMaterial(p)).toBe(ref.isInsufficientMaterial());
expect(p.half >= 100).toBe(ref.isDrawByFiftyMoves());
// chess.js keys repetitions by its own hash (placement, side, castling, e.p. when capturable).
const key = positionKey(p.fen());
expect((seen.get(key) ?? 0) >= 3).toBe(ref.isThreefoldRepetition());
if (ref.isThreefoldRepetition()) threefolds++;
if (ref.isInsufficientMaterial()) insufficient++;
if (refMoves.length === 0) {
if (ref.isCheckmate()) mates++;
else stalemates++;
break;
}
if (ref.isInsufficientMaterial() || ref.isDrawByFiftyMoves()) break;
// Prefer captures, castling and promotions now and then so games reach endings and special moves.
const special = refMoves.filter((m) => m.isCapture() || m.isPromotion() || m.isKingsideCastle() || m.isQueensideCastle() || m.isEnPassant());
const pool = special.length && rng.int(3) === 0 ? special : refMoves;
const pick = pool[rng.int(pool.length)]!;
const mv = mine.get(uciOf(pick))!;
expect(p.san(mv, legal)).toBe(pick.san);
// The adapter input path finds the same move from (from, to, promotion).
expect(p.findMove(pick.from, pick.to, pick.promotion as 'q' | undefined, legal)).toBe(mv);
const played = p.play(mv, legal);
expect(played.san).toBe(pick.san);
expect(played.uci).toBe(uciOf(pick));
if (played.castle) castles++;
if (played.enPassant) enPassants++;
if (played.promotion) promotions++;
ref.move(pick);
seen.set(positionKey(p.fen()), (seen.get(positionKey(p.fen())) ?? 0) + 1);
plies++;
}
}
// The sample really exercised the special cases.
expect(plies).toBeGreaterThan(2_000);
for (const n of [mates, castles, enPassants, promotions, insufficient]) expect(n).toBeGreaterThan(0);
expect(stalemates + threefolds).toBeGreaterThan(0);
}, DEEP_TIMEOUT);
test('threefold repetition keys agree, including a double step without a capturable e.p.', () => {
// 1. e4 (no black pawn can take e.p.: the key must not carry e3), then knights out and back twice.
const line = ['e2e4', 'g8f6', 'g1f3', 'f6g8', 'f3g1', 'g8f6', 'g1f3', 'f6g8', 'f3g1', 'g8f6', 'g1f3', 'f6g8', 'f3g1'];
const ref = new Chess();
const p = Position.fromFen(START_FEN);
const seen = new Map<string, number>();
const flags: [boolean, boolean][] = [];
for (const u of line) {
ref.move({ from: u.slice(0, 2), to: u.slice(2, 4) });
p.play(p.findMove(u.slice(0, 2), u.slice(2, 4))!);
const key = positionKey(p.fen());
seen.set(key, (seen.get(key) ?? 0) + 1);
flags.push([seen.get(key)! >= 3, ref.isThreefoldRepetition()]);
}
expect(flags.map(([a]) => a)).toEqual(flags.map(([, b]) => b));
expect(flags.some(([a]) => a)).toBe(true);
});
test('loading FENs saved by chess.js (state v1) prints them back unchanged', () => {
for (const fen of [
START_FEN,
'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 2',
'rnbqkbnr/pppp1ppp/8/8/3pP3/8/PPP2PPP/RNBQKBNR b KQkq e3 0 3',
'r3k2r/8/8/8/8/8/8/R3K2R w Kq - 4 20',
'8/8/8/8/8/8/8/K6k w - - 99 120',
]) {
const ref = new Chess(fen);
expect(Position.fromFen(fen).fen()).toBe(ref.fen());
expect(Position.fromFen(fen).fen({ forceEnpassantSquare: true })).toBe(ref.fen({ forceEnpassantSquare: true }));
}
});
});

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine'; import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { chess, type ChessAction, type ChessOptions, type ChessState } from './index'; import { chess, type ChessAction, type ChessOptions, type ChessState } from './index';
import { START_FEN, adapterPerft, cannotMate, fullFen, insufficientMaterial, keyOf, load, positionKey, threatenedSquares } from './rules'; import { START_FEN, adapterPerft, cannotMate, chess960Fen, fullFen, insufficientMaterial, keyOf, load, positionKey, threatenedSquares } from './rules';
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]); const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
@@ -39,7 +39,7 @@ const canCastle = (s: ChessState, side: 'king' | 'queen') => chess.validate(s, t
describe('perft (§8.0)', () => { describe('perft (§8.0)', () => {
const KIWI = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'; const KIWI = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1';
test('initial position depth 1-4 (chess.js generator on adapter-loaded position)', () => { test('initial position depth 1-4 (adapter-loaded position; more in chess.perft.test.ts)', () => {
expect([1, 2, 3, 4].map((d) => load(START_FEN).perft(d))).toEqual([20, 400, 8902, 197281]); expect([1, 2, 3, 4].map((d) => load(START_FEN).perft(d))).toEqual([20, 400, 8902, 197281]);
}); });
test('Kiwipete depth 1-3', () => { test('Kiwipete depth 1-3', () => {
@@ -376,3 +376,201 @@ describe('views and playouts', () => {
} }
}, 30_000); }, 30_000);
}); });
// ---------------------------------------------------------------- Chess960
/** Chess960 game from a FEN (Shredder-FEN or X-FEN). White = 'A', black = 'B', clocks running. */
function make960(fen: string, opts: Partial<ChessOptions> = {}): ChessState {
const s = make({ variant: 'chess960', ...opts });
const c = load(fen, true);
s.startFen = fullFen(c);
s.fen = s.startFen;
s.repetition = { [keyOf(c)]: 1 };
return s;
}
const backRank = (s: ChessState) => s.fen.split(' ')[0]!.split('/')[7];
const castling = (s: ChessState) => s.fen.split(' ')[2];
const castleTargets = (s: ChessState) =>
chess
.view(s, turnPlayer(s))
.legalMoves!.filter((m) => m.castle)
.map((m) => `${m.castle}:${m.from}${m.to}`)
.sort();
describe('Chess960 set-up (§4)', () => {
test('number drawn from the seeded rng, start FEN in Shredder-FEN, shown in the view', () => {
const setup = (seed: number, variant: ChessOptions['variant'] = 'chess960') =>
chess.setup({ players: ['H', 'G'], options: chess.optionsSchema.parse({ variant }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
const s = setup(1);
expect(s.chess960Index).toBeGreaterThanOrEqual(0);
expect(s.chess960Index).toBeLessThan(960);
expect(s.startFen).toBe(chess960Fen(s.chess960Index!));
expect(s.fen).toBe(s.startFen);
expect(s.repetition).toEqual({ [positionKey(s.startFen)]: 1 });
expect(setup(1)).toEqual(s);
expect(chess.view(s, null).chess960Index).toBe(s.chess960Index);
expect(new Set(Array.from({ length: 200 }, (_, i) => setup(i).chess960Index)).size).toBeGreaterThan(150);
const std = setup(1, 'standard');
expect(std.chess960Index).toBeNull();
expect(std.startFen).toBe(START_FEN);
expect(chess.view(std, null).chess960Index).toBeNull();
});
test('the variant option draws the number after the colours, so standard games keep their rng sequence', () => {
const o = (variant: ChessOptions['variant']) => chess.optionsSchema.parse({ variant });
const a = chess.setup({ players: ['H', 'G'], options: o('standard'), rng: SeededRng.fromSeed(seedN(7)), now: 0 });
const b = chess.setup({ players: ['H', 'G'], options: o('chess960'), rng: SeededRng.fromSeed(seedN(7)), now: 0 });
expect(b.players).toEqual(a.players);
});
});
describe('Chess960 castling (§5.2)', () => {
test('king already on g1: king-side castling moves only the rook; input by king onto rook or castle action', () => {
const s = make960('4k3/8/8/8/8/8/8/R5KR w HA - 0 1');
expect(castleTargets(s)).toEqual(['king:g1h1', 'queen:g1a1', 'queen:g1c1']);
for (const a of [act(s, 'A', { type: 'castle', side: 'king' }), play(s, 'g1h1')]) {
expect(backRank(a)).toBe('R4RK1');
expect(castling(a)).toBe('-');
expect(a.moves[0]).toMatchObject({ san: 'O-O', uci: 'g1h1' });
expect(chess.view(a, null).lastMove).toEqual({ from: 'g1', to: 'g1' });
}
// Queen side: the king walks g1 → c1, the rook a1 → d1; c1 works as the king's target as well.
for (const a of [play(s, 'g1c1'), play(s, 'g1a1'), act(s, 'A', { type: 'castle', side: 'queen' })]) {
expect(backRank(a)).toBe('2KR3R');
expect(a.moves[0]).toMatchObject({ san: 'O-O-O', uci: 'g1a1' });
expect(chess.view(a, null).lastMove).toEqual({ from: 'g1', to: 'c1' });
}
});
test('king on b1: b1→g1 castles, but b1→c1 is a plain king move (castle queen-side via the rook or the button)', () => {
const s = make960('4k3/8/8/8/8/8/8/RK5R w HA - 0 1');
expect(castleTargets(s)).toEqual(['king:b1g1', 'king:b1h1', 'queen:b1a1']);
const kingSide = play(s, 'b1g1');
expect(backRank(kingSide)).toBe('R4RK1');
expect(kingSide.moves[0]!.san).toBe('O-O');
const step = play(s, 'b1c1');
expect(backRank(step)).toBe('R1K4R');
expect(step.moves[0]!.san).toBe('Kc1');
expect(castling(step)).toBe('-');
const queenSide = play(s, 'b1a1');
expect(backRank(queenSide)).toBe('2KR3R');
expect(queenSide.moves[0]!.san).toBe('O-O-O');
});
test('black castles the same way (8th rank)', () => {
const s = make960('rk5r/8/8/8/8/8/8/4K3 b ha - 0 1');
expect(backRank(s)).toBe('4K3');
const a = play(s, 'b8g8');
expect(a.fen.split(' ')[0]!.split('/')[0]).toBe('r4rk1');
expect(a.moves[0]).toMatchObject({ san: 'O-O', uci: 'b8h8', color: 'black' });
});
test('every square the king and the rook cross must be empty except for themselves', () => {
// Knight on b1: the king's path f1→c1 is clear, but the rook's path a1→d1 is not.
const rookPath = make960('4k3/8/8/8/8/8/8/RN3K1R w HA - 0 1');
expect(canCastle(rookPath, 'queen')).toBe(false);
expect(canCastle(rookPath, 'king')).toBe(true);
expect(tryMove(rookPath, 'f1a1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
expect(tryMove(rookPath, 'f1c1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
// Knight on g1 blocks the king's destination.
const kingDest = make960('4k3/8/8/8/8/8/8/RK4NR w HA - 0 1');
expect(canCastle(kingDest, 'king')).toBe(false);
expect(tryMove(kingDest, 'b1h1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
// The other rook in the way (king b1, rooks a1 and c1): queen-side king target c1 is occupied by it.
const otherRook = make960('4k3/8/8/8/8/8/8/RKR5 w CA - 0 1');
expect(canCastle(otherRook, 'queen')).toBe(false);
expect(canCastle(otherRook, 'king')).toBe(true);
expect(backRank(play(otherRook, 'b1c1'))).toBe('R4RK1');
});
test("the king's path may not be attacked; the rook's path may", () => {
// Black rook on e8 covers e1, which the king crosses on b1→g1.
const kingPath = make960('4r1k1/8/8/8/8/8/8/RK5R w HA - 0 1');
expect(canCastle(kingPath, 'king')).toBe(false);
expect(canCastle(kingPath, 'queen')).toBe(true);
// b1 is attacked, but only the rook crosses it (king g1 → c1).
expect(canCastle(make960('1r4k1/8/8/8/8/8/8/R5K1 w A - 0 1'), 'queen')).toBe(true);
// In check: no castling at all.
const check = make960('6k1/8/8/8/8/8/8/R5Kr w A - 0 1');
expect(canCastle(check, 'queen')).toBe(false);
});
test('the castling rook may not be shielding the king destination (queen a1, rook b1, king d1)', () => {
const s = make960('3k4/8/8/8/8/8/8/qR1K4 w B - 0 1');
expect(canCastle(s, 'queen')).toBe(false);
expect(tryMove(s, 'd1b1')).toEqual({ ok: false, reason: '지금은 캐슬링할 수 없어요.' });
});
test('rights: a moved rook loses its side, a moved king both; captured rooks too', () => {
const s = make960('r3k2r/8/8/8/8/8/8/RK5R w HAha - 0 1');
expect(castling(s)).toBe('HAha');
expect(castling(play(s, 'h1h2'))).toBe('Aha');
expect(castling(play(s, 'b1b2'))).toBe('ha');
expect(castling(play(s, 'h1h8'))).toBe('Aa');
});
test('standard chess never treats king-onto-rook as castling', () => {
const s = make({}, 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1');
expect(tryMove(s, 'e1h1')).toEqual({ ok: false, reason: '그 기물은 그렇게 움직일 수 없어요.' });
expect(castleTargets(s)).toEqual(['king:e1g1', 'queen:e1c1']);
});
test('rights still read as Chess960 after both sides lost them; undo restores them', () => {
let s = make960('r3k2r/8/8/8/8/8/8/RK5R w HAha - 0 1', { undo: '3' });
s = play(s, 'b1b2', 'e8e7');
expect(castling(s)).toBe('-');
s = act(s, 'A', { type: 'requestUndo' });
s = act(s, 'B', { type: 'respondUndo', accept: true });
expect(s.fen).toBe(s.startFen);
expect(castling(s)).toBe('HAha');
expect(canCastle(s, 'king')).toBe(true);
});
test('PGN carries Variant, SetUp and the Shredder-FEN start', () => {
let s = make960(chess960Fen(0));
s = act(s, 'A', { type: 'resign' });
expect(s.result!.pgn).toContain('[Variant "Chess960"]');
expect(s.result!.pgn).toContain('[SetUp "1"]');
expect(s.result!.pgn).toContain(`[FEN "${chess960Fen(0)}"]`);
});
});
describe('saved games (stateVersion 1 → 2)', () => {
test('a v1 state (no variant, no chess960Index) migrates to standard and keeps playing', () => {
const v2 = play(make(), 'e2e4', 'e7e5');
const { chess960Index: _drop, ...rest } = v2;
const { variant: _v, ...oldOptions } = v2.options;
const v1 = JSON.parse(JSON.stringify({ ...rest, options: oldOptions }));
const m = chess.migrate!(v1, 1);
expect(m.options.variant).toBe('standard');
expect(m.chess960Index).toBeNull();
expect(m).toEqual(v2);
expect(play(m, 'g1f3').fen).toBe(play(v2, 'g1f3').fen);
expect(chess.migrate!(v2, 2)).toBe(v2);
// Finished games are never migrated; their view and PGN still read as standard chess.
const finished = act({ ...v1, chess960Index: undefined } as unknown as ChessState, 'A', { type: 'resign' });
expect(chess.view(finished, null).chess960Index).toBeNull();
expect(finished.result!.pgn).toContain('[Variant "Standard"]');
});
});
describe('Chess960 playouts', () => {
test('random 960 games finish, castle sometimes, keep FENs consistent and replay deterministically', () => {
const options = chess.optionsSchema.parse({ variant: 'chess960', undo: '3' });
let castles = 0;
const indices = new Set<number>();
for (let i = 0; i < 8; i++) {
const a = randomPlayout(chess, {
players: ['A', 'B'],
options,
seed: seedN(100 + i),
maxApplyMs: 50,
invariant: (st) => {
const s = st as ChessState;
expect(load(s.fen, true).fen({ forceEnpassantSquare: true })).toBe(s.fen);
expect(s.fen.split(' ')[2]).toMatch(/^(-|[A-H]{0,2}[a-h]{0,2})$/);
},
});
expect(a.finished).toBe(true);
const final = a.finalState as ChessState;
indices.add(final.chess960Index!);
castles += final.moves.filter((m) => m.san.startsWith('O-O')).length;
if (i === 0) {
const b = randomPlayout(chess, { players: ['A', 'B'], options, seed: seedN(100), maxApplyMs: 50 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
}
}
expect(indices.size).toBeGreaterThan(4);
expect(castles).toBeGreaterThan(0);
}, 30_000);
});

View File

@@ -1,12 +1,9 @@
/** /**
* 체스 (docs/games/chess.md). Standard chess only: chess.js 1.4.0 has no Chess960 castling support, so the * 체스 (docs/games/chess.md): standard and Chess960 on one perft-verified move generator (`movegen.ts`, §8.0).
* `variant` option is left out until a 960-capable generator is chosen (§8.0).
*/ */
import { z } from 'zod'; 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 { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
import { import {
START_FEN,
boardArray, boardArray,
buildPgn, buildPgn,
cannotMate, cannotMate,
@@ -22,17 +19,22 @@ import {
keyOf, keyOf,
positionKey, positionKey,
pseudoReachable, pseudoReachable,
startFenFor,
threatenedSquares, threatenedSquares,
toSide, toSide,
type Color, type Color,
type LegalMove, type LegalMove,
type Promo, type Position,
} from './rules'; } from './rules';
export type { Color, LegalMove, Promo } from './rules'; export type { Color, LegalMove, Promo } from './rules';
const COLOR_KO: Record<Color, string> = { white: '백', black: '흑' }; const COLOR_KO: Record<Color, string> = { white: '백', black: '흑' };
export const chessOptionsSchema = z.object({ export const chessOptionsSchema = z.object({
variant: z
.enum(['standard', 'chess960'])
.default('standard')
.meta({ title: '변형', labels: { standard: '일반', chess960: '체스960(첫 줄 무작위)' } }),
timeControl: z timeControl: z
.object({ .object({
kind: z kind: z
@@ -129,7 +131,10 @@ export interface ChessMoveRecord {
export interface ChessState { export interface ChessState {
options: ChessOptions; options: ChessOptions;
players: Record<Color, PlayerId>; players: Record<Color, PlayerId>;
/** Shredder-FEN for Chess960 (castling rights as rook files, e.g. `HAha`). */
startFen: string; startFen: string;
/** Chess960 Scharnagl number 0..959 (518 = standard set-up); null for standard chess. */
chess960Index: number | null;
/** Current position (single source of truth). */ /** Current position (single source of truth). */
fen: string; fen: string;
moves: ChessMoveRecord[]; moves: ChessMoveRecord[];
@@ -149,6 +154,8 @@ export interface ChessView {
/** 64 FEN letters (uppercase = white), a8 first, then b8 … h1. */ /** 64 FEN letters (uppercase = white), a8 first, then b8 … h1. */
board: (string | null)[]; board: (string | null)[];
players: Record<Color, PlayerId>; players: Record<Color, PlayerId>;
/** Chess960 number, null for standard chess. */
chess960Index: number | null;
myColor: Color | null; myColor: Color | null;
turn: Color; turn: Color;
phase: ChessState['phase']; 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 => const colorOf = (s: ChessState, p: PlayerId): Color | null =>
s.players.white === p ? 'white' : s.players.black === p ? 'black' : null; s.players.white === p ? 'white' : s.players.black === p ? 'black' : null;
const turnOf = (s: ChessState): Color => (s.fen.split(' ')[1] === 'b' ? 'black' : 'white'); 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> = { const REASON_KO: Record<ChessReason, string> = {
checkmate: '체크메이트', checkmate: '체크메이트',
@@ -213,7 +224,7 @@ function finish(s: ChessState, winner: Color | null, reason: ChessReason, events
const pgn = buildPgn( const pgn = buildPgn(
s.startFen, s.startFen,
s.moves.map((m) => m.san), 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, tag,
); );
s.result = { winner, reason, moveCount, pgn, summary }; 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. */ /** Rebuild repetition counts from the start position and the stored positions after each move. */
function rebuildRepetition(s: ChessState): void { function rebuildRepetition(s: ChessState): void {
s.repetition = {}; 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. */ /** §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)); const mover = other(turnOf(s));
if (c.isCheckmate()) { if (c.isCheckmate()) {
events.push({ type: 'checkmate', color: mover }); 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; if (s.options.timeControl.kind === 'fischer') s.clock.remainingMs[loser] = 0;
events.push({ type: 'timeLoss', color: loser }); events.push({ type: 'timeLoss', color: loser });
const winner = other(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); 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 }; * Copy for `apply`. Move records, options, players, pending requests and results are never mutated once stored, so
if (a.type !== 'castle') return null; * they are shared; everything `apply` mutates in place is copied. (structuredClone of a long game cost ~0.5ms a move.)
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; 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 { function pendingResponder(s: ChessState): Color | null {
@@ -288,7 +314,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
nameKo: '체스', nameKo: '체스',
minPlayers: 2, minPlayers: 2,
maxPlayers: 2, maxPlayers: 2,
stateVersion: 1, stateVersion: 2,
defaultOptions: chessOptionsSchema.parse({}), defaultOptions: chessOptionsSchema.parse({}),
optionsSchema: chessOptionsSchema, optionsSchema: chessOptionsSchema,
actionSchema: chessActionSchema as z.ZodType<ChessAction>, actionSchema: chessActionSchema as z.ZodType<ChessAction>,
@@ -310,13 +336,16 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
hostWhite = rng.int(2) === 0; hostWhite = rng.int(2) === 0;
} }
const base = options.timeControl.baseMin * 60_000; const base = options.timeControl.baseMin * 60_000;
const chess960Index = options.variant === 'chess960' ? rng.int(960) : null;
const startFen = startFenFor(chess960Index);
return { return {
options, options,
players: hostWhite ? { white: host, black: guest } : { white: guest, black: host }, players: hostWhite ? { white: host, black: guest } : { white: guest, black: host },
startFen: START_FEN, startFen,
fen: START_FEN, chess960Index,
fen: startFen,
moves: [], moves: [],
repetition: { [positionKey(START_FEN)]: 1 }, repetition: { [positionKey(startFen)]: 1 },
phase: 'playing', phase: 'playing',
clock: { remainingMs: { white: base, black: base }, turnStartedAt: now, started: { white: false, black: false } }, clock: { remainingMs: { white: base, black: base }, turnStartedAt: now, started: { white: false, black: false } },
undo: { usedBy: { white: 0, black: 0 }, pending: null }, undo: { usedBy: { white: 0, black: 0 }, pending: null },
@@ -335,7 +364,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
case 'move': case 'move':
case 'castle': { case 'castle': {
if (turn !== me) return fail('지금은 상대 차례예요.'); if (turn !== me) return fail('지금은 상대 차례예요.');
const c = load(s.fen); const c = position(s);
if (a.type === 'castle') { if (a.type === 'castle') {
return legalMoves(c).some((m) => m.castle === a.side) ? ok : fail('지금은 그쪽으로 캐슬링할 수 없어요.'); 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('내 기물을 골라 주세요.'); if (!piece || piece.color !== toSide(me)) return fail('내 기물을 골라 주세요.');
const m = legalMoves(c, a.from).find((x) => x.to === a.to); const m = legalMoves(c, a.from).find((x) => x.to === a.to);
if (!m) { 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('지금은 캐슬링할 수 없어요.'); return fail('지금은 캐슬링할 수 없어요.');
} }
if (pseudoReachable(c, a.from, a.to)) { if (pseudoReachable(c, a.from, a.to)) {
@@ -394,7 +428,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
}, },
apply(prev, actor, a, { now }) { apply(prev, actor, a, { now }) {
const s = structuredClone(prev); const s = cloneState(prev);
const me = colorOf(s, actor)!; const me = colorOf(s, actor)!;
const events: GameEvent[] = []; const events: GameEvent[] = [];
switch (a.type) { switch (a.type) {
@@ -417,14 +451,14 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000; s.clock.remainingMs[me] += s.options.timeControl.incSec * 1000;
} }
// 2) Move. // 2) Move.
const mv = resolveMove(s, a)!; const c = position(s);
const c = load(s.fen); const legal = c.moves();
const isPromo = c.get(mv.from)?.type === 'p' && (mv.to[1] === '8' || mv.to[1] === '1'); const mv = a.type === 'castle' ? c.castleMove(a.side, legal)! : c.findMove(a.from, a.to, a.promotion, legal)!;
const done = c.move({ from: mv.from, to: mv.to, promotion: isPromo ? (mv.promotion ?? 'q') : undefined }); const done = c.play(mv, legal);
s.fen = fullFen(c); s.fen = fullFen(c);
const key = keyOf(c); const key = keyOf(c);
s.moves.push({ s.moves.push({
uci: done.lan, uci: done.uci,
san: done.san, san: done.san,
color: me, color: me,
at: now, at: now,
@@ -441,11 +475,9 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
} }
const check = c.inCheck(); const check = c.inCheck();
events.push({ type: 'moved', color: me, san: done.san, from: done.from, to: done.to, captured: done.captured ?? null, check }); 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.promotion) events.push({ type: 'promoted', color: me, piece: done.promotion });
if (done.isKingsideCastle() || done.isQueensideCastle()) { if (done.castle) events.push({ type: 'castled', color: me, side: done.castle });
events.push({ type: 'castled', color: me, side: done.isKingsideCastle() ? 'king' : 'queen' }); if (done.enPassant) events.push({ type: 'enPassant', color: me });
}
if (done.isEnPassant()) events.push({ type: 'enPassant', color: me });
if (check) events.push({ type: 'check', color: other(me) }); if (check) events.push({ type: 'check', color: other(me) });
// 3) End of game / claimable. // 3) End of game / claimable.
judge(s, c, events); judge(s, c, events);
@@ -482,7 +514,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
rebuildRepetition(s); rebuildRepetition(s);
s.undo.usedBy[by]++; s.undo.usedBy[by]++;
s.clock.turnStartedAt = now; 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 }); events.push({ type: 'undoAccepted', color: by, plies });
break; break;
} }
@@ -518,7 +550,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
view(s, viewer) { view(s, viewer) {
const myColor = viewer ? colorOf(s, viewer) : null; const myColor = viewer ? colorOf(s, viewer) : null;
const turn = turnOf(s); const turn = turnOf(s);
const c = load(s.fen); const c = position(s);
const mover = s.phase === 'playing' && myColor !== null && myColor === turn; const mover = s.phase === 'playing' && myColor !== null && myColor === turn;
const limit = undoLimit(s.options); const limit = undoLimit(s.options);
const last = s.moves[s.moves.length - 1]; const last = s.moves[s.moves.length - 1];
@@ -528,11 +560,12 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
fen: s.fen, fen: s.fen,
board: boardArray(s.fen), board: boardArray(s.fen),
players: s.players, players: s.players,
chess960Index: s.chess960Index ?? null,
myColor, myColor,
turn, turn,
phase: s.phase, phase: s.phase,
moves: s.moves.map((m) => ({ san: m.san, uci: m.uci, color: m.color })), 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, checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null,
timeControl: s.options.timeControl, timeControl: s.options.timeControl,
firstMoveSec: s.options.firstMoveSec, firstMoveSec: s.options.firstMoveSec,
@@ -615,6 +648,13 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
return { ranking, summary, reason: map[reason] }; 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) { legalActions(s, p) {
const me = colorOf(s, p); const me = colorOf(s, p);
if (!me || s.phase !== 'playing') return []; if (!me || s.phase !== 'playing') return [];
@@ -629,10 +669,13 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
} }
if (turnOf(s) !== me) return []; if (turnOf(s) !== me) return [];
const out: ChessAction[] = []; 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 }); 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 }); 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' }); if (s.claimable) out.push({ type: 'claimDraw' });
for (const extra of [{ type: 'requestUndo' }, { type: 'offerDraw' }] as const) { for (const extra of [{ type: 'requestUndo' }, { type: 'offerDraw' }] as const) {
if (chess.validate(s, p, extra).ok) out.push(extra); if (chess.validate(s, p, extra).ok) out.push(extra);

View File

@@ -0,0 +1,570 @@
/**
* Self-contained legal move generator for standard chess and Chess960 (docs/games/chess.md §8.0).
*
* - 0x88 board (index = rank * 16 + file, a1 = 0), make/unmake with an undo stack.
* - Castling is stored as the castling rook's square per colour and side, so standard and Chess960 share one code
* path. FEN input accepts KQkq (X-FEN: outermost rook) and Shredder-FEN file letters (`HAha`); output uses KQkq
* for standard and Shredder-FEN for Chess960.
* - En passant follows chess.js 1.4 exactly (the stored square is set only when an enemy pawn stands next to the
* double-stepped pawn, and `fen()` prints it only when a legal e.p. capture exists), so FENs and repetition keys of
* games saved before this generator replaced chess.js stay byte-identical.
* - SAN (incl. disambiguation and +/#) follows chess.js; Chess960 castling is written O-O / O-O-O.
* Verified with perft (chess.perft.test.ts) and differentially against chess.js (dev dependency only).
*/
export type Side = 'w' | 'b';
export type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k';
const PAWN = 1;
const KNIGHT = 2;
const BISHOP = 3;
const ROOK = 4;
const QUEEN = 5;
const KING = 6;
const BLACK = 8;
const TYPE_CHARS = ' pnbrqk';
const F_EP = 1;
const F_DOUBLE = 2;
const F_CASTLE_K = 4;
const F_CASTLE_Q = 8;
const F_CASTLE = F_CASTLE_K | F_CASTLE_Q;
const KNIGHT_STEPS = [33, 31, 18, 14, -33, -31, -18, -14];
const KING_STEPS = [1, -1, 16, -16, 15, 17, -15, -17];
const DIAG = [15, 17, -15, -17];
const ORTHO = [1, -1, 16, -16];
/** A move in board coordinates. Castling: `to` is the king's destination and `rook` the castling rook's square. */
export interface Move {
from: number;
to: number;
/** Moving piece type (1..6). */
piece: number;
/** Captured piece type, 0 if none (castling never captures). */
captured: number;
/** Promotion piece type, 0 if none. */
promo: number;
flags: number;
rook: number;
}
interface Undo {
move: Move;
castle: [number, number, number, number];
ep: number;
half: number;
}
export interface PieceAt {
square: string;
color: Side;
type: PieceType;
}
/** What `play` reports about a move (enough for events, records and SAN). */
export interface Played {
from: string;
to: string;
/** UCI; Chess960 castling is king square + rook square (UCI_Chess960 convention). */
uci: string;
san: string;
piece: PieceType;
captured?: PieceType;
promotion?: PieceType;
castle?: 'king' | 'queen';
enPassant: boolean;
}
const FILES = 'abcdefgh';
export const sqName = (s: number): string => FILES[s & 7]! + ((s >> 4) + 1);
export function sqIndex(name: string): number {
const f = name.charCodeAt(0) - 97;
const r = name.charCodeAt(1) - 49;
return f >= 0 && f < 8 && r >= 0 && r < 8 && name.length === 2 ? r * 16 + f : -1;
}
const onBoard = (s: number) => (s & 0x88) === 0;
export class Position {
readonly b = new Int8Array(128);
/** 0 = white, 1 = black to move. */
side = 0;
/** Castling rook squares: [white king side, white queen side, black king side, black queen side], -1 = no right. */
castle: [number, number, number, number] = [-1, -1, -1, -1];
ep = -1;
half = 0;
full = 1;
readonly king = [-1, -1];
/** Chess960: Shredder-FEN output and king-onto-rook castling input. */
chess960 = false;
private readonly stack: Undo[] = [];
/** Parse a FEN. Chess960 is detected from Shredder-FEN castling letters unless given explicitly. */
static fromFen(fen: string, chess960?: boolean): Position {
const p = new Position();
const [placement = '', side = 'w', castling = '-', ep = '-', half = '0', full = '1'] = fen.trim().split(/\s+/);
const rows = placement.split('/');
if (rows.length !== 8) throw new Error(`bad FEN: ${fen}`);
rows.forEach((row, i) => {
let f = 0;
for (const ch of row) {
if (ch >= '1' && ch <= '8') f += Number(ch);
else {
const t = TYPE_CHARS.indexOf(ch.toLowerCase());
if (t <= 0 || f > 7) throw new Error(`bad FEN: ${fen}`);
const s = (7 - i) * 16 + f;
p.b[s] = t | (ch === ch.toLowerCase() ? BLACK : 0);
if (t === KING) p.king[ch === 'k' ? 1 : 0] = s;
f++;
}
}
if (f !== 8) throw new Error(`bad FEN: ${fen}`);
});
if (p.king[0]! < 0 || p.king[1]! < 0) throw new Error(`bad FEN (king missing): ${fen}`);
p.side = side === 'b' ? 1 : 0;
p.chess960 = chess960 ?? /[A-Ha-h]/.test(castling);
for (const ch of castling === '-' ? '' : castling) p.addCastlingRight(ch);
p.ep = ep === '-' ? -1 : sqIndex(ep);
p.half = Number(half) || 0;
p.full = Number(full) || 1;
return p;
}
private addCastlingRight(ch: string): void {
const c = ch === ch.toUpperCase() ? 0 : 1;
const base = c * 0x70;
const k = this.king[c]!;
if (k >> 4 !== c * 7) return;
const rook = ROOK | (c ? BLACK : 0);
const lower = ch.toLowerCase();
let rs = -1;
if (lower === 'k') {
for (let f = 7; f > (k & 7); f--) if (this.b[base + f] === rook) { rs = base + f; break; }
} else if (lower === 'q') {
for (let f = 0; f < (k & 7); f++) if (this.b[base + f] === rook) { rs = base + f; break; }
} else {
const f = lower.charCodeAt(0) - 97;
if (f >= 0 && f < 8 && f !== (k & 7) && this.b[base + f] === rook) rs = base + f;
}
if (rs < 0) return;
// Standard chess: only the e-file king with a/h rooks may castle (as chess.js).
if (!this.chess960 && ((k & 7) !== 4 || ((rs & 7) !== 0 && (rs & 7) !== 7))) return;
this.castle[c * 2 + ((rs & 7) > (k & 7) ? 0 : 1)] = rs;
}
fen(opts: { forceEnpassantSquare?: boolean } = {}): string {
let out = '';
for (let r = 7; r >= 0; r--) {
let empty = 0;
for (let f = 0; f < 8; f++) {
const p = this.b[r * 16 + f]!;
if (!p) {
empty++;
continue;
}
if (empty) out += empty;
empty = 0;
const ch = TYPE_CHARS[p & 7]!;
out += p & BLACK ? ch : ch.toUpperCase();
}
if (empty) out += empty;
if (r) out += '/';
}
let castling = '';
for (let i = 0; i < 4; i++) {
const rs = this.castle[i]!;
if (rs < 0) continue;
const ch = this.chess960 ? FILES[rs & 7]! : i % 2 === 0 ? 'k' : 'q';
castling += i < 2 ? ch.toUpperCase() : ch;
}
const ep = this.ep >= 0 && (opts.forceEnpassantSquare || this.epCapturable()) ? sqName(this.ep) : '-';
return `${out} ${this.side ? 'b' : 'w'} ${castling || '-'} ${ep} ${this.half} ${this.full}`;
}
/** Is there a legal en-passant capture right now? */
private epCapturable(): boolean {
const us = this.side;
const pawn = PAWN | (us ? BLACK : 0);
const behind = this.ep + (us ? 16 : -16);
for (const s of [behind - 1, behind + 1]) {
if (!onBoard(s) || this.b[s] !== pawn) continue;
const m: Move = { from: s, to: this.ep, piece: PAWN, captured: PAWN, promo: 0, flags: F_EP, rook: -1 };
if (this.isLegal(m)) return true;
}
return false;
}
turn(): Side {
return this.side ? 'b' : 'w';
}
get(square: string): { type: PieceType; color: Side } | null {
const s = sqIndex(square);
const p = s < 0 ? 0 : this.b[s]!;
return p ? { type: TYPE_CHARS[p & 7] as PieceType, color: p & BLACK ? 'b' : 'w' } : null;
}
pieces(): PieceAt[] {
const out: PieceAt[] = [];
for (let r = 7; r >= 0; r--) {
for (let f = 0; f < 8; f++) {
const p = this.b[r * 16 + f]!;
if (p) out.push({ square: sqName(r * 16 + f), type: TYPE_CHARS[p & 7] as PieceType, color: p & BLACK ? 'b' : 'w' });
}
}
return out;
}
kingSquare(side: Side): string {
return sqName(this.king[side === 'w' ? 0 : 1]!);
}
/** Is square `s` attacked by colour `by` (0 white, 1 black)? */
attacked(s: number, by: number): boolean {
const b = this.b;
const col = by ? BLACK : 0;
const pawnFrom = by ? [s + 15, s + 17] : [s - 15, s - 17];
for (const t of pawnFrom) if (onBoard(t) && b[t] === (PAWN | col)) return true;
for (const d of KNIGHT_STEPS) {
const t = s + d;
if (onBoard(t) && b[t] === (KNIGHT | col)) return true;
}
for (const d of KING_STEPS) {
const t = s + d;
if (onBoard(t) && b[t] === (KING | col)) return true;
}
for (const d of DIAG) {
for (let t = s + d; onBoard(t); t += d) {
const p = b[t]!;
if (!p) continue;
if (p === (BISHOP | col) || p === (QUEEN | col)) return true;
break;
}
}
for (const d of ORTHO) {
for (let t = s + d; onBoard(t); t += d) {
const p = b[t]!;
if (!p) continue;
if (p === (ROOK | col) || p === (QUEEN | col)) return true;
break;
}
}
return false;
}
isAttacked(square: string, by: Side): boolean {
return this.attacked(sqIndex(square), by === 'w' ? 0 : 1);
}
inCheck(): boolean {
return this.attacked(this.king[this.side]!, this.side ^ 1);
}
/** Pseudo-legal moves (own king may be left in check), castling fully checked except the final square. */
private pseudo(): Move[] {
const out: Move[] = [];
const b = this.b;
const us = this.side;
const col = us ? BLACK : 0;
const add = (from: number, to: number, piece: number, captured: number, flags = 0) =>
out.push({ from, to, piece, captured, promo: 0, flags, rook: -1 });
const enemy = (p: number) => p !== 0 && (p & BLACK) !== col;
for (let s = 0; s < 120; s++) {
if (s & 0x88) {
s += 7;
continue;
}
const p = b[s]!;
if (!p || (p & BLACK) !== col) continue;
const t0 = p & 7;
if (t0 === PAWN) {
const dir = us ? -16 : 16;
const promoRank = us ? 0 : 7;
const pawnMove = (to: number, captured: number, flags = 0) => {
if (to >> 4 === promoRank) {
for (const promo of [QUEEN, ROOK, BISHOP, KNIGHT]) out.push({ from: s, to, piece: PAWN, captured, promo, flags, rook: -1 });
} else add(s, to, PAWN, captured, flags);
};
const one = s + dir;
if (onBoard(one) && !b[one]) {
pawnMove(one, 0);
const two = one + dir;
if (s >> 4 === (us ? 6 : 1) && !b[two]) add(s, two, PAWN, 0, F_DOUBLE);
}
for (const t of [one - 1, one + 1]) {
if (!onBoard(t)) continue;
if (enemy(b[t]!)) pawnMove(t, b[t]! & 7);
else if (t === this.ep) add(s, t, PAWN, PAWN, F_EP);
}
} else if (t0 === KNIGHT || t0 === KING) {
for (const d of t0 === KNIGHT ? KNIGHT_STEPS : KING_STEPS) {
const t = s + d;
if (!onBoard(t)) continue;
const q = b[t]!;
if (!q) add(s, t, t0, 0);
else if (enemy(q)) add(s, t, t0, q & 7);
}
} else {
const dirs = t0 === BISHOP ? DIAG : t0 === ROOK ? ORTHO : KING_STEPS;
for (const d of dirs) {
for (let t = s + d; onBoard(t); t += d) {
const q = b[t]!;
if (!q) add(s, t, t0, 0);
else {
if (enemy(q)) add(s, t, t0, q & 7);
break;
}
}
}
}
}
this.castlingMoves(out);
return out;
}
/**
* §5.2: king and rook unmoved (rights), every square between king and its destination and between rook and its
* destination empty except for that king and rook, the king not in check and not passing through an attacked
* square (all checked on the current board, start and destination included). The general legality test after the
* move additionally catches a Chess960 castling rook that was shielding the king's destination from a slider.
*/
private castlingMoves(out: Move[]): void {
const us = this.side;
const them = us ^ 1;
const k = this.king[us]!;
const base = us * 0x70;
for (let i = 0; i < 2; i++) {
const r = this.castle[us * 2 + i]!;
if (r < 0) continue;
const kTo = base + (i === 0 ? 6 : 2);
const rTo = base + (i === 0 ? 5 : 3);
let ok = true;
for (const [a, z] of [
[k, kTo],
[r, rTo],
] as const) {
for (let s = Math.min(a, z); s <= Math.max(a, z) && ok; s++) if (s !== k && s !== r && this.b[s]) ok = false;
}
for (let s = Math.min(k, kTo); s <= Math.max(k, kTo) && ok; s++) if (this.attacked(s, them)) ok = false;
if (ok) out.push({ from: k, to: kTo, piece: KING, captured: 0, promo: 0, flags: i === 0 ? F_CASTLE_K : F_CASTLE_Q, rook: r });
}
}
make(m: Move): void {
const b = this.b;
const us = this.side;
const them = us ^ 1;
const col = us ? BLACK : 0;
this.stack.push({ move: m, castle: [...this.castle], ep: this.ep, half: this.half });
if (m.flags & F_CASTLE) {
const base = us * 0x70;
b[m.from] = 0;
b[m.rook] = 0;
b[m.to] = KING | col;
b[base + (m.flags & F_CASTLE_K ? 5 : 3)] = ROOK | col;
} else {
if (m.flags & F_EP) b[m.to + (us ? 16 : -16)] = 0;
b[m.to] = m.promo ? m.promo | col : b[m.from]!;
b[m.from] = 0;
}
if (m.piece === KING) {
this.king[us] = m.to;
this.castle[us * 2] = -1;
this.castle[us * 2 + 1] = -1;
}
for (let i = 0; i < 2; i++) {
if (this.castle[us * 2 + i] === m.from) this.castle[us * 2 + i] = -1;
if (m.captured && this.castle[them * 2 + i] === m.to) this.castle[them * 2 + i] = -1;
}
this.ep = -1;
if (m.flags & F_DOUBLE) {
const enemyPawn = PAWN | (them ? BLACK : 0);
if ((onBoard(m.to - 1) && b[m.to - 1] === enemyPawn) || (onBoard(m.to + 1) && b[m.to + 1] === enemyPawn)) this.ep = (m.from + m.to) >> 1;
}
this.half = m.piece === PAWN || m.captured ? 0 : this.half + 1;
if (us) this.full++;
this.side = them;
}
unmake(): void {
const u = this.stack.pop()!;
const m = u.move;
const b = this.b;
const us = this.side ^ 1;
const col = us ? BLACK : 0;
this.side = us;
this.castle = u.castle;
this.ep = u.ep;
this.half = u.half;
if (us) this.full--;
if (m.flags & F_CASTLE) {
b[m.to] = 0;
b[us * 0x70 + (m.flags & F_CASTLE_K ? 5 : 3)] = 0;
b[m.from] = KING | col;
b[m.rook] = ROOK | col;
} else {
b[m.from] = m.promo ? PAWN | col : b[m.to]!;
const capCol = us ? 0 : BLACK;
if (m.flags & F_EP) {
b[m.to] = 0;
b[m.to + (us ? 16 : -16)] = PAWN | capCol;
} else b[m.to] = m.captured ? m.captured | capCol : 0;
}
if (m.piece === KING) this.king[us] = m.from;
}
private isLegal(m: Move): boolean {
const us = this.side;
this.make(m);
const ok = !this.attacked(this.king[us]!, us ^ 1);
this.unmake();
return ok;
}
/** All legal moves; promotions appear once per piece (q, r, b, n). */
moves(): Move[] {
return this.pseudo().filter((m) => this.isLegal(m));
}
isCheckmate(): boolean {
return this.inCheck() && this.moves().length === 0;
}
isStalemate(): boolean {
return !this.inCheck() && this.moves().length === 0;
}
perft(depth: number): number {
if (depth === 0) return 1;
const moves = this.moves();
if (depth === 1) return moves.length;
let n = 0;
for (const m of moves) {
this.make(m);
n += this.perft(depth - 1);
this.unmake();
}
return n;
}
/** UCI for a move; Chess960 castling is written king square → rook square. */
uci(m: Move): string {
if (m.flags & F_CASTLE && this.chess960) return sqName(m.from) + sqName(m.rook);
return sqName(m.from) + sqName(m.to) + (m.promo ? TYPE_CHARS[m.promo] : '');
}
/** SAN as chess.js writes it (`legal` = this position's legal moves, for disambiguation). */
san(m: Move, legal: Move[] = this.moves()): string {
let out: string;
if (m.flags & F_CASTLE) out = m.flags & F_CASTLE_K ? 'O-O' : 'O-O-O';
else {
out = '';
if (m.piece !== PAWN) {
let ambiguities = 0;
let sameRank = 0;
let sameFile = 0;
for (const o of legal) {
if (o.piece !== m.piece || o.from === m.from || o.to !== m.to || o.flags & F_CASTLE) continue;
ambiguities++;
if (o.from >> 4 === m.from >> 4) sameRank++;
if ((o.from & 7) === (m.from & 7)) sameFile++;
}
let dis = '';
if (ambiguities > 0) {
const name = sqName(m.from);
dis = sameRank > 0 && sameFile > 0 ? name : sameFile > 0 ? name[1]! : name[0]!;
}
out += TYPE_CHARS[m.piece]!.toUpperCase() + dis;
}
if (m.captured) {
if (m.piece === PAWN) out += FILES[m.from & 7];
out += 'x';
}
out += sqName(m.to);
if (m.promo) out += '=' + TYPE_CHARS[m.promo]!.toUpperCase();
}
this.make(m);
if (this.inCheck()) out += this.moves().length === 0 ? '#' : '+';
this.unmake();
return out;
}
/** Castling move for one side, if legal. */
castleMove(side: 'king' | 'queen', legal: Move[] = this.moves()): Move | undefined {
return legal.find((m) => m.flags & (side === 'king' ? F_CASTLE_K : F_CASTLE_Q));
}
/**
* Find the legal move for a from/to input. Castling: standard accepts the king's two-step (e1g1); Chess960
* accepts the king onto its own castling rook, or onto its destination square when that is not also a plain
* king move (e.g. king b1 → g1). Promotions default to a queen when `promotion` is not given.
*/
findMove(from: string, to: string, promotion?: PieceType, legal: Move[] = this.moves()): Move | undefined {
const f = sqIndex(from);
const t = sqIndex(to);
const promo = promotion ? TYPE_CHARS.indexOf(promotion) : QUEEN;
const plain = legal.find((m) => m.from === f && m.to === t && !(m.flags & F_CASTLE) && (!m.promo || m.promo === promo));
if (plain) return plain;
return legal.find((m) => m.flags & F_CASTLE && m.from === f && (this.chess960 ? m.rook === t || (m.to === t && m.to !== f) : m.to === t));
}
/** Play a legal move and describe it. */
play(m: Move, legal: Move[] = this.moves()): Played {
const san = this.san(m, legal);
const out: Played = {
from: sqName(m.from),
to: sqName(m.to),
uci: this.uci(m),
san,
piece: TYPE_CHARS[m.piece] as PieceType,
enPassant: !!(m.flags & F_EP),
};
if (m.captured) out.captured = TYPE_CHARS[m.captured] as PieceType;
if (m.promo) out.promotion = TYPE_CHARS[m.promo] as PieceType;
if (m.flags & F_CASTLE) out.castle = m.flags & F_CASTLE_K ? 'king' : 'queen';
this.make(m);
return out;
}
}
/** Helpers on raw moves for the adapter. */
export const moveInfo = {
isCastle: (m: Move): 'king' | 'queen' | null => (m.flags & F_CASTLE_K ? 'king' : m.flags & F_CASTLE_Q ? 'queen' : null),
isEnPassant: (m: Move) => !!(m.flags & F_EP),
from: (m: Move) => sqName(m.from),
to: (m: Move) => sqName(m.to),
rook: (m: Move) => sqName(m.rook),
};
// ---------------------------------------------------------------- Chess960 start positions
/** Back rank (white, a→h) for Scharnagl number 0..959 (docs/games/chess.md §4). 518 = RNBQKBNR. */
export function chess960BackRank(index: number): string {
if (!Number.isInteger(index) || index < 0 || index > 959) throw new Error(`bad Chess960 index ${index}`);
const rank: (string | null)[] = Array(8).fill(null);
let n = index;
rank[2 * (n % 4) + 1] = 'B';
n = Math.floor(n / 4);
rank[2 * (n % 4)] = 'B';
n = Math.floor(n / 4);
const free = () => rank.flatMap((p, i) => (p ? [] : [i]));
rank[free()[n % 6]!] = 'Q';
n = Math.floor(n / 6);
const KNIGHTS: [number, number][] = [[0, 1], [0, 2], [0, 3], [0, 4], [1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]];
const [k1, k2] = KNIGHTS[n]!;
const f5 = free();
rank[f5[k1]!] = 'N';
rank[f5[k2]!] = 'N';
const [r1, k, r2] = free() as [number, number, number];
rank[r1] = 'R';
rank[k] = 'K';
rank[r2] = 'R';
return rank.join('');
}
/** Shredder-FEN start position for a Chess960 number, e.g. 518 → `rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w HAha - 0 1`. */
export function chess960Fen(index: number): string {
const back = chess960BackRank(index);
const rooks = [back.lastIndexOf('R'), back.indexOf('R')].map((f) => FILES[f]!);
return `${back.toLowerCase()}/pppppppp/8/8/8/8/PPPPPPPP/${back} w ${rooks.join('').toUpperCase()}${rooks.join('')} - 0 1`;
}

View File

@@ -1,20 +1,27 @@
/** /**
* chess.js adapter (docs/games/chess.md §8.0). Move generation/validation comes from chess.js (BSD-2-Clause, * Chess rules adapter (docs/games/chess.md §8.0). Move generation/validation comes from our own generator
* Copyright (c) 2025 Jeff Hlywa); position keys, draw rules, insufficient material and the FIDE 6.9 * (`movegen.ts`, standard + Chess960, perft-verified); position keys, draw rules, insufficient material and the
* time-out exception are decided here with our own rules. * 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'; import { Position, chess960Fen, moveInfo, type Move, type PieceType } from './movegen';
export { Position, chess960BackRank, chess960Fen } from './movegen';
export type Color = 'white' | 'black'; export type Color = 'white' | 'black';
export type Promo = 'q' | 'r' | 'b' | 'n'; export type Promo = 'q' | 'r' | 'b' | 'n';
export const START_FEN = DEFAULT_POSITION; 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 toSide = (c: Color): 'w' | 'b' => (c === 'white' ? 'w' : 'b');
export const fromSide = (s: 'w' | 'b'): Color => (s === 'w' ? 'white' : 'black'); export const fromSide = (s: 'w' | 'b'): Color => (s === 'w' ? 'white' : 'black');
export const other = (c: Color): Color => (c === 'white' ? 'black' : 'white'); 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 }; export const PIECE_VALUE: Record<PieceType, 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). */ /**
* 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 { export interface LegalMove {
from: string; from: string;
to: string; to: string;
@@ -22,63 +29,79 @@ export interface LegalMove {
castle?: 'king' | 'queen'; castle?: 'king' | 'queen';
} }
export const isSquare = (s: string): s is Square => /^[a-h][1-8]$/.test(s); export const isSquare = (s: string): boolean => /^[a-h][1-8]$/.test(s);
export function load(fen: string): Chess { /** Load a FEN. Chess960 is recognised by its Shredder-FEN castling letters unless `chess960` is given. */
return new Chess(fen); 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. chess.js's fen() already prints * Repetition key: placement, side to move, castling rights, en-passant square. `fen()` prints the e.p. square only
* the e.p. square only when a legal e.p. capture exists, which is exactly the FIDE 9.2 normalisation. * when a legal e.p. capture exists, which is exactly the FIDE 9.2 normalisation.
*/ */
export function positionKey(fen: string): string { export function positionKey(fen: string): string {
return fen.split(' ').slice(0, 4).join(' '); return fen.split(' ').slice(0, 4).join(' ');
} }
export const keyOf = (c: Chess): string => positionKey(c.fen()); 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). */ /** 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 fullFen = (c: Position): string => c.fen({ forceEnpassantSquare: true });
export const halfmoveClock = (fen: string): number => Number(fen.split(' ')[4] ?? 0); export const halfmoveClock = (fen: string): number => Number(fen.split(' ')[4] ?? 0);
export const turnOf = (fen: string): Color => (fen.split(' ')[1] === 'b' ? 'black' : 'white'); 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. */ /** Legal moves for the side to move (optionally from one square), promotions collapsed. */
export function legalMoves(c: Chess, square?: Square): LegalMove[] { export function legalMoves(c: Position, square?: string): LegalMove[] {
const raw = square ? c.moves({ square, verbose: true }) : c.moves({ verbose: true });
const out: LegalMove[] = []; const out: LegalMove[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const m of raw) { const push = (m: LegalMove) => {
const k = m.from + m.to; const k = m.from + m.to;
if (seen.has(k)) continue; if (seen.has(k)) return;
seen.add(k); seen.add(k);
out.push(toLegal(m)); 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; return out;
} }
/** Count leaf nodes through the adapter (legalMoves → expand promotions → chess.move). */ /** Count leaf nodes through the adapter (legalMoves → expand promotions → findMove → make). */
export function adapterPerft(fen: string, depth: number): number { export function adapterPerft(fen: string, depth: number): number {
const c = load(fen); const c = load(fen);
const walk = (d: number): number => { const walk = (d: number): number => {
const moves = legalMoves(c);
let n = 0; let n = 0;
for (const m of moves) { 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]) { for (const promotion of m.promotion ? (['q', 'r', 'b', 'n'] as const) : [undefined]) {
if (d === 1) { if (d === 1) {
n++; n++;
continue; continue;
} }
c.move({ from: m.from, to: m.to, promotion }); c.make(c.findMove(m.from, m.to, promotion)!);
n += walk(d - 1); n += walk(d - 1);
c.undo(); c.unmake();
} }
} }
return n; return n;
@@ -90,7 +113,7 @@ export function adapterPerft(fen: string, depth: number): number {
* Can `from → to` be reached by the piece's movement pattern, ignoring checks? Used only to pick the rejection * 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. * message ("킹이 공격받게 되어…" vs "그렇게 움직일 수 없어요"). Castling is handled separately.
*/ */
export function pseudoReachable(c: Chess, from: Square, to: Square): boolean { export function pseudoReachable(c: Position, from: string, to: string): boolean {
const p = c.get(from); const p = c.get(from);
if (!p || from === to) return false; if (!p || from === to) return false;
const target = c.get(to); const target = c.get(to);
@@ -103,7 +126,7 @@ export function pseudoReachable(c: Chess, from: Square, to: Square): boolean {
const dy = ty - fy; const dy = ty - fy;
const adx = Math.abs(dx); const adx = Math.abs(dx);
const ady = Math.abs(dy); const ady = Math.abs(dy);
const sq = (x: number, y: number) => `${String.fromCharCode(97 + x)}${y + 1}` as Square; const sq = (x: number, y: number) => `${String.fromCharCode(97 + x)}${y + 1}`;
const clear = () => { const clear = () => {
const sx = Math.sign(dx); const sx = Math.sign(dx);
const sy = Math.sign(dy); const sy = Math.sign(dy);
@@ -135,28 +158,20 @@ export function pseudoReachable(c: Chess, from: Square, to: Square): boolean {
return false; return false;
} }
interface PieceAt { export const pieces = (c: Position) => c.pieces();
square: Square; /** 'light' | 'dark' square colour (a1 is dark). */
color: 'w' | 'b'; const squareColor = (sq: string): 'light' | 'dark' => ((sq.charCodeAt(0) - 97 + Number(sq[1]) - 1) % 2 === 0 ? 'dark' : 'light');
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 * 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. * bishop stands on the same square colour. KNN vs K is *not* included.
*/ */
export function insufficientMaterial(c: Chess): boolean { export function insufficientMaterial(c: Position): boolean {
const minors = pieces(c).filter((p) => p.type !== 'k'); const minors = pieces(c).filter((p) => p.type !== 'k');
if (minors.length === 0) return true; if (minors.length === 0) return true;
if (minors.length === 1 && (minors[0]!.type === 'b' || minors[0]!.type === 'n')) return true; if (minors.length === 1 && (minors[0]!.type === 'b' || minors[0]!.type === 'n')) return true;
if (minors.every((p) => p.type === 'b')) { if (minors.every((p) => p.type === 'b')) {
const colors = new Set(minors.map((p) => c.squareColor(p.square))); const colors = new Set(minors.map((p) => squareColor(p.square)));
return colors.size === 1; return colors.size === 1;
} }
return false; return false;
@@ -166,20 +181,20 @@ export function insufficientMaterial(c: Chess): boolean {
* FIDE 6.9 `cannotMate(side)` (§6 무승부 6): true only for the conservative, certain cases. Everything else is * 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). * treated as "mate is possible" (so the flagged player loses).
*/ */
export function cannotMate(c: Chess, side: 'w' | 'b'): boolean { export function cannotMate(c: Position, side: 'w' | 'b'): boolean {
const all = pieces(c).filter((p) => p.type !== 'k'); const all = pieces(c).filter((p) => p.type !== 'k');
const mine = all.filter((p) => p.color === side); const mine = all.filter((p) => p.color === side);
const theirs = all.filter((p) => p.color !== side); const theirs = all.filter((p) => p.color !== side);
if (mine.length === 0) return true; if (mine.length === 0) return true;
if (mine.length === 1 && theirs.length === 0 && (mine[0]!.type === 'n' || mine[0]!.type === 'b')) 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')) { if (all.every((p) => p.type === 'b')) {
return new Set(all.map((p) => c.squareColor(p.square))).size === 1; return new Set(all.map((p) => squareColor(p.square))).size === 1;
} }
return false; return false;
} }
/** The side to move's non-king pieces that are attacked and not defended (hints=movesAndThreats). */ /** The side to move's non-king pieces that are attacked and not defended (hints=movesAndThreats). */
export function threatenedSquares(c: Chess): string[] { export function threatenedSquares(c: Position): string[] {
const us = c.turn(); const us = c.turn();
const them = us === 'w' ? 'b' : 'w'; const them = us === 'w' ? 'b' : 'w';
return pieces(c) return pieces(c)
@@ -187,12 +202,12 @@ export function threatenedSquares(c: Chess): string[] {
.map((p) => p.square); .map((p) => p.square);
} }
export function kingSquare(c: Chess, side: 'w' | 'b'): Square | null { export function kingSquare(c: Position, side: 'w' | 'b'): string {
return c.findPiece({ type: 'k', color: side })[0] ?? null; return c.kingSquare(side);
} }
/** White material minus black material (Q9 R5 B3 N3 P1). */ /** White material minus black material (Q9 R5 B3 N3 P1). */
export function materialDiff(c: Chess): number { export function materialDiff(c: Position): number {
let d = 0; let d = 0;
for (const p of pieces(c)) d += (p.color === 'w' ? 1 : -1) * PIECE_VALUE[p.type]; for (const p of pieces(c)) d += (p.color === 'w' ? 1 : -1) * PIECE_VALUE[p.type];
return d; return d;