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:
EJClaw
2026-10-05 14:52:20 +09:00
parent 559fb8507c
commit 1fc4d8c7fa
9 changed files with 2811 additions and 0 deletions

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 [];
},
};