M9: 체스960 화면·E2E·문서

- 방 설정에 규칙(일반/체스960), 옆 패널에 시작 배치 번호와 캐슬링 버튼
- 캐슬링 칸을 노란 테두리 + '캐슬링' 글씨로 표시, 킹 선택 중 내 룩을 누르면 캐슬링
- E2E: chess960 시나리오(시작 번호를 읽어 뒷줄을 비우고 킹→룩으로 캐슬링), Plan.game
- docs: chess.md 5.4·8.0·9, 13-decisions, 02-architecture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 13:26:02 +09:00
parent 61b7c4e326
commit 5a2d865377
6 changed files with 233 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

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

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

View File

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