Merge branch 'm7/quoridor' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-05 14:56:12 +09:00
9 changed files with 2811 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ import { halliGalli } from './halli-galli';
import { marble } from './marble';
import { dalmuti } from './dalmuti';
import { splendor } from './splendor';
import { quoridor } from './quoridor';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -39,4 +40,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[marble.id]: marble,
[dalmuti.id]: dalmuti,
[splendor.id]: splendor,
[quoridor.id]: quoridor,
};

View File

@@ -0,0 +1,222 @@
/**
* 벽 쌓기 길찾기 판 계산 (docs/games/quoridor.md §3, §5): 좌표, 벽 충돌, 말 이동, BFS 경로.
* 칸 (c, r) 0~8, r=0이 1행(아래). 벽 앵커 (c, r) 0~7은 칸 (c,r)(c+1,r)(c,r+1)(c+1,r+1)의 가운데 교차점.
*/
export type Seat = 'S' | 'W' | 'N' | 'E';
export interface Cell {
c: number;
r: number;
}
export type Orientation = 'h' | 'v';
export interface Wall {
c: number;
r: number;
o: Orientation;
}
/** 8×8 앵커별 점유, 인덱스 r*8+c. */
export interface WallIndex {
h: boolean[];
v: boolean[];
}
export type JumpTrigger = 'wallEdgePawn' | 'wallOnly';
export const SIZE = 9;
export const ANCHORS = 8;
const CELLS = SIZE * SIZE;
export const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
export const START: Record<Seat, Cell> = { S: { c: 4, r: 0 }, W: { c: 0, r: 4 }, N: { c: 4, r: 8 }, E: { c: 8, r: 4 } };
/** 목표 줄: 남 → 9행, 북 → 1행, 서 → i열, 동 → a열. */
export const isGoal = (seat: Seat, c: number, r: number): boolean =>
seat === 'S' ? r === SIZE - 1 : seat === 'N' ? r === 0 : seat === 'W' ? c === SIZE - 1 : c === 0;
export const emptyWallIndex = (): WallIndex => ({ h: new Array<boolean>(64).fill(false), v: new Array<boolean>(64).fill(false) });
export const onBoard = (c: number, r: number): boolean => c >= 0 && r >= 0 && c < SIZE && r < SIZE;
export const anchorInRange = (c: number, r: number): boolean => Number.isInteger(c) && Number.isInteger(r) && c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS;
export const cellIdx = (c: number, r: number): number => r * SIZE + c;
export const sameCell = (a: Cell, b: Cell): boolean => a.c === b.c && a.r === b.r;
export const cellLabel = (p: Cell): string => `${'abcdefghi'[p.c]}${p.r + 1}`;
export const wallLabel = (w: Wall): string => `${w.o === 'h' ? '가로' : '세로'} ${cellLabel(w)}`;
const hasH = (w: WallIndex, c: number, r: number): boolean => c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS && w.h[r * ANCHORS + c]!;
const hasV = (w: WallIndex, c: number, r: number): boolean => c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS && w.v[r * ANCHORS + c]!;
/** (c,r)에서 (dc,dr) 방향 이웃으로 가는 길이 벽에 막혔는지. 판 안 여부는 호출하는 쪽이 본다. */
export function blocked(w: WallIndex, c: number, r: number, dc: number, dr: number): boolean {
if (dr === 1) return hasH(w, c, r) || hasH(w, c - 1, r);
if (dr === -1) return hasH(w, c, r - 1) || hasH(w, c - 1, r - 1);
if (dc === 1) return hasV(w, c, r) || hasV(w, c, r - 1);
return hasV(w, c - 1, r) || hasV(w, c - 1, r - 1);
}
/** 북·남·동·서 순서(결정적). */
export const DIRS: readonly [number, number][] = [
[0, 1],
[0, -1],
[1, 0],
[-1, 0],
];
/** 기존 벽과 겹치는지(§5.3 조건 3): 같은 방향 반 칸 겹침, 같은 앵커 십자 교차. */
export function overlaps(w: WallIndex, c: number, r: number, o: Orientation): boolean {
if (o === 'h') return hasH(w, c - 1, r) || hasH(w, c, r) || hasH(w, c + 1, r) || hasV(w, c, r);
return hasV(w, c, r - 1) || hasV(w, c, r) || hasV(w, c, r + 1) || hasH(w, c, r);
}
export function setWall(w: WallIndex, wall: Wall, on: boolean): void {
(wall.o === 'h' ? w.h : w.v)[wall.r * ANCHORS + wall.c] = on;
}
// BFS 작업 공간(호출마다 stamp로 초기화 → 할당 없음). 순수 함수 결과에는 영향 없음.
const queue = new Int16Array(CELLS);
const seen = new Int32Array(CELLS);
const dist = new Int16Array(CELLS);
const parent = new Int16Array(CELLS);
let stamp = 0;
/** 말은 무시하고 벽만 보는 최단 거리(칸 수). 길이 없으면 -1. §5.3 경로 검사. */
export function pathLength(w: WallIndex, start: Cell, seat: Seat): number {
if (isGoal(seat, start.c, start.r)) return 0;
stamp++;
let head = 0;
let tail = 0;
const s0 = cellIdx(start.c, start.r);
queue[tail++] = s0;
seen[s0] = stamp;
dist[s0] = 0;
while (head < tail) {
const cur = queue[head++]!;
const c = cur % SIZE;
const r = (cur - c) / SIZE;
for (const [dc, dr] of DIRS) {
const nc = c + dc;
const nr = r + dr;
if (!onBoard(nc, nr) || blocked(w, c, r, dc, dr)) continue;
const ni = nr * SIZE + nc;
if (seen[ni] === stamp) continue;
seen[ni] = stamp;
dist[ni] = dist[cur]! + 1;
if (isGoal(seat, nc, nr)) return dist[ni]!;
queue[tail++] = ni;
}
}
return -1;
}
/** 출발 칸 다음부터 목표 줄의 칸까지 최단 경로(결정적). 길이 없으면 null. */
export function shortestPath(w: WallIndex, start: Cell, seat: Seat): Cell[] | null {
if (isGoal(seat, start.c, start.r)) return [];
stamp++;
let head = 0;
let tail = 0;
const s0 = cellIdx(start.c, start.r);
queue[tail++] = s0;
seen[s0] = stamp;
parent[s0] = -1;
while (head < tail) {
const cur = queue[head++]!;
const c = cur % SIZE;
const r = (cur - c) / SIZE;
for (const [dc, dr] of DIRS) {
const nc = c + dc;
const nr = r + dr;
if (!onBoard(nc, nr) || blocked(w, c, r, dc, dr)) continue;
const ni = nr * SIZE + nc;
if (seen[ni] === stamp) continue;
seen[ni] = stamp;
parent[ni] = cur;
if (isGoal(seat, nc, nr)) {
const out: Cell[] = [];
for (let i = ni; i !== s0; i = parent[i]!) out.push({ c: i % SIZE, r: Math.floor(i / SIZE) });
return out.reverse();
}
queue[tail++] = ni;
}
}
return null;
}
export interface Pawn {
seat: Seat;
pos: Cell;
}
/** §5.2 이동 가능 칸(중복 없음, 결정적 순서). `pawns`는 판에 있는 모든 말(나 포함). */
export function legalPawnMoves(w: WallIndex, pawns: readonly Pawn[], me: Cell, trigger: JumpTrigger): Cell[] {
const occ = new Set(pawns.map((p) => cellIdx(p.pos.c, p.pos.r)));
const out: Cell[] = [];
const seenOut = new Set<number>();
const add = (c: number, r: number) => {
const i = cellIdx(c, r);
if (!seenOut.has(i)) {
seenOut.add(i);
out.push({ c, r });
}
};
for (const [dc, dr] of DIRS) {
const nc = me.c + dc;
const nr = me.r + dr;
if (!onBoard(nc, nr) || blocked(w, me.c, me.r, dc, dr)) continue;
if (!occ.has(cellIdx(nc, nr))) {
add(nc, nr);
continue;
}
// 인접 칸에 말 Q: 곧게 뛰어넘기 → 안 되면 조건에 따라 대각.
const jc = nc + dc;
const jr = nr + dr;
const jOn = onBoard(jc, jr);
const wallBehind = jOn && blocked(w, nc, nr, dc, dr);
if (jOn && !wallBehind && !occ.has(cellIdx(jc, jr))) {
add(jc, jr);
continue;
}
if (trigger === 'wallOnly' && !wallBehind) continue;
for (const [pc, pr] of dc === 0 ? [[1, 0], [-1, 0]] : [[0, 1], [0, -1]]) {
const tc = nc + pc!;
const tr = nr + pr!;
if (onBoard(tc, tr) && !blocked(w, nc, nr, pc!, pr!) && !occ.has(cellIdx(tc, tr))) add(tc, tr);
}
}
return out;
}
/** 이동할 수 없는 칸의 이유(한국어). */
export function moveRefusal(w: WallIndex, pawns: readonly Pawn[], me: Cell, to: Cell, trigger: JumpTrigger): string {
if (!onBoard(to.c, to.r)) return '판 밖이에요.';
if (sameCell(me, to)) return '지금 있는 칸이에요.';
const occ = new Set(pawns.map((p) => cellIdx(p.pos.c, p.pos.r)));
if (occ.has(cellIdx(to.c, to.r))) return '다른 말이 있는 칸이에요.';
const dc = to.c - me.c;
const dr = to.r - me.r;
const ad = Math.abs(dc) + Math.abs(dr);
if (ad === 1) return blocked(w, me.c, me.r, dc, dr) ? '벽이 가로막고 있어요.' : '그 칸으로는 갈 수 없어요.';
if (Math.abs(dc) === 1 && Math.abs(dr) === 1) {
// 대각: 옆에 말이 있고 곧게 넘을 수 있으면 대각은 안 됨.
for (const [sc, sr] of [
[dc, 0],
[0, dr],
] as const) {
if (!occ.has(cellIdx(me.c + sc, me.r + sr)) || blocked(w, me.c, me.r, sc, sr)) continue;
const jc = me.c + 2 * sc;
const jr = me.r + 2 * sr;
if (onBoard(jc, jr) && !blocked(w, me.c + sc, me.r + sr, sc, sr) && !occ.has(cellIdx(jc, jr))) {
return '곧게 뛰어넘을 수 있을 때는 대각선으로 갈 수 없어요.';
}
if (trigger === 'wallOnly' && !(onBoard(jc, jr) && blocked(w, me.c + sc, me.r + sr, sc, sr))) {
return '이 방은 넘을 말 뒤에 벽이 있을 때만 대각선으로 갈 수 있어요.';
}
const mc = me.c + sc;
const mr = me.r + sr;
if (blocked(w, mc, mr, to.c - mc, to.r - mr)) return '벽이 가로막고 있어요.';
}
return '대각선으로는 상대 말을 넘을 때만 갈 수 있어요.';
}
if (ad === 2 && (dc === 0 || dr === 0)) {
const sc = Math.sign(dc);
const sr = Math.sign(dr);
if (!occ.has(cellIdx(me.c + sc, me.r + sr))) return '한 번에 한 칸씩만 움직일 수 있어요.';
return '벽이 가로막고 있어요.';
}
if (ad === 3 && (dc === 0 || dr === 0)) return '말은 한 번에 하나만 뛰어넘을 수 있어요.';
return '그 칸으로는 갈 수 없어요.';
}

