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

@@ -51,13 +51,20 @@
- 앙파상(en passant): 상대 폰이 직전 수에 처음 위치에서 2칸 전진해 내 폰 바로 옆에 섰을 때, 바로 다음 수에 한해 그 폰이 1칸만 전진한 것처럼 대각선으로 잡을 수 있다.
- 승진(promotion): 폰이 마지막 행에 도달하면 같은 색 퀸·룩·비숍·나이트 중 하나로 반드시 바꾼다(기존 기물 수와 무관).
- 캐슬링(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.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. 승패와 점수 계산
- 승리: 체크메이트, 상대 기권, 상대 시간패(단 아래 예외), 상대 연결 끊김 시간 초과.
- 무승부:
@@ -84,9 +91,10 @@
| chessops (TypeScript, lichess 개발자 제작) | Chess960 포함 각종 변형 지원, lichess에서 실사용 | GPL-3.0 이상. 클라이언트 번들에 포함하면 클라이언트 코드 공개 의무 발생 가능. 서버 전용으로 쓰면 배포가 아니므로 일반적으로 문제 없으나 법적 판단 필요 |
| 자체 구현 | 의존성·라이선스 없음, 960 포함 완전 통제 | 수 생성 버그 위험. perft 테스트로 검증 필수, 개발 시간 증가 |
- 권장: v1(standard)은 **chess.js를 서버에서만** 사용해 판정하고, 클라이언트는 서버 view의 `legalMoves`로 하이라이트(클라이언트 라이브러리 불필요 → 번들 경량화, 판정 이중화 불일치 방지). 대신 아래를 어댑터에서 우리 규칙으로 직접 판정한다: 반복 국면 키(앙파상 정규화), 50/75수, 기물 부족 목록, 시간패 예외 `cannotMate`.
- Chess960(후속 마일스톤): (a) chessops를 서버 전용으로 도입(GPL 적용 범위를 사용자/법률 검토 후) 또는 (b) 자체 구현 + perft 검증. 사용자가 오픈소스 공개를 원치 않고 법률 검토도 부담이면 (b). 이 문서의 상태·액션 정의는 두 방식 모두에 맞도록 FEN(960은 Shredder-FEN: 캐슬링 권리를 룩의 열 문자로 표기, 예 `HAha`)을 기준으로 한다.
- 어느 쪽이든 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 세트 사용.
- 결정(M9): **자체 구현**(`packages/games/src/chess/movegen.ts`)으로 standard와 Chess960을 한 생성기로 처리한다. M2~M8은 chess.js를 서버에서만 썼고, 지금 chess.js는 테스트 전용 devDependency(비교 검증용)다. 클라이언트는 그대로 서버 view의 `legalMoves`로만 하이라이트한다(클라이언트에 규칙 코드 없음). 반복 국면 키(앙파상 정규화), 50/75수, 기물 부족 목록, 시간패 예외 `cannotMate`는 어댑터(`rules.ts`)에서 우리 규칙으로 판정한다.
- 생성기 구조: 0x88 판, make/unmake. 캐슬링 권리를 "색·방향별 룩 칸"으로 저장해 standard와 960이 같은 코드를 탄다. FEN 입력은 `KQkq`(X-FEN: 가장 바깥 룩)와 Shredder-FEN(`HAha`)을 모두 받고, 출력은 standard는 `KQkq`, 960은 Shredder-FEN.
- 예전 저장 대국 호환: 앙파상 칸 저장·출력, 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)
```ts
@@ -173,7 +181,7 @@ interface ChessResult {
- 모바일 세로: 상단 상대(잡은 기물, 시계), 가운데 정사각 판(폰 360px에서 칸 45px — 탭 정확도를 위해 칸 전체를 터치 영역으로), 하단 내 시계와 버튼 줄(무르기, 무승부, 기권, 기보, 규칙 보기). 판은 내 색이 아래로 오게 자동 회전.
- 조작: 기물 탭 → 이동 가능 칸 점 표시(잡기 칸은 테두리 링) → 도착 칸 탭. 드래그도 지원. 다른 내 기물 탭 시 선택 변경.
- 승진: 도착 시 큰 4개 버튼(퀸, 룩, 비숍, 나이트) 모달.
- 캐슬링: 킹 선택 시 캐슬링 도착 칸(standard: g1/c1, 960: 룩 칸도 탭 가능) 표시.
- 캐슬링: 킹 선택 시 캐슬링 칸을 노란 테두리 + "캐슬링" 글씨로 표시(잡기 링과 헷갈리지 않게). standard: g1/c1. 960: 내 룩 칸, 그리고 킹이 갈 g/c열 칸(그 칸이 보통 킹 이동과 겹치지 않을 때)을 모두 표시하고, 판 아래에 "노란 테두리 칸을 누르세요" 안내. 960은 옆 패널에 [캐슬링: 킹 쪽 (O-O)] / [캐슬링: 퀸 쪽 (O-O-O)] 버튼도 둔다. 960 대국은 옆 패널에 "체스960 · 시작 배치 n번"을 보여 준다.
- 체크 시 킹 칸 빨간 배경 + "체크!" 말풍선, 체크메이트·스테일메이트 결과 모달에 이유 설명(초보용: "킹이 피할 곳이 없어요").
- 마지막 수 출발·도착 칸 강조, 이동 애니메이션 150ms 내외(저사양 고려).
- 규칙 보기: 기물별 움직임 애니메이션 카드, 특수 규칙(앙파상·캐슬링·승진) 그림 설명.
@@ -192,7 +200,7 @@ interface ChessResult {
- [ ] 시간패 예외: 시간 초과한 쪽 상대가 킹만 → 무승부, 상대 킹+나이트이고 시간 초과한 쪽이 킹+퀸 → 상대 승.
- [ ] 첫 수 제한 초과 → `aborted`, 랭킹 비어 있음.
- [ ] 피셔 증가분: 수를 둔 뒤 가산, 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` 카운트 정확, 거절 시 무변화.
- [ ] 연결 끊김 유예 후 시간패 처리.
- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves`/`threatenedSquares` 없음, 모든 view에 `rng` 없음.