Merge branch 'm9/chess960' into codex/owner/grp_discord_joke-app
This commit is contained in:
100
e2e/chess960-plan.ts
Normal file
100
e2e/chess960-plan.ts
Normal 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`);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user