View File

@@ -0,0 +1,838 @@
/** 벽 쌓기 길찾기 (docs/games/quoridor.md). 내부 id 'quoridor'는 화면에 보이지 않는다(§12). */
import { z } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import {
ANCHORS,
SEAT_KO,
SIZE,
START,
anchorInRange,
emptyWallIndex,
isGoal,
legalPawnMoves,
moveRefusal,
overlaps,
pathLength,
sameCell,
setWall,
shortestPath,
type Cell,
type Pawn,
type Seat,
type Wall,
type WallIndex,
} from './board';
export type { Cell, Seat, Wall, Orientation, JumpTrigger } from './board';
export { SEAT_KO, cellLabel, wallLabel } from './board';
const ORDER_2: Seat[] = ['S', 'N'];
/** 위에서 볼 때 시계 방향: 남 → 서 → 북 → 동. */
const ORDER_4: Seat[] = ['S', 'W', 'N', 'E'];
const ALL_SEATS: Seat[] = ['S', 'W', 'N', 'E'];
export const WALLS_4P = 5;
const COUNT_ERROR = '2명 또는 4명이 필요해요.';
export const quoridorOptionsSchema = z.object({
wallsPerPlayer: z.number().int().min(6).max(10).default(10).meta({ title: '2명일 때 1인당 벽 수(4명은 5개 고정)' }),
diagonalJumpTrigger: z
.enum(['wallEdgePawn', 'wallOnly'])
.default('wallEdgePawn')
.meta({
title: '대각선으로 넘어갈 수 있는 때',
labels: { wallEdgePawn: '넘을 말 뒤가 벽·판 끝·다른 말일 때', wallOnly: '넘을 말 뒤가 벽일 때만' },
}),
seatOrder: z.enum(['random', 'joinOrder']).default('random').meta({ title: '시작 자리·순서', labels: { random: '무작위', joinOrder: '들어온 순서' } }),
continueForRanking: z.boolean().default(false).meta({ title: '4명일 때 1등이 나와도 계속해서 2·3등 가리기' }),
timeControl: z
.object({
kind: z
.enum(['none', 'perMove', 'fischer'])
.default('perMove')
.meta({ title: '시간 방식', labels: { none: '제한 없음', perMove: '한 수당 제한', fischer: '전체 시간 + 수당 추가' } }),
perMoveSec: z.number().int().min(5).max(600).default(30).meta({ title: '한 수 제한(초)' }),
baseSec: z.number().int().min(60).max(3600).default(300).meta({ title: '전체 시간(초)' }),
incSec: z.number().int().min(0).max(60).default(3).meta({ title: '한 수마다 추가(초)' }),
})
.default({ kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 })
.meta({ title: '시간 제한' }),
timeoutPolicy: z
.enum(['autoMove', 'lose'])
.default('autoMove')
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '최단 길로 말 한 칸 자동 이동', lose: '시간패' } }),
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 이동 허용 횟수(넘으면 시간패)' }),
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
undo: z
.enum(['off', '1', '3', 'unlimited'])
.default('3')
.meta({ title: '무르기(1인당)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
showPath: z.boolean().default(true).meta({ title: '내 최단 길 보여 주기(힌트)' }),
});
export type QuoridorOptions = z.infer<typeof quoridorOptionsSchema>;
const coord = z.number().int().min(-20).max(20);
export const quoridorActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('move'), to: z.object({ c: coord, r: coord }) }),
z.object({ type: z.literal('placeWall'), c: coord, r: coord, o: z.enum(['h', 'v']) }),
z.object({ type: z.literal('requestUndo') }),
z.object({ type: z.literal('respondUndo'), accept: z.boolean() }),
z.object({ type: z.literal('offerDraw') }),
z.object({ type: z.literal('respondDraw'), accept: z.boolean() }),
z.object({ type: z.literal('cancelOffer') }),
z.object({ type: z.literal('resign') }),
]);
/** 클라이언트 행동 + 시스템 전용 timeout(스키마에 없음). */
export type QuoridorAction = z.infer<typeof quoridorActionSchema> | { type: 'timeout' };
export type SeatStatus = 'playing' | 'finished' | 'resigned' | 'timedOut';
export interface SeatState {
seat: Seat;
playerId: PlayerId;
/** null = 판에서 제거(4인 기권·시간패, 순위 계속 모드의 도착). */
pos: Cell | null;
wallsLeft: number;
status: SeatStatus;
finishOrder: number | null;
/** 기권·시간패 순서(늦을수록 높은 순위). */
outOrder: number | null;
}
export type QuoridorMove =
| { kind: 'move'; seat: Seat; from: Cell; to: Cell; auto: boolean }
| { kind: 'wall'; seat: Seat; wall: Wall }
| { kind: 'pass'; seat: Seat };
export type QuoridorReason = 'reachedGoal' | 'lastPlayerStanding' | 'resign' | 'timeLoss' | 'drawAgreed' | 'stalemate';
export interface QuoridorResult {
ranking: { playerId: PlayerId; rank: number }[];
reason: QuoridorReason;
perPlayer: { playerId: PlayerId; seat: Seat; distanceLeft: number | null; wallsLeft: number; status: SeatStatus }[];
moveCount: number;
summaryKo: string;
}
export interface QuoridorState {
options: QuoridorOptions;
seats: SeatState[];
turnIndex: number;
walls: Wall[];
wallIndex: WallIndex;
moves: QuoridorMove[];
phase: 'playing' | 'finished';
/** 네 자리 키가 항상 있음(2인이면 W/E는 쓰지 않음). */
clock: { remainingMs: Record<Seat, number>; turnStartedAt: number; autoMoveStreak: Record<Seat, number> };
/** `plies` = 수락 시 `moves` 끝에서 되돌릴 항목 수. */
undo: { usedBy: Record<Seat, number>; pending: { by: Seat; accepted: Seat[]; plies: number } | null };
/** lastOfferMove: 마지막 제안 때의 moves.length(-1 = 없음). */
draw: { pendingBy: Seat | null; accepted: Seat[]; lastOfferMove: Record<Seat, number> };
outCount: number;
result: QuoridorResult | null;
}
export interface QuoridorLegalWall extends Wall {
/** 이 벽을 놓으면 상대(들) 최단 거리 합이 몇 칸 늘어나는지. */
opp: number;
/** 내 최단 거리가 몇 칸 늘어나는지. */
me: number;
}
export interface QuoridorSeatView {
seat: Seat;
playerId: PlayerId;
pos: Cell | null;
wallsLeft: number;
status: SeatStatus;
finishOrder: number | null;
/** 말 무시, 벽만 본 최단 거리(판에 없으면 null). */
distance: number | null;
autoMoveStreak: number;
}
export interface QuoridorView {
size: number;
seats: QuoridorSeatView[];
mySeat: Seat | null;
turn: Seat;
phase: QuoridorState['phase'];
walls: Wall[];
moves: QuoridorMove[];
moveCount: number;
lastMove: QuoridorMove | null;
/** 차례인 플레이어에게만(그 외 null). */
legalPawnMoves: Cell[] | null;
legalWalls: QuoridorLegalWall[] | null;
myShortestPath: Cell[] | null;
wallsPerPlayer: number;
diagonalJumpTrigger: QuoridorOptions['diagonalJumpTrigger'];
continueForRanking: boolean;
showPath: boolean;
timeControl: QuoridorOptions['timeControl'];
timeoutPolicy: QuoridorOptions['timeoutPolicy'];
remainingMs: Record<Seat, number>;
turnStartedAt: number;
undo: { allowed: boolean; remaining: Record<Seat, number | null>; pending: { by: Seat; accepted: Seat[]; plies: number } | null; responders: Seat[] };
draw: { allowed: boolean; pendingBy: Seat | null; accepted: Seat[]; responders: Seat[] };
result: QuoridorResult | null;
}
// ---------------------------------------------------------------- helpers
const undoLimit = (o: QuoridorOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
const seatIndexOf = (s: QuoridorState, p: PlayerId): number => s.seats.findIndex((x) => x.playerId === p);
const bySeat = (s: QuoridorState, seat: Seat): SeatState => s.seats.find((x) => x.seat === seat)!;
const playing = (s: QuoridorState): SeatState[] => s.seats.filter((x) => x.status === 'playing');
const pawnsOf = (s: QuoridorState): Pawn[] => s.seats.filter((x) => x.pos).map((x) => ({ seat: x.seat, pos: x.pos! }));
const recordOf = <T>(v: T): Record<Seat, T> => ({ S: v, W: v, N: v, E: v });
const four = (s: QuoridorState) => s.seats.length === 4;
export const wallsFor = (o: QuoridorOptions, players: number): number => (players === 4 ? WALLS_4P : o.wallsPerPlayer);
export function playerCountError(n: number): string | null {
return n === 2 || n === 4 ? null : COUNT_ERROR;
}
export function pawnMovesFor(s: QuoridorState, i: number): Cell[] {
const x = s.seats[i]!;
if (!x.pos || x.status !== 'playing') return [];
return legalPawnMoves(s.wallIndex, pawnsOf(s), x.pos, s.options.diagonalJumpTrigger);
}
/** 앵커 범위·남은 벽·겹침(§5.3 조건 1~3)만 본 이유. 통과하면 null. */
function wallShapeRefusal(s: QuoridorState, i: number, c: number, r: number, o: 'h' | 'v'): string | null {
if (s.seats[i]!.wallsLeft <= 0) return '남은 벽이 없어요.';
if (!anchorInRange(c, r)) return '벽은 판 밖으로 나갈 수 없어요.';
if (overlaps(s.wallIndex, c, r, o)) return '다른 벽과 겹쳐요.';
return null;
}
/** 후보 벽을 잠시 세우고 남아 있는 모든 플레이어의 최단 거리를 잰다(-1 = 막힘). */
function distancesWith(s: QuoridorState, w: Wall | null): number[] {
if (w) setWall(s.wallIndex, w, true);
const out = s.seats.map((x) => (x.status === 'playing' && x.pos ? pathLength(s.wallIndex, x.pos, x.seat) : 0));
if (w) setWall(s.wallIndex, w, false);
return out;
}
/** §5.3 조건 4. `s.wallIndex`를 잠시 바꿨다가 되돌린다 — 반드시 사본 상태로 부른다. */
function blocksSomeone(s: QuoridorState, w: Wall): boolean {
setWall(s.wallIndex, w, true);
let bad = false;
for (const x of s.seats) {
if (x.status === 'playing' && x.pos && pathLength(s.wallIndex, x.pos, x.seat) < 0) {
bad = true;
break;
}
}
setWall(s.wallIndex, w, false);
return bad;
}
export function wallRefusal(s: QuoridorState, i: number, w: Wall): string | null {
const shape = wallShapeRefusal(s, i, w.c, w.r, w.o);
if (shape) return shape;
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
return blocksSomeone(t, { c: w.c, r: w.r, o: w.o }) ? '누군가의 길을 완전히 막을 수 없어요.' : null;
}
/** 합법 벽 목록(최대 128 후보 × 남은 사람 수만큼 BFS). `withDelta`면 거리 변화량도. */
export function legalWallsFor(s: QuoridorState, i: number, withDelta = false): QuoridorLegalWall[] {
if (s.seats[i]!.wallsLeft <= 0 || s.seats[i]!.status !== 'playing') return [];
// wallIndex를 잠시 바꾸므로 사본에서 계산(state는 불변 취급).
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
const base = withDelta ? distancesWith(t, null) : [];
const out: QuoridorLegalWall[] = [];
for (let r = 0; r < ANCHORS; r++) {
for (let c = 0; c < ANCHORS; c++) {
for (const o of ['h', 'v'] as const) {
if (overlaps(t.wallIndex, c, r, o)) continue;
const w = { c, r, o };
if (!withDelta) {
if (!blocksSomeone(t, w)) out.push({ ...w, opp: 0, me: 0 });
continue;
}
const d = distancesWith(t, w);
if (d.some((v) => v < 0)) continue;
let opp = 0;
t.seats.forEach((x, k) => {
if (k !== i && x.status === 'playing') opp += d[k]! - base[k]!;
});
out.push({ ...w, opp, me: d[i]! - base[i]! });
}
}
}
return out;
}
function hasLegalWall(s: QuoridorState, i: number): boolean {
if (s.seats[i]!.wallsLeft <= 0) return false;
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
for (let r = 0; r < ANCHORS; r++) {
for (let c = 0; c < ANCHORS; c++) {
for (const o of ['h', 'v'] as const) if (!overlaps(t.wallIndex, c, r, o) && !blocksSomeone(t, { c, r, o })) return true;
}
}
return false;
}
const canAct = (s: QuoridorState, i: number): boolean => pawnMovesFor(s, i).length > 0 || hasLegalWall(s, i);
function distanceOf(s: QuoridorState, x: SeatState): number | null {
if (x.status === 'finished') return 0;
if (!x.pos) return null;
return pathLength(s.wallIndex, x.pos, x.seat);
}
/** 요청자 외 아직 남아 있는 플레이어. */
function respondersOf(s: QuoridorState, by: Seat): Seat[] {
return playing(s)
.map((x) => x.seat)
.filter((x) => x !== by);
}
function clearPending(s: QuoridorState): void {
s.undo.pending = null;
s.draw.pendingBy = null;
s.draw.accepted = [];
}
// ---------------------------------------------------------------- finishing
function rankAndFinish(s: QuoridorState, reason: QuoridorReason, events: GameEvent[]): void {
s.phase = 'finished';
clearPending(s);
const ranking: QuoridorResult['ranking'] = [];
let next = 1;
const pushGroup = (group: SeatState[]) => {
for (const x of group) ranking.push({ playerId: x.playerId, rank: next });
next += group.length;
};
const finished = s.seats.filter((x) => x.status === 'finished').sort((a, b) => a.finishOrder! - b.finishOrder!);
for (const x of finished) pushGroup([x]);
const alive = playing(s);
if (reason === 'drawAgreed') pushGroup(alive);
else {
const byDist = new Map<number, SeatState[]>();
for (const x of alive) {
const d = distanceOf(s, x) ?? 999;
byDist.set(d, [...(byDist.get(d) ?? []), x]);
}
for (const d of [...byDist.keys()].sort((a, b) => a - b)) pushGroup(byDist.get(d)!);
}
const out = s.seats.filter((x) => x.status === 'resigned' || x.status === 'timedOut').sort((a, b) => b.outOrder! - a.outOrder!);
for (const x of out) pushGroup([x]);
const moveCount = s.moves.filter((m) => m.kind !== 'pass').length;
const first = ranking.filter((r) => r.rank === 1).map((r) => s.seats.find((x) => x.playerId === r.playerId)!.seat);
const firstKo = first.map((x) => SEAT_KO[x]).join('·');
const lastOut = out[0];
let summaryKo: string;
switch (reason) {
case 'reachedGoal':
summaryKo = `${firstKo} 승리 — ${moveCount}수 만에 도착`;
break;
case 'lastPlayerStanding':
summaryKo = `${finished.map((x) => SEAT_KO[x.seat]).join(' → ')} 순서로 도착`;
break;
case 'resign':
summaryKo = four(s) && out.length > 1 ? `다른 사람이 모두 기권해 ${firstKo} 승리` : `${SEAT_KO[lastOut!.seat]} 기권으로 ${firstKo} 승리`;
break;
case 'timeLoss':
summaryKo = `${SEAT_KO[lastOut!.seat]} 시간패로 ${firstKo} 승리`;
break;
case 'drawAgreed':
summaryKo = '합의 무승부';
break;
case 'stalemate':
summaryKo = '아무도 움직일 수 없어 남은 거리로 순위를 정했어요';
break;
}
s.result = {
ranking,
reason,
perPlayer: s.seats.map((x) => ({ playerId: x.playerId, seat: x.seat, distanceLeft: distanceOf(s, x), wallsLeft: x.wallsLeft, status: x.status })),
moveCount,
summaryKo,
};
events.push({ type: 'gameEnded', reason, ranking });
}
/**
* 차례 넘기기(§8.2): `from` 다음의 남은 플레이어. 이동·벽 모두 불가하면 자동 패스.
* 모두가 아무것도 못 하면(극히 드묾) 남은 거리로 순위를 정해 끝낸다.
*/
function advance(s: QuoridorState, from: number, now: number, events: GameEvent[]): void {
const n = s.seats.length;
let i = from;
for (let k = 0; k < n; k++) {
i = nextSeat(i, n, (j) => s.seats[j]!.status !== 'playing');
if (canAct(s, i)) {
s.turnIndex = i;
s.clock.turnStartedAt = now;
return;
}
const seat = s.seats[i]!.seat;
s.moves.push({ kind: 'pass', seat });
events.push({ type: 'forcedPass', seat });
}
s.turnIndex = i;
rankAndFinish(s, 'stalemate', events);
}
/** 기권·시간패 처리(§6, §8.5). 2인은 상대 승, 4인은 말 제거·남은 벽 소멸. */
function removeSeat(s: QuoridorState, i: number, status: 'resigned' | 'timedOut', now: number, events: GameEvent[]): void {
const x = s.seats[i]!;
clearPending(s);
x.status = status;
x.outOrder = ++s.outCount;
events.push({ type: status === 'resigned' ? 'playerResigned' : 'timeLoss', seat: x.seat });
if (four(s)) {
x.pos = null;
x.wallsLeft = 0;
}
if (playing(s).length <= 1) {
rankAndFinish(s, status === 'resigned' ? 'resign' : 'timeLoss', events);
return;
}
if (s.turnIndex === i) advance(s, i, now, events);
}
/** 이미 검증된 말 이동. 도착 판정 → 종료 또는 계속 → 차례 넘기기. */
function movePawn(s: QuoridorState, i: number, to: Cell, auto: boolean, now: number, events: GameEvent[]): void {
const x = s.seats[i]!;
const from = x.pos!;
x.pos = { c: to.c, r: to.r };
s.moves.push({ kind: 'move', seat: x.seat, from, to: x.pos, auto });
clearPending(s);
events.push({ type: 'pawnMoved', seat: x.seat, from, to: x.pos, auto });
if (isGoal(x.seat, to.c, to.r)) {
x.status = 'finished';
x.finishOrder = s.seats.filter((y) => y.finishOrder !== null).length + 1;
events.push({ type: 'playerFinished', seat: x.seat, order: x.finishOrder });
if (!(four(s) && s.options.continueForRanking)) {
rankAndFinish(s, 'reachedGoal', events);
return;
}
x.pos = null;
if (playing(s).length <= 1) {
// 마지막 1명은 자동으로 다음 순위.
rankAndFinish(s, 'lastPlayerStanding', events);
return;
}
}
advance(s, i, now, events);
}
function placeWall(s: QuoridorState, i: number, w: Wall, now: number, events: GameEvent[]): void {
const x = s.seats[i]!;
const wall = { c: w.c, r: w.r, o: w.o };
s.walls.push(wall);
setWall(s.wallIndex, wall, true);
x.wallsLeft--;
s.moves.push({ kind: 'wall', seat: x.seat, wall });
clearPending(s);
events.push({ type: 'wallPlaced', seat: x.seat, wall });
advance(s, i, now, events);
}
/** §8.5 autoMove: 목표까지 거리가 가장 짧아지는 칸(동률은 RNG). */
function autoTarget(s: QuoridorState, i: number, rng: Rng): Cell | null {
const x = s.seats[i]!;
const cands = pawnMovesFor(s, i);
if (cands.length === 0) return null;
let best = Infinity;
let pool: Cell[] = [];
for (const c of cands) {
const d = pathLength(s.wallIndex, c, x.seat);
const dd = d < 0 ? 999 : d;
if (dd < best) {
best = dd;
pool = [c];
} else if (dd === best) pool.push(c);
}
return pool[rng.int(pool.length)]!;
}
/** 직전 행동부터 되돌릴 기록 시작 인덱스. 없으면 -1. */
function undoStart(s: QuoridorState, seat: Seat): number {
let lastAction = -1;
for (let k = s.moves.length - 1; k >= 0; k--) {
if (s.moves[k]!.kind !== 'pass') {
lastAction = k;
break;
}
}
if (lastAction < 0) return -1;
if (four(s)) return s.moves[lastAction]!.seat === seat ? lastAction : -1;
for (let k = s.moves.length - 1; k >= 0; k--) {
const m = s.moves[k]!;
if (m.kind !== 'pass' && m.seat === seat) return k;
}
return -1;
}
/** 기록 끝에서 `plies`개를 거꾸로 되돌린다(벽 개수 포함). */
function rewind(s: QuoridorState, plies: number): void {
for (let k = 0; k < plies; k++) {
const m = s.moves.pop()!;
if (m.kind === 'move') {
const x = bySeat(s, m.seat);
if (!x.pos || !sameCell(x.pos, m.to)) throw new Error('undo: move record does not match the board');
x.pos = { ...m.from };
} else if (m.kind === 'wall') {
const last = s.walls.pop();
if (!last || last.c !== m.wall.c || last.r !== m.wall.r || last.o !== m.wall.o) throw new Error('undo: wall record mismatch');
setWall(s.wallIndex, last, false);
bySeat(s, m.seat).wallsLeft++;
}
}
}
function chargeClock(s: QuoridorState, i: number, now: number): boolean {
if (s.options.timeControl.kind !== 'fischer') return true;
const seat = s.seats[i]!.seat;
s.clock.remainingMs[seat] -= now - s.clock.turnStartedAt;
if (s.clock.remainingMs[seat] <= 0) {
s.clock.remainingMs[seat] = 0;
return false;
}
s.clock.remainingMs[seat] += s.options.timeControl.incSec * 1000;
return true;
}
// ---------------------------------------------------------------- definition
export const quoridor: GameDefinition<QuoridorState, QuoridorAction, QuoridorView, QuoridorOptions> = {
id: 'quoridor',
nameKo: '벽 쌓기 길찾기',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: quoridorOptionsSchema.parse({}),
optionsSchema: quoridorOptionsSchema as unknown as z.ZodType<QuoridorOptions>,
actionSchema: quoridorActionSchema as z.ZodType<QuoridorAction>,
playerCountError: (_o, n) => playerCountError(n),
setup({ players, options, rng, now }) {
const err = playerCountError(players.length);
if (err) throw new Error(`quoridor: ${err}`);
const order = players.length === 4 ? ORDER_4 : ORDER_2;
const assigned = options.seatOrder === 'random' ? rng.shuffle(players) : [...players];
const walls = wallsFor(options, players.length);
const base = options.timeControl.baseSec * 1000;
return {
options,
seats: order.map((seat, k) => ({ seat, playerId: assigned[k]!, pos: { ...START[seat] }, wallsLeft: walls, status: 'playing' as const, finishOrder: null, outOrder: null })),
turnIndex: 0,
walls: [],
wallIndex: emptyWallIndex(),
moves: [],
phase: 'playing',
clock: { remainingMs: recordOf(base), turnStartedAt: now, autoMoveStreak: recordOf(0) },
undo: { usedBy: recordOf(0), pending: null },
draw: { pendingBy: null, accepted: [], lastOfferMove: recordOf(-1) },
outCount: 0,
result: null,
};
},
validate(s, actor, a) {
const i = seatIndexOf(s, actor);
if (i < 0) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
const x = s.seats[i]!;
if (x.status !== 'playing') return fail(x.status === 'finished' ? '이미 목표에 도착했어요.' : '이미 게임에서 빠졌어요.');
const myTurn = s.turnIndex === i;
switch (a.type) {
case 'move': {
if (!myTurn) return fail('지금은 내 차례가 아니에요.');
const to = a.to;
if (pawnMovesFor(s, i).some((m) => sameCell(m, to))) return ok;
return fail(moveRefusal(s.wallIndex, pawnsOf(s), x.pos!, to, s.options.diagonalJumpTrigger));
}
case 'placeWall': {
if (!myTurn) return fail('지금은 내 차례가 아니에요.');
const why = wallRefusal(s, i, { c: a.c, r: a.r, o: a.o });
return why ? fail(why) : ok;
}
case 'timeout':
return myTurn ? ok : fail('지금은 내 차례가 아니에요.');
case 'requestUndo': {
if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.');
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
if (s.undo.usedBy[x.seat] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.');
if (undoStart(s, x.seat) < 0) return fail(four(s) ? '방금 내가 한 행동만, 다음 사람이 두기 전에 무를 수 있어요.' : '아직 무를 수가 없어요.');
return ok;
}
case 'respondUndo': {
const p = s.undo.pending;
if (!p || p.by === x.seat || p.accepted.includes(x.seat)) return fail('응답할 무르기 요청이 없어요.');
return ok;
}
case 'offerDraw': {
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
const last = s.draw.lastOfferMove[x.seat];
if (last >= 0 && !s.moves.slice(last).some((m) => m.kind !== 'pass' && m.seat === x.seat)) {
return fail('무승부 제안은 한 번 더 둔 뒤에 다시 할 수 있어요.');
}
return ok;
}
case 'respondDraw': {
const by = s.draw.pendingBy;
if (!by || by === x.seat || s.draw.accepted.includes(x.seat)) return fail('응답할 무승부 제안이 없어요.');
return ok;
}
case 'cancelOffer':
if (s.undo.pending?.by !== x.seat && s.draw.pendingBy !== x.seat) return fail('취소할 요청이 없어요.');
return ok;
case 'resign':
return ok;
}
return fail('알 수 없는 행동이에요.');
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const i = seatIndexOf(s, actor);
const x = s.seats[i]!;
const seat = x.seat;
const events: GameEvent[] = [];
switch (a.type) {
case 'move':
case 'placeWall': {
if (!chargeClock(s, i, now)) {
removeSeat(s, i, 'timedOut', now, events);
break;
}
s.clock.autoMoveStreak[seat] = 0;
if (a.type === 'move') {
if (!pawnMovesFor(s, i).some((m) => sameCell(m, a.to))) throw new Error('illegal pawn move');
movePawn(s, i, a.to, false, now, events);
} else {
if (wallRefusal(s, i, a)) throw new Error('illegal wall');
placeWall(s, i, a, now, events);
}
break;
}
case 'timeout': {
const tc = s.options.timeControl.kind;
if (tc === 'perMove' && s.options.timeoutPolicy === 'autoMove' && s.clock.autoMoveStreak[seat] < s.options.autoMoveLimit) {
s.clock.autoMoveStreak[seat]++;
events.push({ type: 'timeoutAutoMove', seat });
const to = autoTarget(s, i, rng);
if (to) movePawn(s, i, to, true, now, events);
else {
s.moves.push({ kind: 'pass', seat });
clearPending(s);
events.push({ type: 'forcedPass', seat });
advance(s, i, now, events);
}
} else {
if (tc === 'fischer') s.clock.remainingMs[seat] = 0;
removeSeat(s, i, 'timedOut', now, events);
}
break;
}
case 'requestUndo': {
const start = undoStart(s, seat);
s.undo.pending = { by: seat, accepted: [], plies: s.moves.length - start };
events.push({ type: 'undoRequested', seat });
break;
}
case 'respondUndo': {
const p = s.undo.pending!;
if (!a.accept) {
s.undo.pending = null;
events.push({ type: 'undoDeclined', seat: p.by, by: seat });
break;
}
p.accepted.push(seat);
if (!respondersOf(s, p.by).every((y) => p.accepted.includes(y))) {
events.push({ type: 'undoAcceptedBy', seat: p.by, by: seat });
break;
}
s.undo.pending = null;
// 생각하던 사람이 쓴 시간은 차감하고, 시계는 그대로 둔다.
if (s.options.timeControl.kind === 'fischer') {
const thinker = s.seats[s.turnIndex]!.seat;
s.clock.remainingMs[thinker] = Math.max(0, s.clock.remainingMs[thinker] - (now - s.clock.turnStartedAt));
}
rewind(s, p.plies);
s.undo.usedBy[p.by]++;
s.turnIndex = s.seats.findIndex((y) => y.seat === p.by);
s.clock.turnStartedAt = now;
for (const y of ALL_SEATS) s.draw.lastOfferMove[y] = Math.min(s.draw.lastOfferMove[y], s.moves.length);
events.push({ type: 'undoAccepted', seat: p.by, plies: p.plies });
break;
}
case 'offerDraw':
s.draw.pendingBy = seat;
s.draw.accepted = [];
s.draw.lastOfferMove[seat] = s.moves.length;
events.push({ type: 'drawOffered', seat });
break;
case 'respondDraw': {
if (!a.accept) {
s.draw.pendingBy = null;
s.draw.accepted = [];
events.push({ type: 'drawDeclined', by: seat });
break;
}
s.draw.accepted.push(seat);
if (respondersOf(s, s.draw.pendingBy!).every((y) => s.draw.accepted.includes(y))) {
events.push({ type: 'drawAccepted' });
rankAndFinish(s, 'drawAgreed', events);
} else events.push({ type: 'drawAcceptedBy', by: seat });
break;
}
case 'cancelOffer':
if (s.undo.pending?.by === seat) s.undo.pending = null;
if (s.draw.pendingBy === seat) {
s.draw.pendingBy = null;
s.draw.accepted = [];
}
events.push({ type: 'offerCancelled', seat });
break;
case 'resign':
removeSeat(s, i, 'resigned', now, events);
break;
}
return { state: s, events };
},
view(s, viewer) {
const vi = viewer === null ? -1 : seatIndexOf(s, viewer);
const mySeat = vi >= 0 ? s.seats[vi]!.seat : null;
const mine = s.phase === 'playing' && vi >= 0 && vi === s.turnIndex && s.seats[vi]!.status === 'playing';
const limit = undoLimit(s.options);
const me = vi >= 0 ? s.seats[vi]! : null;
let lastMove: QuoridorMove | null = null;
for (let k = s.moves.length - 1; k >= 0; k--) {
if (s.moves[k]!.kind !== 'pass') {
lastMove = s.moves[k]!;
break;
}
}
return {
size: SIZE,
seats: s.seats.map((x) => ({
seat: x.seat,
playerId: x.playerId,
pos: x.pos,
wallsLeft: x.wallsLeft,
status: x.status,
finishOrder: x.finishOrder,
distance: distanceOf(s, x),
autoMoveStreak: s.clock.autoMoveStreak[x.seat],
})),
mySeat,
turn: s.seats[s.turnIndex]!.seat,
phase: s.phase,
walls: s.walls,
moves: s.moves,
moveCount: s.moves.length,
lastMove,
legalPawnMoves: mine ? pawnMovesFor(s, vi) : null,
legalWalls: mine ? legalWallsFor(s, vi, true) : null,
myShortestPath: mine && s.options.showPath && me?.pos ? shortestPath(s.wallIndex, me.pos, me.seat) : null,
wallsPerPlayer: wallsFor(s.options, s.seats.length),
diagonalJumpTrigger: s.options.diagonalJumpTrigger,
continueForRanking: four(s) && s.options.continueForRanking,
showPath: s.options.showPath,
timeControl: s.options.timeControl,
timeoutPolicy: s.options.timeoutPolicy,
remainingMs: s.clock.remainingMs,
turnStartedAt: s.clock.turnStartedAt,
undo: {
allowed: s.options.undo !== 'off',
remaining: Object.fromEntries(ALL_SEATS.map((y) => [y, Number.isFinite(limit) ? limit - s.undo.usedBy[y] : null])) as Record<Seat, number | null>,
pending: s.undo.pending,
responders: s.undo.pending ? respondersOf(s, s.undo.pending.by) : [],
},
draw: {
allowed: s.options.drawOffer,
pendingBy: s.draw.pendingBy,
accepted: s.draw.accepted,
responders: s.draw.pendingBy ? respondersOf(s, s.draw.pendingBy) : [],
},
result: s.result,
};
},
activePlayers(s) {
if (s.phase !== 'playing') return [];
const out = [s.seats[s.turnIndex]!.playerId];
const p = s.undo.pending;
const waiting = p ? respondersOf(s, p.by).filter((y) => !p.accepted.includes(y)) : s.draw.pendingBy ? respondersOf(s, s.draw.pendingBy).filter((y) => !s.draw.accepted.includes(y)) : [];
for (const y of waiting) {
const id = bySeat(s, y).playerId;
if (!out.includes(id)) out.push(id);
}
return out;
},
deadline(s) {
if (s.phase !== 'playing') return null;
const tc = s.options.timeControl;
const start = s.clock.turnStartedAt;
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.seats[s.turnIndex]!.seat];
return start + s.options.idleLimitSec * 1000;
},
timeoutPlayers(s) {
return s.phase === 'playing' ? [s.seats[s.turnIndex]!.playerId] : [];
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
const i = seatIndexOf(s, player);
if (i < 0 || s.phase !== 'playing' || s.seats[i]!.status !== 'playing') return null;
return { type: 'resign' };
},
result(s): GameResult | null {
const r = s.result;
if (!r) return null;
const groups = new Map<number, PlayerId[]>();
for (const x of r.ranking) groups.set(x.rank, [...(groups.get(x.rank) ?? []), x.playerId]);
const reason: Record<QuoridorReason, GameResult['reason']> = {
reachedGoal: 'normal',
lastPlayerStanding: 'normal',
stalemate: 'normal',
resign: 'resign',
timeLoss: 'timeout',
drawAgreed: 'draw-agreed',
};
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps),
summary: r.summaryKo,
reason: reason[r.reason],
};
},
/** 테스트용: 차례인 사람의 모든 말 이동·벽, 대기 중인 요청의 응답. */
legalActions(s, p) {
const i = seatIndexOf(s, p);
if (i < 0 || s.phase !== 'playing' || s.seats[i]!.status !== 'playing') return [];
const seat = s.seats[i]!.seat;
const out: QuoridorAction[] = [];
const u = s.undo.pending;
if (u && u.by !== seat && !u.accepted.includes(seat)) out.push({ type: 'respondUndo', accept: true }, { type: 'respondUndo', accept: false });
const d = s.draw.pendingBy;
if (d && d !== seat && !s.draw.accepted.includes(seat)) out.push({ type: 'respondDraw', accept: false });
if (s.turnIndex !== i) return out;
for (const to of pawnMovesFor(s, i)) out.push({ type: 'move', to });
for (const w of legalWallsFor(s, i)) out.push({ type: 'placeWall', c: w.c, r: w.r, o: w.o });
return out;
},
/** 완전 정보 게임: 숨길 패가 없다(RNG는 상태 밖, view의 힌트는 차례인 사람에게만 — 테스트에서 따로 확인). */
secretsOf() {
return [];
},
};

