Files
joke-app/packages/games/src/quoridor/index.ts
EJClaw 1fc4d8c7fa 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>
2026-10-05 14:52:20 +09:00

839 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 벽 쌓기 길찾기 (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 [];
},
};