- 방 설정에 규칙(일반/체스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>
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
/**
|
||
* 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`);
|
||
}
|