Merge branch 'm9/chess960' into codex/owner/grp_discord_joke-app
This commit is contained in:
@@ -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>
|
||||||
<p>
|
{c960 ? (
|
||||||
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이
|
<p>
|
||||||
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.
|
<strong>캐슬링(체스960)</strong>: 끝나는 자리는 일반 체스와 같아요. 킹 쪽(O-O)은 킹이 g열, 룩이 f열로, 퀸 쪽(O-O-O)은 킹이 c열, 룩이 d열로 가요. 킹과 그 룩이 한 번도
|
||||||
</p>
|
움직이지 않았고, 둘이 지나가는 칸이 (둘 자신 말고는) 모두 비어 있어야 해요. 체크 중이거나 킹이 지나가는 칸·도착 칸이 공격받으면 안 돼요. 킹을 누른 뒤 노란 테두리 칸(내 룩이나
|
||||||
|
g·c열 칸)을 누르거나, [캐슬링] 버튼을 눌러요.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이
|
||||||
|
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p>
|
<p>
|
||||||
<strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요.
|
<strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -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",
|
||||||
|
|||||||
@@ -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로 고정하고, 업데이트는 테스트 통과 후에만.
|
||||||
|
|||||||
@@ -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열끗, 동점은 재경기, 다음 선은 승자 | 나눠 갖기, 시계 방향 선 |
|
||||||
|
|||||||
@@ -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
100
e2e/chess960-plan.ts
Normal 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`);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
235
packages/games/src/chess/chess.perft.test.ts
Normal file
235
packages/games/src/chess/chess.perft.test.ts
Normal 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 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
570
packages/games/src/chess/movegen.ts
Normal file
570
packages/games/src/chess/movegen.ts
Normal 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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user