View File

@@ -0,0 +1,696 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import {
legalWallsFor,
pawnMovesFor,
quoridor,
type Cell,
type QuoridorAction,
type QuoridorOptions,
type QuoridorState,
type Seat,
} from './index';
import { cellLabel, emptyWallIndex, isGoal, pathLength, setWall, type Wall } from './board';
const rng = (n = 1) => SeededRng.fromSeed([n, n + 1, n + 2, n + 3]);
/** 'e5' → { c: 4, r: 4 } (열 a~i 왼→오, 행 1~9 아래→위). */
const at = (l: string): Cell => ({ c: 'abcdefghi'.indexOf(l[0]!), r: Number(l.slice(1)) - 1 });
/** 'h:d4' → 앵커 d4 (c=3, r=3)의 가로벽. */
const wall = (w: string): Wall => ({ o: w[0] as 'h' | 'v', ...at(w.slice(2)) });
/** joinOrder: 2인 A=남 B=북, 4인 A=남 B=서 C=북 D=동. */
function make(n: 2 | 4 = 2, opts: Partial<QuoridorOptions> = {}): QuoridorState {
const options = quoridor.optionsSchema.parse({ seatOrder: 'joinOrder', ...opts });
const players = n === 2 ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
return quoridor.setup({ players, options, rng: rng(), now: 0 });
}
/** 말 위치·벽·차례를 직접 정한 국면. */
function position(s: QuoridorState, pos: Partial<Record<Seat, string | null>>, walls: string[] = [], turn?: Seat): QuoridorState {
for (const x of s.seats) if (x.seat in pos) x.pos = pos[x.seat] ? at(pos[x.seat]!) : null;
s.walls = walls.map(wall);
s.wallIndex = emptyWallIndex();
for (const w of s.walls) setWall(s.wallIndex, w, true);
if (turn) s.turnIndex = s.seats.findIndex((x) => x.seat === turn);
return s;
}
function act(s: QuoridorState, actor: string, a: QuoridorAction, now = 1000, seed = 9): QuoridorState {
const v = quoridor.validate(s, actor, a);
if (!v.ok) throw new Error(v.reason);
return quoridor.apply(s, actor, a, { rng: rng(seed), now }).state;
}
const turnId = (s: QuoridorState) => s.seats[s.turnIndex]!.playerId;
const go = (s: QuoridorState, to: string, now = 1000) => act(s, turnId(s), { type: 'move', to: at(to) }, now);
const put = (s: QuoridorState, w: string, now = 1000) => act(s, turnId(s), { type: 'placeWall', ...wall(w) }, now);
const tryGo = (s: QuoridorState, to: string) => quoridor.validate(s, turnId(s), { type: 'move', to: at(to) });
const tryPut = (s: QuoridorState, w: string, actor = turnId(s)) => quoridor.validate(s, actor, { type: 'placeWall', ...wall(w) });
const moves = (s: QuoridorState) => pawnMovesFor(s, s.turnIndex).map(cellLabel).sort();
const seatOf = (s: QuoridorState, seat: Seat) => s.seats.find((x) => x.seat === seat)!;
const reason = (v: { ok: boolean; reason?: string }) => (v.ok ? 'ok' : v.reason);
describe('setup', () => {
test('2인: 남 e1 → 9행, 북 e9 → 1행, 남 먼저, 벽 10개씩', () => {
const s = make(2);
expect(s.seats.map((x) => [x.seat, x.playerId, cellLabel(x.pos!), x.wallsLeft])).toEqual([
['S', 'A', 'e1', 10],
['N', 'B', 'e9', 10],
]);
expect(turnId(s)).toBe('A');
expect(isGoal('S', 0, 8) && isGoal('N', 8, 0) && !isGoal('S', 4, 7)).toBe(true);
expect(quoridor.view(s, null).seats.map((x) => x.distance)).toEqual([8, 8]);
});
test('4인: 남 e1, 서 a5, 북 e9, 동 i5, 시계 방향 차례, 벽 5개씩; 하우스 룰 2인 벽 수', () => {
let s = make(4);
expect(s.seats.map((x) => `${x.seat}${cellLabel(x.pos!)}${x.wallsLeft}`)).toEqual(['Se15', 'Wa55', 'Ne95', 'Ei55']);
expect(isGoal('W', 8, 3) && isGoal('E', 0, 3)).toBe(true);
s = go(s, 'e2');
expect(turnId(s)).toBe('B');
s = go(s, 'b5');
expect(turnId(s)).toBe('C');
s = go(s, 'e8');
expect(turnId(s)).toBe('D');
s = go(s, 'h5');
expect(turnId(s)).toBe('A');
expect(make(2, { wallsPerPlayer: 6 }).seats[0]!.wallsLeft).toBe(6);
expect(make(4, { wallsPerPlayer: 6 }).seats[0]!.wallsLeft).toBe(5);
});
test('3인은 시작할 수 없어요', () => {
expect(quoridor.playerCountError!(quoridor.defaultOptions, 3)).toBe('2명 또는 4명이 필요해요.');
expect(quoridor.playerCountError!(quoridor.defaultOptions, 2)).toBeNull();
expect(quoridor.playerCountError!(quoridor.defaultOptions, 4)).toBeNull();
expect(() => quoridor.setup({ players: ['A', 'B', 'C'], options: quoridor.defaultOptions, rng: rng(), now: 0 })).toThrow('2명 또는 4명');
});
test('random 자리 배정은 RNG로, 같은 시드면 같은 결과', () => {
const seats = (n: number) => quoridor.setup({ players: ['A', 'B', 'C', 'D'], options: quoridor.defaultOptions, rng: rng(n), now: 0 }).seats.map((x) => x.playerId).join('');
expect(seats(3)).toBe(seats(3));
expect(new Set([1, 2, 3, 4, 5, 6, 7, 8].map(seats)).size).toBeGreaterThan(1);
});
});
describe('pawn moves (§5.2)', () => {
test('기본 이동 4방향; 벽·판 끝·다른 말 때문에 막힘', () => {
let s = position(make(), { S: 'e5', N: 'e9' });
expect(moves(s)).toEqual(['d5', 'e4', 'e6', 'f5']);
s = position(make(), { S: 'e5', N: 'e9' }, ['h:e5']); // e5↔e6, f5↔f6 차단
expect(moves(s)).toEqual(['d5', 'e4', 'f5']);
expect(reason(tryGo(s, 'e6'))).toBe('벽이 가로막고 있어요.');
s = position(make(), { S: 'a1', N: 'e9' });
expect(moves(s)).toEqual(['a2', 'b1']);
s = position(make(), { S: 'e5', N: 'e9' }, ['v:d4']); // d4↔e4 … 세로벽 d4: (d4|e4), (d5|e5)
expect(moves(s)).toEqual(['e4', 'e6', 'f5']);
expect(reason(tryGo(s, 'd5'))).toBe('벽이 가로막고 있어요.');
expect(reason(tryGo(s, 'e7'))).toBe('한 번에 한 칸씩만 움직일 수 있어요.');
expect(reason(tryGo(s, 'f6'))).toBe('대각선으로는 상대 말을 넘을 때만 갈 수 있어요.');
expect(reason(tryGo(s, 'e9'))).toBe('다른 말이 있는 칸이에요.');
});
test('직선 점프 성공', () => {
const s = position(make(), { S: 'e5', N: 'e6' });
expect(moves(s)).toEqual(['d5', 'e4', 'e7', 'f5']);
expect(seatOf(go(s, 'e7'), 'S').pos).toEqual(at('e7'));
});
test('점프할 칸 뒤가 벽 / 판 끝 / 다른 말이면 대각 2곳, 한쪽이 벽이면 1곳', () => {
// 뒤가 벽
let s = position(make(), { S: 'e5', N: 'e6' }, ['h:d6']); // d6↔d7, e6↔e7 차단
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
// 뒤가 판 끝
s = position(make(), { S: 'e8', N: 'e9' });
expect(moves(s)).toEqual(['d8', 'd9', 'e7', 'f8', 'f9']);
// 뒤가 다른 말(4인)
s = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
// 대각 한쪽이 벽: 북 말과 d6 사이 세로벽(d6|e6)
expect(reason(tryPut(position(make(), { S: 'e5', N: 'e6' }, ['h:d6']), 'v:d6'))).toBe('다른 벽과 겹쳐요.');
s = position(make(), { S: 'e5', N: 'e6' }, ['h:e6', 'v:d6']); // e6↔e7, f6↔f7 / (d6|e6),(d7|e7)
expect(moves(s)).toEqual(['d5', 'e4', 'f5', 'f6']);
expect(reason(tryGo(s, 'd6'))).toBe('벽이 가로막고 있어요.');
});
test('wallOnly: 판 끝·다른 말 뒤로는 대각 불가, 벽 뒤로는 가능', () => {
let s = position(make(2, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e8', N: 'e9' });
expect(moves(s)).toEqual(['d8', 'e7', 'f8']);
expect(reason(tryGo(s, 'd9'))).toBe('이 방은 넘을 말 뒤에 벽이 있을 때만 대각선으로 갈 수 있어요.');
s = position(make(4, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
expect(moves(s)).toEqual(['d5', 'e4', 'f5']);
s = position(make(2, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e5', N: 'e6' }, ['h:d6']);
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
});
test('직선 점프가 되면 대각은 거부, 말 2개 연속 점프 거부', () => {
let s = position(make(), { S: 'e5', N: 'e6' });
expect(reason(tryGo(s, 'd6'))).toBe('곧게 뛰어넘을 수 있을 때는 대각선으로 갈 수 없어요.');
s = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
expect(reason(tryGo(s, 'e8'))).toBe('말은 한 번에 하나만 뛰어넘을 수 있어요.');
});
test('판에서 빠진 말은 장애물이 아니에요', () => {
const s = position(make(4), { S: 'e5', N: 'e6', W: null, E: 'i1' }, [], 'S');
seatOf(s, 'W').status = 'resigned';
expect(moves(s)).toEqual(['d5', 'e4', 'e7', 'f5']);
});
});
describe('walls (§5.3)', () => {
test('벽 설치: 개수 감소, 기록, 차례 넘김', () => {
const s = put(make(), 'h:e5');
expect(s.walls).toEqual([wall('h:e5')]);
expect(seatOf(s, 'S').wallsLeft).toBe(9);
expect(s.moves.at(-1)).toEqual({ kind: 'wall', seat: 'S', wall: wall('h:e5') });
expect(turnId(s)).toBe('B');
});
test('겹침: 같은 방향 반 칸 겹침·같은 앵커 십자 거부, 다른 앵커의 T자·끝 맞닿음은 허용', () => {
const s = position(make(), { S: 'e1', N: 'e9' }, ['h:d4']);
for (const w of ['h:c4', 'h:d4', 'h:e4', 'v:d4']) expect(reason(tryPut(s, w))).toBe('다른 벽과 겹쳐요.');
expect(reason(tryPut(s, 'h:b4'))).toBe('ok'); // 끝이 맞닿는 이웃
expect(reason(tryPut(s, 'v:d5'))).toBe('ok'); // T자 (d4 앵커 가로벽의 가운데에 세로벽 끝)
expect(reason(tryPut(s, 'v:e4'))).toBe('ok'); // 오른쪽 끝에 세로벽 가운데(ㅓ 모양)
const v = position(make(), { S: 'e1', N: 'e9' }, ['v:d4']);
for (const w of ['v:d3', 'v:d4', 'v:d5', 'h:d4']) expect(reason(tryPut(v, w))).toBe('다른 벽과 겹쳐요.');
expect(reason(tryPut(v, 'v:d6'))).toBe('ok');
expect(reason(tryPut(v, 'h:c5'))).toBe('ok');
});
test('판 밖 앵커(0~7 밖) 거부, 남은 벽 0개면 거부', () => {
const s = make();
for (const [c, r] of [
[-1, 0],
[8, 0],
[0, 8],
[0, -1],
]) {
expect(reason(quoridor.validate(s, 'A', { type: 'placeWall', c: c!, r: r!, o: 'h' }))).toBe('벽은 판 밖으로 나갈 수 없어요.');
}
expect(quoridor.validate(s, 'A', { type: 'placeWall', c: 7, r: 7, o: 'v' }).ok).toBe(true);
seatOf(s, 'S').wallsLeft = 0;
expect(reason(tryPut(s, 'h:a1'))).toBe('남은 벽이 없어요.');
expect(legalWallsFor(s, 0)).toEqual([]);
});
// 북(e9) 주변 상자: e8·e9를 좌우 세로벽으로 감싸고 e7↔e8 가로벽으로 닫으면 북은 1행에 못 간다.
const box = ['v:d8', 'v:e8'];
test('상대 길을 완전히 막는 벽 거부, 자기 길을 막는 벽 거부', () => {
const s = position(make(), { S: 'a1', N: 'e9' }, box, 'S');
expect(reason(tryPut(s, 'h:e7'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
const n = position(make(), { S: 'a1', N: 'e9' }, box, 'N');
expect(reason(tryPut(n, 'h:e7'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
expect(reason(tryPut(n, 'h:a7'))).toBe('ok');
});
test('4인에서 제3자(서)의 길을 막는 벽 거부', () => {
const s = position(make(4), {}, ['h:a5', 'h:a4'], 'S'); // 서 a5·b5를 위아래로 감쌈
expect(reason(tryPut(s, 'v:b5'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
expect(legalWallsFor(s, 0).some((w) => w.o === 'v' && w.c === 1 && w.r === 4)).toBe(false);
});
test('말 때문에만 막히는 경우는 허용(경로 검사는 벽만 봄)', () => {
// 북의 유일한 출구 e7을 남의 말이 막고 있어도 벽 설치는 합법.
const s = position(make(), { S: 'e7', N: 'e9' }, box, 'S');
expect(reason(tryPut(s, 'h:a1'))).toBe('ok');
expect(pathLength(s.wallIndex, at('e9'), 'N')).toBe(8);
});
test('경로 검사에서 기권한 사람은 빠져요', () => {
let s = position(make(4), {}, ['h:a5', 'h:a4'], 'S');
s = act(s, 'B', { type: 'resign' });
expect(seatOf(s, 'W').pos).toBeNull();
expect(reason(tryPut(s, 'v:b5'))).toBe('ok');
});
test('legalWalls 미리보기: 상대 거리 변화량', () => {
const s = make();
const lw = quoridor.view(s, 'A').legalWalls!;
expect(lw.length).toBe(128);
// e8↔e9·f8↔f9 가로벽은 북과 남의 길을 모두 1칸 늘린다(둘 다 e열을 지나므로).
expect(lw.find((w) => w.o === 'h' && w.c === 4 && w.r === 7)).toMatchObject({ opp: 1, me: 1 });
});
});
describe('goal and game end (§6)', () => {
test('목표 줄 도착 즉시 승리', () => {
const s = go(position(make(), { S: 'c8', N: 'a5' }), 'c9');
expect(s.phase).toBe('finished');
expect(s.result).toMatchObject({ reason: 'reachedGoal' });
expect(quoridor.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal' });
expect(s.result!.summaryKo).toBe('남 승리 — 1수 만에 도착');
expect(s.result!.perPlayer.map((p) => p.distanceLeft)).toEqual([0, 4]);
});
test('점프로 도착해도 승리', () => {
const s = go(position(make(), { S: 'e7', N: 'e8' }), 'e9');
expect(s.result?.ranking[0]).toEqual({ playerId: 'A', rank: 1 });
});
test('북도 1행에 닿으면 승리', () => {
let s = position(make(), { S: 'a1', N: 'e2' }, [], 'N');
s = go(s, 'e1');
expect(quoridor.result(s)!.ranking).toEqual([['B'], ['A']]);
});
test('4인 결과 순위: 도착자 1등, 나머지는 남은 거리 순, 같으면 공동', () => {
let s = position(make(4), { S: 'e8', W: 'f5', N: 'c4', E: 'f3' }, [], 'S');
s = go(s, 'e9');
expect(s.result!.ranking).toEqual([
{ playerId: 'A', rank: 1 },
{ playerId: 'B', rank: 2 },
{ playerId: 'C', rank: 2 },
{ playerId: 'D', rank: 4 },
]);
expect(quoridor.result(s)!.ranking).toEqual([['A'], ['B', 'C'], ['D']]);
});
test('4인 기권: 말 제거, 남은 벽 소멸, 설치한 벽 유지, 1명 남으면 그 사람 1등', () => {
let s = make(4);
s = put(s, 'h:a1'); // 남
s = put(s, 'h:c1'); // 서
s = act(s, 'B', { type: 'resign' });
expect(seatOf(s, 'W')).toMatchObject({ pos: null, wallsLeft: 0, status: 'resigned' });
expect(s.walls).toHaveLength(2);
expect(turnId(s)).toBe('C');
s = act(s, 'C', { type: 'resign' }); // 차례인 사람이 기권 → 다음(동)
expect(turnId(s)).toBe('D');
s = act(s, 'A', { type: 'resign' });
expect(s.phase).toBe('finished');
expect(quoridor.result(s)).toMatchObject({ ranking: [['D'], ['A'], ['C'], ['B']], reason: 'resign' });
expect(s.result!.summaryKo).toBe('다른 사람이 모두 기권해 동 승리');
});
test('2인 기권은 상대 승', () => {
const s = act(make(), 'A', { type: 'resign' });
expect(quoridor.result(s)).toMatchObject({ ranking: [['B'], ['A']], reason: 'resign', summary: '남 기권으로 북 승리' });
expect(quoridor.onLeave!(s, 'B')).toBeNull();
expect(quoridor.onLeave!(make(), 'A')).toEqual({ type: 'resign' });
});
test('continueForRanking: 도착자를 판에서 빼고 계속, 도착 순서대로 순위', () => {
let s = position(make(4, { continueForRanking: true }), { S: 'e8', W: 'h5', N: 'c2', E: 'f3' }, [], 'S');
s = go(s, 'e9');
expect(s.phase).toBe('playing');
expect(seatOf(s, 'S')).toMatchObject({ status: 'finished', finishOrder: 1, pos: null });
expect(turnId(s)).toBe('B');
s = go(s, 'i5'); // 서 도착
expect(turnId(s)).toBe('C');
expect(quoridor.validate(s, 'A', { type: 'resign' }).ok).toBe(false);
s = go(s, 'c1'); // 북 도착 → 동만 남음
expect(s.phase).toBe('finished');
expect(quoridor.result(s)!.ranking).toEqual([['A'], ['B'], ['C'], ['D']]);
expect(s.result!.summaryKo).toBe('남 → 서 → 북 순서로 도착');
});
test('무승부 합의: 남은 사람 모두 동의해야, 다음 사람이 두면 자동 취소', () => {
let s = act(make(4), 'B', { type: 'offerDraw' });
expect(quoridor.activePlayers(s)).toEqual(['A', 'C', 'D']);
s = act(s, 'A', { type: 'respondDraw', accept: true });
s = act(s, 'C', { type: 'respondDraw', accept: true });
expect(s.phase).toBe('playing');
const moved = go(s, 'e2');
expect(moved.draw.pendingBy).toBeNull();
expect(reason(quoridor.validate(moved, 'B', { type: 'offerDraw' }))).toBe('무승부 제안은 한 번 더 둔 뒤에 다시 할 수 있어요.');
s = act(s, 'D', { type: 'respondDraw', accept: true });
expect(quoridor.result(s)).toMatchObject({ ranking: [['A', 'B', 'C', 'D']], reason: 'draw-agreed', summary: '합의 무승부' });
const declined = act(act(make(), 'A', { type: 'offerDraw' }), 'B', { type: 'respondDraw', accept: false });
expect(declined.draw.pendingBy).toBeNull();
expect(quoridor.validate(make(2, { drawOffer: false }), 'A', { type: 'offerDraw' }).ok).toBe(false);
});
});
describe('undo', () => {
test('2인: 상대가 이미 뒀으면 2개 되돌림(벽 개수 포함), 거절하면 그대로, 횟수 제한', () => {
let s = put(make(2, { undo: '1' }), 'h:e5'); // 남 벽
s = go(s, 'e8'); // 북 이동
s = act(s, 'A', { type: 'requestUndo' });
expect(s.undo.pending).toEqual({ by: 'S', accepted: [], plies: 2 });
expect(reason(quoridor.validate(s, 'A', { type: 'respondUndo', accept: true }))).toBe('응답할 무르기 요청이 없어요.');
const no = act(s, 'B', { type: 'respondUndo', accept: false });
expect(no.walls).toHaveLength(1);
expect(no.undo.pending).toBeNull();
s = act(s, 'B', { type: 'respondUndo', accept: true });
expect(s.walls).toEqual([]);
expect(seatOf(s, 'S').wallsLeft).toBe(10);
expect(seatOf(s, 'N').pos).toEqual(at('e9'));
expect(turnId(s)).toBe('A');
expect(s.moves).toEqual([]);
s = go(s, 'e2');
expect(reason(quoridor.validate(s, 'A', { type: 'requestUndo' }))).toBe('무르기 횟수를 다 썼어요.');
expect(quoridor.validate(make(2, { undo: 'off' }), 'A', { type: 'requestUndo' }).ok).toBe(false);
expect(reason(quoridor.validate(make(), 'A', { type: 'requestUndo' }))).toBe('아직 무를 수가 없어요.');
});
test('4인: 다른 3명 전원 수락 시에만, 다음 사람이 행동한 뒤에는 요청 불가', () => {
let s = go(make(4), 'e2');
s = act(s, 'A', { type: 'requestUndo' });
expect(quoridor.activePlayers(s)).toEqual(['B', 'C', 'D']);
s = act(s, 'B', { type: 'respondUndo', accept: true });
s = act(s, 'C', { type: 'respondUndo', accept: true });
expect(seatOf(s, 'S').pos).toEqual(at('e2'));
expect(reason(quoridor.validate(s, 'C', { type: 'respondUndo', accept: true }))).toBe('응답할 무르기 요청이 없어요.');
const declined = act(s, 'D', { type: 'respondUndo', accept: false });
expect(seatOf(declined, 'S').pos).toEqual(at('e2'));
expect(declined.undo.pending).toBeNull();
s = act(s, 'D', { type: 'respondUndo', accept: true });
expect(seatOf(s, 'S').pos).toEqual(at('e1'));
expect(turnId(s)).toBe('A');
expect(s.undo.usedBy.S).toBe(1);
// 다음 사람(서)이 행동한 뒤에는 남이 무를 수 없다.
s = go(go(s, 'e2'), 'b5');
expect(reason(quoridor.validate(s, 'A', { type: 'requestUndo' }))).toBe('방금 내가 한 행동만, 다음 사람이 두기 전에 무를 수 있어요.');
expect(quoridor.validate(s, 'B', { type: 'requestUndo' }).ok).toBe(true);
});
});
describe('forced pass (§5.4)', () => {
// 4인: 남 a1, 서 a2(위), 북 a3(서 뒤), 동 b2. 세로벽 a1|b1·a2|b2. 남은 벽이 없으면 말·벽 모두 불가.
const stuck = (wallsLeft: number) => {
const s = position(make(4), { S: 'a1', W: 'a2', N: 'a3', E: 'b2' }, ['v:a1'], 'E');
seatOf(s, 'S').wallsLeft = wallsLeft;
return s;
};
test('말 이동도 벽도 불가하면 자동 패스', () => {
const s0 = stuck(0);
expect(pawnMovesFor(s0, 0)).toEqual([]);
const s = go(s0, 'c2'); // 동이 움직여도 남은 여전히 갇힘
expect(s.moves.slice(-2)).toEqual([
{ kind: 'move', seat: 'E', from: at('b2'), to: at('c2'), auto: false },
{ kind: 'pass', seat: 'S' },
]);
expect(turnId(s)).toBe('B');
});
test('벽이 남아 있으면 패스하지 않음', () => {
const s = go(stuck(1), 'c2');
expect(turnId(s)).toBe('A');
expect(quoridor.view(s, 'A').legalPawnMoves).toEqual([]);
expect(quoridor.view(s, 'A').legalWalls!.length).toBeGreaterThan(0);
});
});
describe('time (§8.5)', () => {
test('autoMove: 최단 거리가 줄어드는 칸으로 이동', () => {
const s = act(make(), 'A', { type: 'timeout' });
expect(seatOf(s, 'S').pos).toEqual(at('e2'));
expect(s.moves[0]).toMatchObject({ kind: 'move', auto: true });
expect(s.clock.autoMoveStreak.S).toBe(1);
});
test('autoMove 동률은 RNG: 같은 시드 같은 결과, 시드에 따라 둘 다 나옴', () => {
// 남 e5 앞에 북 e6, 그 뒤에 서 e7 → 대각 d6·f6 모두 거리 3.
const base = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
const pick = (seed: number) => cellLabel(seatOf(act(base, 'A', { type: 'timeout' }, 1000, seed), 'S').pos!);
const seen = new Set<string>();
for (let seed = 1; seed <= 20; seed++) {
expect(pick(seed)).toBe(pick(seed));
seen.add(pick(seed));
}
expect([...seen].sort()).toEqual(['d6', 'f6']);
});
test('3번 연속 시간 초과면 시간패, 직접 두면 연속 횟수 초기화', () => {
let s = act(make(), 'A', { type: 'timeout' });
s = go(s, 'e8');
s = act(s, 'A', { type: 'timeout' });
s = go(s, 'e7');
const reset = go(s, 'd3');
expect(reset.clock.autoMoveStreak.S).toBe(0);
s = act(s, 'A', { type: 'timeout' });
expect(s.phase).toBe('finished');
expect(quoridor.result(s)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout', summary: '남 시간패로 북 승리' });
expect(seatOf(s, 'S').status).toBe('timedOut');
});
test('4인 시간패는 말 제거 후 다음 사람 차례', () => {
const s = act(make(4, { timeoutPolicy: 'lose' }), 'A', { type: 'timeout' });
expect(seatOf(s, 'S')).toMatchObject({ status: 'timedOut', pos: null, wallsLeft: 0 });
expect(turnId(s)).toBe('B');
expect(s.phase).toBe('playing');
});
test('lose 정책·idle 한도는 첫 시간 초과에 시간패; 차례인 사람만', () => {
expect(act(make(2, { timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).phase).toBe('finished');
expect(act(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }), 'A', { type: 'timeout' }).phase).toBe('finished');
expect(quoridor.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false);
let s = act(go(make(), 'e2'), 'A', { type: 'requestUndo' });
expect(quoridor.timeoutPlayers!(s)).toEqual(['B']);
s = act(s, 'B', { type: 'timeout' }); // 자동 이동이 요청을 지운다
expect(s.undo.pending).toBeNull();
});
test('deadline: perMove / none / fischer', () => {
expect(quoridor.deadline!(go(make(), 'e2', 7000))).toBe(37_000);
expect(quoridor.deadline!(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }))).toBe(300_000);
const tc = { kind: 'fischer' as const, perMoveSec: 30, baseSec: 60, incSec: 5 };
let s = make(2, { timeControl: tc });
expect(quoridor.deadline!(s)).toBe(60_000);
s = go(s, 'e2', 10_000);
expect(s.clock.remainingMs.S).toBe(55_000);
expect(quoridor.deadline!(s)).toBe(70_000);
const late = go(s, 'e8', 80_000);
expect(late.result).toMatchObject({ reason: 'timeLoss' });
expect(late.clock.remainingMs.N).toBe(0);
const t = act(s, 'B', { type: 'timeout' });
expect(quoridor.result(t)).toMatchObject({ ranking: [['A'], ['B']], reason: 'timeout' });
expect(quoridor.deadline!(t)).toBeNull();
});
});
describe('views (§8.3)', () => {
test('legalPawnMoves/legalWalls/myShortestPath는 차례인 사람에게만, rng 없음', () => {
const s = go(make(4), 'e2');
const v = quoridor.view(s, 'B');
expect(v.legalPawnMoves!.map(cellLabel).sort()).toEqual(['a4', 'a6', 'b5']);
expect(v.legalWalls!.length).toBe(128);
expect(v.myShortestPath!.length).toBe(8);
expect(v.mySeat).toBe('W');
for (const viewer of ['A', 'C', 'D', null]) {
const o = quoridor.view(s, viewer);
expect(o.legalPawnMoves).toBeNull();
expect(o.legalWalls).toBeNull();
expect(o.myShortestPath).toBeNull();
expect(o.seats.map((x) => x.distance)).toEqual([7, 8, 8, 8]);
}
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(quoridor.view(s, viewer))).not.toMatch(/rng/i);
expect(JSON.stringify(s)).not.toMatch(/rng/i);
expect(quoridor.view(make(2, { showPath: false }), 'A').myShortestPath).toBeNull();
expect(quoridor.view(s, null).mySeat).toBeNull();
});
test('성능: 합법 벽 목록(128 후보 × BFS 4회)이 1ms 수준', () => {
// 벽 몇 개가 있는 4인 중반 국면.
let s = make(4);
for (const w of ['h:a3', 'h:c3', 'v:e2', 'h:f6', 'v:b6', 'h:d7', 'v:g3', 'h:g1']) s = put(s, w);
const t0 = performance.now();
const N = 200;
let n = 0;
for (let k = 0; k < N; k++) n += legalWallsFor(s, s.turnIndex, true).length;
const ms = (performance.now() - t0) / N;
console.log(` legalWalls (4인, 변화량 포함): ${ms.toFixed(3)}ms / 회, 후보 ${n / N}개`);
expect(n / N).toBeGreaterThan(80);
expect(ms).toBeLessThan(5); // 부하가 있는 CI에서도 여유 있게
});
});
// ---------------------------------------------------------------- random playouts
const TOTAL_SEATS = (s: QuoridorState) => s.seats.length;
/** 매 단계 불변식: 벽 보존, 인덱스 일치, 겹침 없음, 남은 사람 모두 길 있음, 말 겹침 없음, 정보 누출 없음. */
function invariant(s: QuoridorState): void {
const per = s.seats.length === 4 ? 5 : s.options.wallsPerPlayer;
let placed = 0;
for (const x of s.seats) {
const mine = s.moves.filter((m) => m.kind === 'wall' && m.seat === x.seat).length;
placed += mine;
if (x.status === 'resigned' || x.status === 'timedOut') {
if (s.seats.length === 4 && x.wallsLeft !== 0) throw new Error('removed seat kept walls');
} else if (mine + x.wallsLeft !== per) throw new Error(`wall conservation broken for ${x.seat}`);
}
if (placed !== s.walls.length) throw new Error('walls list != wall moves');
const idx = emptyWallIndex();
for (const w of s.walls) {
if (w.c < 0 || w.c > 7 || w.r < 0 || w.r > 7) throw new Error('wall anchor out of range');
setWall(idx, w, true);
}
if (JSON.stringify(idx) !== JSON.stringify(s.wallIndex)) throw new Error('wallIndex out of sync');
const keys = new Set<string>();
for (const w of s.walls) {
// 같은 방향 반 칸 겹침, 같은 앵커 십자.
const k = w.o === 'h' ? [`h${w.c}${w.r}`, `h${w.c + 1}${w.r}`, `x${w.c}${w.r}`] : [`v${w.c}${w.r}`, `v${w.c}${w.r + 1}`, `x${w.c}${w.r}`];
for (const key of k) {
if (keys.has(key)) throw new Error('overlapping walls');
keys.add(key);
}
}
const cells = new Set<string>();
for (const x of s.seats) {
if (!x.pos) continue;
const k = cellLabel(x.pos);
if (cells.has(k)) throw new Error('two pawns on one cell');
cells.add(k);
if (x.status === 'playing' && pathLength(s.wallIndex, x.pos, x.seat) < 0) throw new Error(`${x.seat} has no path`);
}
if (s.phase === 'playing') {
const cur = s.seats[s.turnIndex]!;
if (cur.status !== 'playing') throw new Error('turn on a removed seat');
if (s.seats.filter((x) => x.status === 'playing').length < 2) throw new Error('game should have ended');
for (const p of quoridor.timeoutPlayers!(s)) {
const v = quoridor.validate(s, p, quoridor.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
}
} else if (!s.result || !quoridor.result(s)) throw new Error('finished without result');
}
function checkViews(s: QuoridorState): void {
for (const viewer of [...s.seats.map((x) => x.playerId), null]) {
const v = quoridor.view(s, viewer);
const mine = s.phase === 'playing' && viewer === s.seats[s.turnIndex]!.playerId;
if (!mine && (v.legalWalls !== null || v.legalPawnMoves !== null || v.myShortestPath !== null)) throw new Error('turn-only hint leaked');
if (/rng/i.test(JSON.stringify(v))) throw new Error('rng in view');
}
}
/** 빠른 무작위 대국: 최단 길 이동 / 무작위 이동 / 무작위 벽(거부 샘플링) / 가끔 시간 초과·무르기·무승부·기권. */
function fastPlayout(seed: number, n: 2 | 4, options: QuoridorOptions, checkEvery: number) {
const r = SeededRng.fromSeed(seedN(seed));
const pick = SeededRng.fromSeed(seedN(seed + 77_777));
let now = 1_700_000_000_000;
const players = n === 2 ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
let s = quoridor.setup({ players, options, rng: r, now });
const trace: { actor: string; action: QuoridorAction }[] = [];
const step = (actor: string, action: QuoridorAction) => {
const v = quoridor.validate(s, actor, action);
if (!v.ok) throw new Error(`seed ${seed}: rejected ${JSON.stringify(action)}: ${v.reason}`);
now += 1000;
s = quoridor.apply(s, actor, action, { rng: r, now }).state;
trace.push({ actor, action });
};
for (let k = 0; k < 3000 && s.phase === 'playing'; k++) {
if (k % checkEvery === 0) {
invariant(s);
checkViews(s);
if (JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
}
// 대기 중인 요청이 있으면 응답부터.
const pend = s.undo.pending ?? (s.draw.pendingBy ? { by: s.draw.pendingBy, accepted: s.draw.accepted } : null);
if (pend) {
const responder = s.seats.find((x) => x.status === 'playing' && x.seat !== pend.by && !pend.accepted.includes(x.seat));
if (responder) {
const accept = pick.next() < 0.7;
step(responder.playerId, s.undo.pending ? { type: 'respondUndo', accept } : { type: 'respondDraw', accept: accept && pick.next() < 0.3 });
continue;
}
}
const i = s.turnIndex;
const me = s.seats[i]!;
const roll = pick.next();
if (roll < 0.03) {
step(me.playerId, { type: 'timeout' });
continue;
}
if (roll < 0.04) {
const any = s.seats.filter((x) => x.status === 'playing')[pick.int(s.seats.filter((x) => x.status === 'playing').length)]!;
const a: QuoridorAction = pick.next() < 0.5 ? { type: 'requestUndo' } : { type: 'offerDraw' };
if (quoridor.validate(s, any.playerId, a).ok) {
step(any.playerId, a);
continue;
}
}
if (roll < 0.042 && n === 4) {
step(s.seats.filter((x) => x.status === 'playing')[0]!.playerId, { type: 'resign' });
continue;
}
if (roll < 0.4 && me.wallsLeft > 0) {
let placed = false;
for (let t = 0; t < 12 && !placed; t++) {
const a: QuoridorAction = { type: 'placeWall', c: pick.int(8), r: pick.int(8), o: pick.int(2) ? 'h' : 'v' };
if (quoridor.validate(s, me.playerId, a).ok) {
step(me.playerId, a);
placed = true;
}
}
if (placed) continue;
}
const cands = pawnMovesFor(s, i);
if (cands.length === 0) {
const lw = legalWallsFor(s, i);
step(me.playerId, lw.length ? { type: 'placeWall', c: lw[0]!.c, r: lw[0]!.r, o: lw[0]!.o } : { type: 'timeout' });
continue;
}
let to = cands[pick.int(cands.length)]!;
if (pick.next() < 0.6) {
let best = Infinity;
for (const c of cands) {
const d = pathLength(s.wallIndex, c, me.seat);
if (d >= 0 && d < best) {
best = d;
to = c;
}
}
}
step(me.playerId, { type: 'move', to });
}
invariant(s);
return { state: s, trace, players };
}
describe('random playouts (docs/09 §7)', () => {
test('1,000판 무작위 대국: 끝까지, 불변식, 시간 초과 합법, 정보 누출 없음', () => {
const t0 = performance.now();
let finished = 0;
let steps = 0;
const reasons: Record<string, number> = {};
for (let i = 0; i < 1000; i++) {
const n = i % 3 === 0 ? 4 : 2;
const options = quoridor.optionsSchema.parse({
seatOrder: i % 2 ? 'random' : 'joinOrder',
diagonalJumpTrigger: i % 5 === 0 ? 'wallOnly' : 'wallEdgePawn',
continueForRanking: i % 6 === 0,
wallsPerPlayer: 6 + (i % 5),
undo: i % 7 === 0 ? 'unlimited' : '3',
timeControl: i % 11 === 0 ? { kind: 'fischer', perMoveSec: 30, baseSec: 300, incSec: 3 } : { kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 },
});
const { state, trace } = fastPlayout(i, n, options, i < 100 ? 1 : 7);
if (state.phase === 'finished') finished++;
steps += trace.length;
reasons[state.result?.reason ?? 'none'] = (reasons[state.result?.reason ?? 'none'] ?? 0) + 1;
}
console.log(` 1000 playouts: ${finished} finished, avg ${(steps / 1000).toFixed(1)} actions, ${(performance.now() - t0).toFixed(0)}ms`, reasons);
expect(finished).toBe(1000);
expect(reasons.reachedGoal).toBeGreaterThan(500);
}, 60_000);
test('결정성: 같은 시드·같은 행동이면 같은 결과, 기록 재생도 같은 상태', () => {
for (let i = 0; i < 40; i++) {
const n = i % 2 ? 4 : 2;
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 4 === 1 });
const a = fastPlayout(5000 + i, n, options, 1000);
const b = fastPlayout(5000 + i, n, options, 1000);
expect(JSON.stringify(a.state)).toBe(JSON.stringify(b.state));
const r = SeededRng.fromSeed(seedN(5000 + i));
let now = 1_700_000_000_000;
let s = quoridor.setup({ players: a.players, options, rng: r, now });
for (const { actor, action } of a.trace) {
now += 1000;
s = quoridor.apply(s, actor, action, { rng: r, now }).state;
}
expect(JSON.stringify(s)).toBe(JSON.stringify(a.state));
}
}, 60_000);
test('엔진 randomPlayout(완전 무작위 합법 행동)으로도 끝까지', () => {
for (let i = 0; i < 24; i++) {
const players = i % 3 === 0 ? ['A', 'B', 'C', 'D'] : ['A', 'B'];
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 2 === 0, wallsPerPlayer: 6 });
const inv = (st: unknown) => {
const s = st as QuoridorState;
if (TOTAL_SEATS(s) !== players.length) throw new Error('seat count changed');
invariant(s);
};
const rep = randomPlayout(quoridor, { players, options, seed: seedN(900 + i), invariant: inv, maxSteps: 20_000, maxApplyMs: 50 });
expect(rep.finished).toBe(true);
expect(quoridor.result(rep.finalState as QuoridorState)).not.toBeNull();
}
}, 60_000);
});