Files
joke-app/e2e/chess960-plan.ts
EJClaw 5a2d865377 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>
2026-10-06 13:26:02 +09:00

101 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`);
}