- 규칙 모듈 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>
839 lines
33 KiB
TypeScript
839 lines
33 KiB
TypeScript
/** 벽 쌓기 길찾기 (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 [];
|
||
},
|
||
};
|