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

@@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { loadConfig } from '../apps/server/src/config';
import { startServer } from '../apps/server/src/server';
import { plan960 } from './chess960-plan';
const PORT = Number(process.env.E2E_PORT ?? 4519);
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, ...). */
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. */
players?: number;
/** 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)
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: {
// cursor starts at 천원: black plays there, white plays one point to the left
moves: [['Enter'], ['ArrowLeft', 'Enter']],
@@ -2015,7 +2067,7 @@ for (const id of games) {
const guest = guests[0]!;
for (const p of [host, ...guests]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`));
try {
await host.goto(`${ORIGIN}/g/${id}`);
await host.goto(`${ORIGIN}/g/${plan.game ?? id}`);
await host.getByRole('button', { name: /친구랑 하기/ }).click();
await host.getByLabel('내 이름(닉네임)').fill('방장');
await host.getByRole('button', { name: '이 이름으로 시작하기' }).click();