M7: 쿼리도(벽 쌓기 길찾기) 규칙·화면·테스트
- 규칙 모듈 packages/games/src/quoridor (id 'quoridor', 표시 이름 '벽 쌓기 길찾기'): 2인/4인(3인 거부, playerCountError), 직선·대각 점프(wallEdgePawn/wallOnly), 벽 겹침·판 밖·남은 벽·BFS 길 막기 검사, 도착 즉시 승리, 4인 순위(남은 거리, 공동 순위), continueForRanking, 기권·시간패(4인은 말 제거·남은 벽 소멸), 자동 패스, 무르기(2인 최대 2개 / 4인 다음 사람 전, 전원 동의)·무승부, perMove/fischer/none 시계, autoMove(최단 거리 칸, 동률 RNG, 연속 한도 넘으면 시간패). view는 차례인 사람에게만 legalPawnMoves·legalWalls(거리 변화량 포함)·myShortestPath. - 테스트: 문서 §10 시나리오 전부, 1,000판 빠른 무작위 대국(불변식·정보 누출·onTimeout 합법), 결정성·기록 재생, 엔진 randomPlayout 24판, 합법 벽 목록 성능(약 1.2ms). - 화면 apps/web/src/games/quoridor: 내 출발 쪽이 아래로 오게 판 회전, 홈 교차점 스냅 → 미리보기(불가하면 빨간색+이유) → 확인, 가로/세로 전환, 키보드(방향키·R·W·Enter), 이동 가능 칸·최단 길 점선·목표 줄 색 띠·남은 벽 막대, 무르기/무승부/기권, 규칙 그림. - 등록: GAMES, CATALOG, 웹 registry, GAME_ICON. E2E quoridor 플랜(PC 방장+휴대폰 친구). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { gostop } from './gostop';
|
||||
import { bang } from './bang';
|
||||
import { halliGalli } from './halli-galli';
|
||||
import { marble } from './marble';
|
||||
import { quoridor } from './quoridor';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -35,4 +36,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[bang.id]: bang,
|
||||
[halliGalli.id]: halliGalli,
|
||||
[marble.id]: marble,
|
||||
[quoridor.id]: quoridor,
|
||||
};
|
||||
|
||||
222
packages/games/src/quoridor/board.ts
Normal file
222
packages/games/src/quoridor/board.ts
Normal 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 '그 칸으로는 갈 수 없어요.';
|
||||
}
|
||||
838
packages/games/src/quoridor/index.ts
Normal file
838
packages/games/src/quoridor/index.ts
Normal 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 [];
|
||||
},
|
||||
};
|
||||
696
packages/games/src/quoridor/quoridor.test.ts
Normal file
696
packages/games/src/quoridor/quoridor.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user