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:
936
apps/web/src/games/quoridor/index.tsx
Normal file
936
apps/web/src/games/quoridor/index.tsx
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
/** 벽 쌓기 길찾기 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Cell, QuoridorLegalWall, QuoridorMove, QuoridorOptions, QuoridorSeatView, QuoridorView, Seat, Wall } from '@bg/games/quoridor';
|
||||||
|
import type { PublicUser } from '@bg/shared/lite';
|
||||||
|
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||||
|
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||||
|
import { haptic, sounds } from '../../lib/feedback';
|
||||||
|
import { toast } from '../../store/toast';
|
||||||
|
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||||
|
|
||||||
|
type O = 'h' | 'v';
|
||||||
|
interface Pt {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
const N = 9;
|
||||||
|
const G = 0.2; // 홈(groove) 폭
|
||||||
|
const PAD = 0.78;
|
||||||
|
const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
|
||||||
|
const SEAT_COLOR: Record<Seat, string> = { S: '#dc2626', W: '#16a34a', N: '#2563eb', E: '#d97706' };
|
||||||
|
const COLOR_KO: Record<Seat, string> = { S: '빨강', W: '초록', N: '파랑', E: '주황' };
|
||||||
|
const SIDE_KO = { top: '맨 위', bottom: '맨 아래', left: '맨 왼쪽', right: '맨 오른쪽' } as const;
|
||||||
|
type Side = keyof typeof SIDE_KO;
|
||||||
|
const label = (p: Cell) => `${'abcdefghi'[p.c]}${p.r + 1}`;
|
||||||
|
const wallKey = (w: Wall) => `${w.o}:${w.c},${w.r}`;
|
||||||
|
/** 가로·세로는 보는 사람 화면 기준(서·동 자리는 판이 90° 돌아가 있음). */
|
||||||
|
const wallText = (w: Wall, swap = false) => `${(w.o === 'h') !== swap ? '가로' : '세로'} 벽 ${label(w)}`;
|
||||||
|
const swapFor = (seat: Seat | null) => seat === 'W' || seat === 'E';
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.qd-pawn { transition: transform 220ms ease-out; }
|
||||||
|
.qd-wall-new { animation: qd-drop 260ms ease-out both; transform-box: fill-box; transform-origin: center; }
|
||||||
|
.qd-turn-ring { animation: qd-pulse 1.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||||
|
@keyframes qd-drop { 0% { transform: scale(1.35); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||||
|
@keyframes qd-pulse { 0%, 100% { opacity: 0.95; } 50% { opacity: 0.35; } }
|
||||||
|
.qd-board * { pointer-events: none; }
|
||||||
|
.qd-board [data-cell], .qd-board [data-anchor] { pointer-events: auto; }
|
||||||
|
.qd-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.qd-modes .btn { min-height: 52px; padding-inline: 8px; white-space: nowrap; font-size: 1rem; }
|
||||||
|
.qd-modes .btn[aria-pressed='true'] { box-shadow: inset 0 0 0 3px var(--focus); }
|
||||||
|
.qd-tray { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 52px; }
|
||||||
|
.qd-tray .btn { min-height: 48px; }
|
||||||
|
.qd-hint { flex: 1 1 100%; min-width: 0; font-weight: 700; }
|
||||||
|
.qd-tray .qd-confirm { flex: 2 1 0; }
|
||||||
|
.qd-tray .qd-rotate { flex: 1 1 0; }
|
||||||
|
.qd-hint.bad { color: var(--danger); }
|
||||||
|
.qd-bars { display: inline-flex; gap: 2px; align-items: flex-end; }
|
||||||
|
.qd-bars i { display: inline-block; width: 4px; height: 16px; border-radius: 2px; background: var(--warning); }
|
||||||
|
.qd-bars i.used { background: var(--border); opacity: 0.6; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.qd-pawn { transition: none; }
|
||||||
|
.qd-wall-new, .qd-turn-ring { animation: none; }
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** 판 돌리기: 모델 좌표(x 오른쪽, y 위쪽, 0~9) ↔ 화면 좌표(y 아래쪽). 네 가지 모두 자기 자신이 역변환. */
|
||||||
|
function makeTf(seat: Seat | null) {
|
||||||
|
const s = seat ?? 'S';
|
||||||
|
const tf = (x: number, y: number): [number, number] =>
|
||||||
|
s === 'S' ? [x, N - y] : s === 'N' ? [N - x, y] : s === 'W' ? [N - y, N - x] : [y, x];
|
||||||
|
const swap = s === 'W' || s === 'E';
|
||||||
|
return {
|
||||||
|
swap,
|
||||||
|
cellToScreen: (p: Cell): Pt => {
|
||||||
|
const [x, y] = tf(p.c + 0.5, p.r + 0.5);
|
||||||
|
return { x: Math.floor(x), y: Math.floor(y) };
|
||||||
|
},
|
||||||
|
screenToCell: (p: Pt): Cell => {
|
||||||
|
const [x, y] = tf(p.x + 0.5, p.y + 0.5);
|
||||||
|
return { c: Math.floor(x), r: Math.floor(y) };
|
||||||
|
},
|
||||||
|
/** 화면 앵커(0~7)와 화면 방향. */
|
||||||
|
wallToScreen: (w: Wall): { a: Pt; o: O } => {
|
||||||
|
const [x, y] = tf(w.c + 1, w.r + 1);
|
||||||
|
return { a: { x: Math.round(x) - 1, y: Math.round(y) - 1 }, o: swap ? (w.o === 'h' ? 'v' : 'h') : w.o };
|
||||||
|
},
|
||||||
|
screenToWall: (a: Pt, o: O): Wall => {
|
||||||
|
const [x, y] = tf(a.x + 1, a.y + 1);
|
||||||
|
return { c: Math.round(x) - 1, r: Math.round(y) - 1, o: swap ? (o === 'h' ? 'v' : 'h') : o };
|
||||||
|
},
|
||||||
|
goalSide: (seat: Seat): Side => {
|
||||||
|
const g: Record<Seat, [number, number]> = { S: [4.5, 9], N: [4.5, 0], W: [9, 4.5], E: [0, 4.5] };
|
||||||
|
const [x, y] = tf(...g[seat]);
|
||||||
|
return y < 0.5 ? 'top' : y > 8.5 ? 'bottom' : x < 0.5 ? 'left' : 'right';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapsAny(walls: Wall[], w: Wall): boolean {
|
||||||
|
const has = (o: O, c: number, r: number) => walls.some((x) => x.o === o && x.c === c && x.r === r);
|
||||||
|
if (w.o === 'h') return has('h', w.c - 1, w.r) || has('h', w.c, w.r) || has('h', w.c + 1, w.r) || has('v', w.c, w.r);
|
||||||
|
return has('v', w.c, w.r - 1) || has('v', w.c, w.r) || has('v', w.c, w.r + 1) || has('h', w.c, w.r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wallRect(a: Pt, o: O) {
|
||||||
|
return o === 'h' ? { x: a.x + 0.05, y: a.y + 1 - G / 2, width: 2 - 0.1, height: G } : { x: a.x + 1 - G / 2, y: a.y + 0.05, width: G, height: 2 - 0.1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveText(m: QuoridorMove, swap = false): string {
|
||||||
|
if (m.kind === 'move') return `${SEAT_KO[m.seat]} ${label(m.to)}${m.auto ? '(자동)' : ''}`;
|
||||||
|
if (m.kind === 'wall') return `${SEAT_KO[m.seat]} ${wallText(m.wall, swap)}`;
|
||||||
|
return `${SEAT_KO[m.seat]} 쉼`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PawnMark({ seat, cx, cy, r = 0.34, ghost }: { seat: Seat; cx: number; cy: number; r?: number; ghost?: boolean }) {
|
||||||
|
return (
|
||||||
|
<g opacity={ghost ? 0.5 : 1}>
|
||||||
|
{!ghost && <circle cx={cx + 0.03} cy={cy + 0.05} r={r} fill="#000" opacity={0.25} />}
|
||||||
|
<circle cx={cx} cy={cy} r={r} fill={SEAT_COLOR[seat]} stroke="#fff" strokeWidth={0.05} />
|
||||||
|
<text x={cx} y={cy + 0.01} fontSize={r * 0.95} fontWeight={800} fill="#fff" textAnchor="middle" dominantBaseline="central">
|
||||||
|
{SEAT_KO[seat]}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mode = 'move' | 'wall';
|
||||||
|
|
||||||
|
function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProps<QuoridorView>) {
|
||||||
|
const me = v.seats.find((x) => x.seat === v.mySeat) ?? null;
|
||||||
|
const myTurn = v.phase === 'playing' && !!me && v.turn === me.seat && v.legalPawnMoves !== null;
|
||||||
|
const t = useMemo(() => makeTf(v.mySeat), [v.mySeat]);
|
||||||
|
const [mode, setMode] = useState<Mode>('move');
|
||||||
|
const [orient, setOrient] = useState<O>('h');
|
||||||
|
const [wallAt, setWallAt] = useState<Pt | null>(null); // 화면 앵커(고정된 미리보기)
|
||||||
|
const [hoverAnchor, setHoverAnchor] = useState<Pt | null>(null);
|
||||||
|
const [hoverCell, setHoverCell] = useState<Pt | null>(null);
|
||||||
|
const [movePreview, setMovePreview] = useState<Pt | null>(null);
|
||||||
|
const [cursor, setCursor] = useState<Pt>({ x: 4, y: 8 });
|
||||||
|
const [kbd, setKbd] = useState(false);
|
||||||
|
const [announce, setAnnounce] = useState('');
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const prevCount = useRef(v.moveCount);
|
||||||
|
const prevWalls = useRef(v.walls.length);
|
||||||
|
const [newWall, setNewWall] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const legalCells = useMemo(() => {
|
||||||
|
const m = new Set<string>();
|
||||||
|
for (const c of v.legalPawnMoves ?? []) {
|
||||||
|
const p = t.cellToScreen(c);
|
||||||
|
m.add(`${p.x},${p.y}`);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [v.legalPawnMoves, t]);
|
||||||
|
const legalWalls = useMemo(() => {
|
||||||
|
const m = new Map<string, QuoridorLegalWall>();
|
||||||
|
for (const w of v.legalWalls ?? []) m.set(wallKey(w), w);
|
||||||
|
return m;
|
||||||
|
}, [v.legalWalls]);
|
||||||
|
|
||||||
|
// 새 수: 소리·진동·스크린리더 안내, 미리보기 지우기, 차례가 오면 커서를 내 말로.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const prev = prevCount.current;
|
||||||
|
prevCount.current = v.moveCount;
|
||||||
|
setMovePreview(null);
|
||||||
|
setWallAt(null);
|
||||||
|
if (v.walls.length > prevWalls.current) setNewWall(wallKey(v.walls[v.walls.length - 1]!));
|
||||||
|
else setNewWall(null);
|
||||||
|
prevWalls.current = v.walls.length;
|
||||||
|
if (v.moveCount <= prev) return;
|
||||||
|
const added = v.moves.slice(prev);
|
||||||
|
const texts = added.map((m) => (m.kind === 'pass' ? `${SEAT_KO[m.seat]}은 움직일 곳이 없어 차례를 넘겼어요.` : `${moveText(m, t.swap)}.`));
|
||||||
|
for (const m of added) if (m.kind === 'pass' && m.seat === v.mySeat) toast('움직일 곳도 세울 벽도 없어서 차례를 넘겼어요');
|
||||||
|
setAnnounce(texts.join(' '));
|
||||||
|
if (added.some((m) => m.kind !== 'pass')) sounds.stone();
|
||||||
|
if (v.phase === 'playing' && v.mySeat === v.turn) {
|
||||||
|
haptic(40);
|
||||||
|
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||||
|
}
|
||||||
|
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (myTurn && me?.pos) setCursor(t.cellToScreen(me.pos));
|
||||||
|
if (!myTurn) setMode('move');
|
||||||
|
}, [myTurn]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const wallsLeft = me?.wallsLeft ?? 0;
|
||||||
|
const canWall = myTurn && wallsLeft > 0;
|
||||||
|
|
||||||
|
/** 화면 앵커·방향의 벽에 대한 판정: 합법이면 정보, 아니면 이유. */
|
||||||
|
const judge = (a: Pt, o: O): { wall: Wall; legal: QuoridorLegalWall | null; why: string | null } => {
|
||||||
|
const wall = t.screenToWall(a, o);
|
||||||
|
if (wallsLeft <= 0) return { wall, legal: null, why: '남은 벽이 없어요.' };
|
||||||
|
const legal = legalWalls.get(wallKey(wall)) ?? null;
|
||||||
|
if (legal) return { wall, legal, why: null };
|
||||||
|
return { wall, legal: null, why: overlapsAny(v.walls, wall) ? '다른 벽과 겹쳐요.' : '누군가의 길을 완전히 막을 수 없어요.' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const doMove = (p: Pt) => {
|
||||||
|
act({ type: 'move', to: t.screenToCell(p) });
|
||||||
|
setMovePreview(null);
|
||||||
|
setHoverCell(null);
|
||||||
|
};
|
||||||
|
const doWall = (a: Pt) => {
|
||||||
|
const j = judge(a, orient);
|
||||||
|
if (j.why) {
|
||||||
|
haptic([30, 30, 30]);
|
||||||
|
setAnnounce(j.why);
|
||||||
|
toast(j.why);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
act({ type: 'placeWall', ...j.wall });
|
||||||
|
setWallAt(null);
|
||||||
|
setHoverAnchor(null);
|
||||||
|
};
|
||||||
|
const refuseCell = (p: Pt) => {
|
||||||
|
const c = t.screenToCell(p);
|
||||||
|
const occupied = v.seats.some((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
|
||||||
|
const msg = occupied ? '말이 있는 칸이에요.' : '그 칸으로는 갈 수 없어요. 밝게 표시된 칸으로 움직일 수 있어요.';
|
||||||
|
haptic([30, 30, 30]);
|
||||||
|
setAnnounce(msg);
|
||||||
|
toast(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tapCell = (p: Pt, mouse: boolean) => {
|
||||||
|
setCursor(p);
|
||||||
|
if (!myTurn) return;
|
||||||
|
if (!legalCells.has(`${p.x},${p.y}`)) {
|
||||||
|
refuseCell(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mouse || !tapConfirm || (movePreview && movePreview.x === p.x && movePreview.y === p.y)) {
|
||||||
|
if (!pending) doMove(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMovePreview(p);
|
||||||
|
};
|
||||||
|
const tapAnchor = (a: Pt) => {
|
||||||
|
if (!canWall) return;
|
||||||
|
if (wallAt && wallAt.x === a.x && wallAt.y === a.y) {
|
||||||
|
setOrient((o) => (o === 'h' ? 'v' : 'h'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWallAt(a);
|
||||||
|
setCursor(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointer = (e: React.PointerEvent<SVGSVGElement>): { x: number; y: number } | null => {
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!svg) return null;
|
||||||
|
const pt = svg.createSVGPoint();
|
||||||
|
pt.x = e.clientX;
|
||||||
|
pt.y = e.clientY;
|
||||||
|
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||||
|
return { x: loc.x, y: loc.y };
|
||||||
|
};
|
||||||
|
const toCell = (p: { x: number; y: number }): Pt | null => {
|
||||||
|
const x = Math.floor(p.x);
|
||||||
|
const y = Math.floor(p.y);
|
||||||
|
return x >= 0 && y >= 0 && x < N && y < N ? { x, y } : null;
|
||||||
|
};
|
||||||
|
/** 가장 가까운 교차점으로 스냅. */
|
||||||
|
const toAnchor = (p: { x: number; y: number }): Pt | null => {
|
||||||
|
if (p.x < -0.3 || p.y < -0.3 || p.x > N + 0.3 || p.y > N + 0.3) return null;
|
||||||
|
return { x: Math.min(7, Math.max(0, Math.round(p.x) - 1)), y: Math.min(7, Math.max(0, Math.round(p.y) - 1)) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setModeTo = (m: Mode) => {
|
||||||
|
setMode(m);
|
||||||
|
setMovePreview(null);
|
||||||
|
setWallAt(null);
|
||||||
|
if (m === 'wall' && me?.pos) {
|
||||||
|
const p = t.cellToScreen(me.pos);
|
||||||
|
// 내 말 바로 위쪽 교차점에서 시작(상대 쪽으로 세우기 쉽게).
|
||||||
|
setCursor({ x: Math.min(7, Math.max(0, p.x - 1)), y: Math.min(7, Math.max(0, p.y - 2)) });
|
||||||
|
} else if (m === 'move' && me?.pos) setCursor(t.cellToScreen(me.pos));
|
||||||
|
setAnnounce(m === 'wall' ? '벽 세우기: 방향키로 자리를 고르고 R로 방향을 바꾼 뒤 Enter로 세워요.' : '말 움직이기: 방향키로 칸을 고르고 Enter로 움직여요.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
setKbd(true);
|
||||||
|
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||||
|
const max = mode === 'wall' ? 7 : 8;
|
||||||
|
if (d[e.key]) {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = { x: Math.min(max, Math.max(0, cursor.x + d[e.key]![0])), y: Math.min(max, Math.max(0, cursor.y + d[e.key]![1])) };
|
||||||
|
setCursor(next);
|
||||||
|
if (mode === 'wall') {
|
||||||
|
setWallAt(next);
|
||||||
|
const j = judge(next, orient);
|
||||||
|
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
|
||||||
|
} else {
|
||||||
|
const c = t.screenToCell(next);
|
||||||
|
const who = v.seats.find((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
|
||||||
|
setAnnounce(`${label(c)} ${who ? `${SEAT_KO[who.seat]} 말` : legalCells.has(`${next.x},${next.y}`) ? '갈 수 있음' : '빈칸'}`);
|
||||||
|
}
|
||||||
|
} else if ((e.key === 'r' || e.key === 'R') && mode === 'wall') {
|
||||||
|
e.preventDefault();
|
||||||
|
const o = orient === 'h' ? 'v' : 'h';
|
||||||
|
setOrient(o);
|
||||||
|
setWallAt(cursor);
|
||||||
|
const j = judge(cursor, o);
|
||||||
|
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
|
||||||
|
} else if (e.key === 'w' || e.key === 'W') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (canWall) setModeTo(mode === 'wall' ? 'move' : 'wall');
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setWallAt(null);
|
||||||
|
setMovePreview(null);
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!myTurn || pending) return;
|
||||||
|
if (mode === 'wall') doWall(cursor);
|
||||||
|
else if (legalCells.has(`${cursor.x},${cursor.y}`)) doMove(cursor);
|
||||||
|
else refuseCell(cursor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 미리보기 대상(고정 > 마우스 올림 > 키보드 커서).
|
||||||
|
const previewAnchor = mode === 'wall' && canWall ? (wallAt ?? hoverAnchor ?? (kbd ? cursor : null)) : null;
|
||||||
|
const preview = previewAnchor ? judge(previewAnchor, orient) : null;
|
||||||
|
const ghostCell = mode === 'move' && myTurn ? (movePreview ?? (hoverCell && legalCells.has(`${hoverCell.x},${hoverCell.y}`) ? hoverCell : null)) : null;
|
||||||
|
const path = myTurn && v.showPath && v.myShortestPath && me?.pos ? [me.pos, ...v.myShortestPath].map((c) => t.cellToScreen(c)) : null;
|
||||||
|
const goalSides = v.seats.filter((x) => x.status === 'playing' || (x.status === 'finished' && v.phase === 'finished')).map((x) => ({ seat: x.seat, side: t.goalSide(x.seat) }));
|
||||||
|
const nick = (id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||||
|
const lastWall = v.lastMove?.kind === 'wall' ? wallKey(v.lastMove.wall) : null;
|
||||||
|
const lastTo = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.to) : null;
|
||||||
|
const lastFrom = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.from) : null;
|
||||||
|
const myGoal = me ? SIDE_KO[t.goalSide(me.seat)] : null;
|
||||||
|
|
||||||
|
const band = (side: Side, k: number) => {
|
||||||
|
const w = 0.16;
|
||||||
|
const off = -0.1 - w - k * (w + 0.04);
|
||||||
|
if (side === 'top') return { x: 0, y: off, width: N, height: w };
|
||||||
|
if (side === 'bottom') return { x: 0, y: N - off - w, width: N, height: w };
|
||||||
|
if (side === 'left') return { x: off, y: 0, width: w, height: N };
|
||||||
|
return { x: N - off - w, y: 0, width: w, height: N };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="board-wrap stack" style={{ gap: 10, maxWidth: 'min(100%, calc(100vh - 340px))', minWidth: 'min(100%, 300px)' }}>
|
||||||
|
<style>{CSS}</style>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="qd-board"
|
||||||
|
viewBox={`${-PAD} ${-PAD} ${N + 2 * PAD} ${N + 2 * PAD}`}
|
||||||
|
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 14, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
|
||||||
|
data-my-seat={v.mySeat ?? ''}
|
||||||
|
data-mode={mode}
|
||||||
|
data-path={path && v.myShortestPath ? v.myShortestPath.map(label).join(' ') : ''}
|
||||||
|
data-preview={preview ? `${preview.wall.o}:${label(preview.wall)}${preview.why ? ':bad' : ''}` : ''}
|
||||||
|
onPointerMove={(e) => {
|
||||||
|
if (e.pointerType !== 'mouse' || !myTurn) return;
|
||||||
|
const p = pointer(e);
|
||||||
|
if (!p) return;
|
||||||
|
if (mode === 'wall') {
|
||||||
|
const a = toAnchor(p);
|
||||||
|
if (a?.x !== hoverAnchor?.x || a?.y !== hoverAnchor?.y) setHoverAnchor(a);
|
||||||
|
} else {
|
||||||
|
const c = toCell(p);
|
||||||
|
if (c?.x !== hoverCell?.x || c?.y !== hoverCell?.y) setHoverCell(c);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
setHoverAnchor(null);
|
||||||
|
setHoverCell(null);
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
setKbd(false);
|
||||||
|
const p = pointer(e);
|
||||||
|
if (!p) return;
|
||||||
|
if (mode === 'wall') {
|
||||||
|
const a = toAnchor(p);
|
||||||
|
if (a) tapAnchor(a);
|
||||||
|
} else {
|
||||||
|
const c = toCell(p);
|
||||||
|
if (c) tapCell(c, e.pointerType === 'mouse');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="grid"
|
||||||
|
aria-label={`벽 쌓기 길찾기 판 9×9, 지금은 ${mode === 'wall' ? '벽 세우기' : '말 움직이기'}. 방향키로 고르고 Enter로 정해요. W는 모드 바꾸기, R은 벽 방향 바꾸기예요.${myGoal ? ` 내 목표는 ${myGoal} 줄이에요.` : ''}`}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
>
|
||||||
|
{goalSides.map((g, k) => {
|
||||||
|
const sameSide = goalSides.filter((x, j) => j < k && x.side === g.side).length;
|
||||||
|
return <rect key={`g${g.seat}`} {...band(g.side, sameSide)} rx={0.06} fill={SEAT_COLOR[g.seat]} opacity={0.9} />;
|
||||||
|
})}
|
||||||
|
{Array.from({ length: N }, (_, i) => {
|
||||||
|
const bottom = t.screenToCell({ x: i, y: N - 1 });
|
||||||
|
const left = t.screenToCell({ x: 0, y: i });
|
||||||
|
return (
|
||||||
|
<g key={`l${i}`} fill="var(--wood-line)" fontSize={0.28} fontWeight={700} textAnchor="middle" dominantBaseline="central" aria-hidden>
|
||||||
|
<text x={i + 0.5} y={N + PAD - 0.2}>
|
||||||
|
{t.swap ? bottom.r + 1 : 'abcdefghi'[bottom.c]}
|
||||||
|
</text>
|
||||||
|
<text x={-PAD + 0.2} y={i + 0.5}>
|
||||||
|
{t.swap ? 'abcdefghi'[left.c] : left.r + 1}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Array.from({ length: N * N }, (_, i) => {
|
||||||
|
const x = i % N;
|
||||||
|
const y = Math.floor(i / N);
|
||||||
|
const legal = myTurn && mode === 'move' && legalCells.has(`${x},${y}`);
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={`c${i}`}
|
||||||
|
x={x + G / 2}
|
||||||
|
y={y + G / 2}
|
||||||
|
width={1 - G}
|
||||||
|
height={1 - G}
|
||||||
|
rx={0.08}
|
||||||
|
fill={legal ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.32)'}
|
||||||
|
stroke={legal ? 'var(--primary)' : 'none'}
|
||||||
|
strokeWidth={legal ? 0.06 : 0}
|
||||||
|
data-cell={label(t.screenToCell({ x, y }))}
|
||||||
|
data-legal={legal ? '1' : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{lastFrom && <rect x={lastFrom.x + G / 2 + 0.05} y={lastFrom.y + G / 2 + 0.05} width={1 - G - 0.1} height={1 - G - 0.1} rx={0.08} fill="none" stroke="var(--wood-line)" strokeWidth={0.04} strokeDasharray="0.1 0.08" />}
|
||||||
|
{path && path.length > 1 && (
|
||||||
|
<polyline
|
||||||
|
points={path.map((p) => `${p.x + 0.5},${p.y + 0.5}`).join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke={SEAT_COLOR[me!.seat]}
|
||||||
|
strokeWidth={0.07}
|
||||||
|
strokeDasharray="0.14 0.12"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity={0.75}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{myTurn &&
|
||||||
|
mode === 'move' &&
|
||||||
|
[...legalCells].map((k) => {
|
||||||
|
const [x, y] = k.split(',').map(Number) as [number, number];
|
||||||
|
return <circle key={`d${k}`} cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" opacity={0.8} />;
|
||||||
|
})}
|
||||||
|
{v.walls.map((w) => {
|
||||||
|
const s = t.wallToScreen(w);
|
||||||
|
const k = wallKey(w);
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={k}
|
||||||
|
className={k === newWall ? 'qd-wall-new' : undefined}
|
||||||
|
{...wallRect(s.a, s.o)}
|
||||||
|
rx={0.05}
|
||||||
|
fill="#3b2412"
|
||||||
|
stroke={k === lastWall ? 'var(--focus)' : '#f3e2c0'}
|
||||||
|
strokeWidth={k === lastWall ? 0.05 : 0.025}
|
||||||
|
data-wall={`${w.o}:${label(w)}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{v.seats.map((x) => {
|
||||||
|
if (!x.pos) return null;
|
||||||
|
const p = t.cellToScreen(x.pos);
|
||||||
|
const turn = v.phase === 'playing' && v.turn === x.seat;
|
||||||
|
return (
|
||||||
|
<g key={x.seat} className="qd-pawn" style={{ transform: `translate(${p.x + 0.5}px, ${p.y + 0.5}px)` }} data-pawn={x.seat} data-at={label(x.pos)}>
|
||||||
|
{turn && <circle className="qd-turn-ring" cx={0} cy={0} r={0.45} fill="none" stroke={SEAT_COLOR[x.seat]} strokeWidth={0.06} />}
|
||||||
|
<PawnMark seat={x.seat} cx={0} cy={0} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{lastTo && <circle cx={lastTo.x + 0.5 + 0.27} cy={lastTo.y + 0.5 - 0.27} r={0.08} fill="var(--focus)" stroke="#fff" strokeWidth={0.02} />}
|
||||||
|
{ghostCell && me && <PawnMark seat={me.seat} cx={ghostCell.x + 0.5} cy={ghostCell.y + 0.5} ghost />}
|
||||||
|
{preview && previewAnchor && (
|
||||||
|
<rect
|
||||||
|
{...wallRect(previewAnchor, orient)}
|
||||||
|
rx={0.05}
|
||||||
|
fill={preview.why ? 'rgba(220, 38, 38, 0.6)' : SEAT_COLOR[me!.seat]}
|
||||||
|
opacity={preview.why ? 1 : 0.6}
|
||||||
|
stroke={preview.why ? '#7f1d1d' : '#fff'}
|
||||||
|
strokeWidth={0.04}
|
||||||
|
strokeDasharray={wallAt ? undefined : '0.12 0.08'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{myTurn && mode === 'wall' && (
|
||||||
|
<g aria-hidden>
|
||||||
|
{Array.from({ length: 64 }, (_, i) => {
|
||||||
|
const a = { x: i % 8, y: Math.floor(i / 8) };
|
||||||
|
const w = t.screenToWall(a, 'h');
|
||||||
|
return <circle key={`a${i}`} cx={a.x + 1} cy={a.y + 1} r={0.09} fill="var(--wood-line)" opacity={0.35} data-anchor={label(w)} />;
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{myTurn && kbd && mode === 'move' && (
|
||||||
|
<rect x={cursor.x + 0.04} y={cursor.y + 0.04} width={0.92} height={0.92} rx={0.1} fill="none" stroke="var(--focus)" strokeWidth={0.07} />
|
||||||
|
)}
|
||||||
|
{myTurn && kbd && mode === 'wall' && <circle cx={cursor.x + 1} cy={cursor.y + 1} r={0.16} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||||
|
</svg>
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
{announce}
|
||||||
|
</p>
|
||||||
|
{me && v.phase === 'playing' && me.status === 'playing' && (
|
||||||
|
<div className="qd-modes" role="group" aria-label="할 일 고르기">
|
||||||
|
<button type="button" className={`btn ${mode === 'move' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'move'} disabled={!myTurn} onClick={() => setModeTo('move')}>
|
||||||
|
🚶 말 움직이기
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`btn ${mode === 'wall' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'wall'} disabled={!canWall} onClick={() => setModeTo('wall')}>
|
||||||
|
🧱 벽 세우기 ({wallsLeft}개)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{myTurn && mode === 'wall' && (
|
||||||
|
<div className="qd-tray">
|
||||||
|
<span className={`qd-hint small${preview?.why ? ' bad' : ''}`} role="status">
|
||||||
|
{!preview
|
||||||
|
? '판의 홈(칸 사이) 교차점을 눌러 벽 자리를 골라요.'
|
||||||
|
: preview.why
|
||||||
|
? `${wallText(preview.wall, t.swap)}: ${preview.why}`
|
||||||
|
: `${wallText(preview.wall, t.swap)} · 상대 길 +${preview.legal!.opp}칸${preview.legal!.me ? ` · 내 길 +${preview.legal!.me}칸` : ''}`}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-secondary qd-rotate" onClick={() => {
|
||||||
|
const o = orient === 'h' ? 'v' : 'h';
|
||||||
|
setOrient(o);
|
||||||
|
}} aria-label={`벽 방향 바꾸기, 지금 ${orient === 'h' ? '가로' : '세로'}`}>
|
||||||
|
↻ {orient === 'h' ? '가로' : '세로'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary qd-confirm" disabled={!previewAnchor || !!preview?.why || pending} onClick={() => previewAnchor && doWall(previewAnchor)}>
|
||||||
|
벽 세우기 확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{myTurn && mode === 'move' && movePreview && (
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(movePreview)}>
|
||||||
|
{label(t.screenToCell(movePreview))}으로 이동
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setMovePreview(null)}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{me && v.phase === 'playing' && !(myTurn && mode === 'wall') && (
|
||||||
|
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||||
|
{me.status === 'playing'
|
||||||
|
? `내 말은 ${COLOR_KO[me.seat]}(${SEAT_KO[me.seat]})이에요. ${myGoal} 줄(${COLOR_KO[me.seat]} 띠)에 먼저 닿으면 이겨요.`
|
||||||
|
: me.status === 'finished'
|
||||||
|
? '목표에 도착했어요! 남은 사람들의 순위가 정해질 때까지 지켜봐요.'
|
||||||
|
: '게임에서 빠졌어요. 계속 지켜볼 수 있어요.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!me && (
|
||||||
|
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||||
|
{v.seats.map((x) => `${nick(x.playerId)}: ${COLOR_KO[x.seat]} → ${SIDE_KO[t.goalSide(x.seat)]} 줄`).join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- side panel
|
||||||
|
|
||||||
|
function WallBars({ left, total }: { left: number; total: number }) {
|
||||||
|
return (
|
||||||
|
<span className="qd-bars" aria-label={`남은 벽 ${left}개`} role="img">
|
||||||
|
{Array.from({ length: total }, (_, i) => (
|
||||||
|
<i key={i} className={i < left ? undefined : 'used'} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: QuoridorSeatView; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||||
|
const u = players[x.playerId] ?? { id: x.playerId, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||||
|
const active = v.phase === 'playing' && v.turn === x.seat;
|
||||||
|
const now = useServerNow(500);
|
||||||
|
const tc = v.timeControl;
|
||||||
|
let clock: React.ReactNode = null;
|
||||||
|
if (x.status === 'playing' && tc.kind === 'fischer') {
|
||||||
|
const ms = Math.max(0, v.remainingMs[x.seat] - (active ? now - v.turnStartedAt : 0));
|
||||||
|
const s = Math.ceil(ms / 1000);
|
||||||
|
clock = (
|
||||||
|
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: active && s <= 10 ? 'var(--danger)' : undefined }}>
|
||||||
|
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (active) {
|
||||||
|
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
|
||||||
|
clock = <TurnTimer deadline={deadline} totalMs={total} mine={x.playerId === me} size={40} />;
|
||||||
|
}
|
||||||
|
const state =
|
||||||
|
x.status === 'finished'
|
||||||
|
? `${x.finishOrder}등 도착`
|
||||||
|
: x.status === 'resigned'
|
||||||
|
? '기권'
|
||||||
|
: x.status === 'timedOut'
|
||||||
|
? '시간패'
|
||||||
|
: `남은 길 ${x.distance}칸${x.autoMoveStreak > 0 ? ` · 자리 비움 ${x.autoMoveStreak}번` : ''}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card row"
|
||||||
|
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? SEAT_COLOR[x.seat] : 'transparent'}`, opacity: x.status === 'resigned' || x.status === 'timedOut' ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
<svg width={28} height={28} viewBox="-0.5 -0.5 1 1" aria-hidden>
|
||||||
|
<PawnMark seat={x.seat} cx={0} cy={0} r={0.45} />
|
||||||
|
</svg>
|
||||||
|
<Avatar user={u} size={32} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{u.nickname}
|
||||||
|
{x.playerId === me && <span className="muted small"> (나)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="muted small" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{COLOR_KO[x.seat]} · {state}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<WallBars left={x.wallsLeft} total={v.wallsPerPlayer} />
|
||||||
|
<span className="small" style={{ fontWeight: 800, minWidth: '2ch', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }} aria-hidden>
|
||||||
|
{x.wallsLeft}
|
||||||
|
</span>
|
||||||
|
{clock}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuoridorSide({ view: v, me, players, act, pending, deadline }: BoardProps<QuoridorView>) {
|
||||||
|
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||||
|
const mine = v.seats.find((x) => x.seat === v.mySeat);
|
||||||
|
const live = v.phase === 'playing' && mine?.status === 'playing';
|
||||||
|
const incoming =
|
||||||
|
live && v.mySeat
|
||||||
|
? v.undo.pending && v.undo.pending.by !== v.mySeat && !v.undo.pending.accepted.includes(v.mySeat)
|
||||||
|
? 'undo'
|
||||||
|
: v.draw.pendingBy && v.draw.pendingBy !== v.mySeat && !v.draw.accepted.includes(v.mySeat)
|
||||||
|
? 'draw'
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
const waitingOthers = live && v.mySeat && ((v.undo.pending && (v.undo.pending.by === v.mySeat || v.undo.pending.accepted.includes(v.mySeat))) || (v.draw.pendingBy && (v.draw.pendingBy === v.mySeat || v.draw.accepted.includes(v.mySeat))));
|
||||||
|
const outgoing = !!v.mySeat && (v.undo.pending?.by === v.mySeat || v.draw.pendingBy === v.mySeat);
|
||||||
|
const undoLeft = v.mySeat ? v.undo.remaining[v.mySeat] : null;
|
||||||
|
const requester = v.undo.pending?.by ?? v.draw.pendingBy;
|
||||||
|
// 내 자리가 맨 아래, 나머지는 차례 순서대로 위에.
|
||||||
|
const order = mine ? [...v.seats.filter((x) => x.seat !== mine.seat), mine] : v.seats;
|
||||||
|
const four = v.seats.length === 4;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{order.map((x) => (
|
||||||
|
<PlayerRow key={x.seat} v={v} x={x} players={players} deadline={deadline} me={me} />
|
||||||
|
))}
|
||||||
|
{live && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: v.undo.allowed && v.draw.allowed ? '1fr 1fr 1fr' : '1fr 1fr', gap: 8 }}>
|
||||||
|
{v.undo.allowed && (
|
||||||
|
<button className="btn btn-secondary btn-sm" disabled={!!requester || undoLeft === 0 || !v.moves.some((m) => m.kind !== 'pass' && m.seat === v.mySeat)} onClick={() => act({ type: 'requestUndo' })} title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}>
|
||||||
|
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{v.draw.allowed && (
|
||||||
|
<button className="btn btn-secondary btn-sm" disabled={!!requester} onClick={() => setConfirm('draw')}>
|
||||||
|
🤝 무승부
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||||
|
🏳 기권
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{waitingOthers && (
|
||||||
|
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||||
|
<span className="small">다른 사람의 답을 기다리는 중…</span>
|
||||||
|
{outgoing && (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||||
|
요청 취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MoveList view={v} />
|
||||||
|
{incoming && requester && (
|
||||||
|
<Sheet onClose={() => {}} label="다른 사람의 요청">
|
||||||
|
<h2>{incoming === 'undo' ? `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무르기를 요청했어요` : `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무승부를 제안했어요`}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{incoming === 'undo'
|
||||||
|
? `수락하면 ${v.undo.pending!.plies}개의 행동이 되돌려져요.${four ? ' 다른 사람도 모두 수락해야 해요.' : ''}`
|
||||||
|
: `수락하면 이 판은 무승부로 끝나요.${four ? ' 남은 사람이 모두 수락해야 해요.' : ''}`}
|
||||||
|
</p>
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||||
|
수락
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
{confirm === 'resign' && (
|
||||||
|
<ConfirmSheet
|
||||||
|
title="기권할까요?"
|
||||||
|
body={four ? '기권하면 내 말이 판에서 빠지고, 남은 벽은 사라져요.' : '기권하면 이 판은 상대가 이겨요.'}
|
||||||
|
confirm="기권하기"
|
||||||
|
danger
|
||||||
|
onConfirm={() => act({ type: 'resign' })}
|
||||||
|
onClose={() => setConfirm(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoveList({ view: v }: { view: QuoridorView }) {
|
||||||
|
const ref = useRef<HTMLOListElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||||
|
}, [v.moveCount]);
|
||||||
|
if (v.moveCount === 0) return null;
|
||||||
|
let n = 0;
|
||||||
|
return (
|
||||||
|
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록 ({v.moves.filter((m) => m.kind !== 'pass').length}수)</summary>
|
||||||
|
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{v.moves.map((m, i) => (
|
||||||
|
<li key={i} className={`small${m.kind === 'pass' ? ' muted' : ''}`}>
|
||||||
|
{m.kind !== 'pass' && <span className="muted">{++n}. </span>}
|
||||||
|
<span style={{ color: SEAT_COLOR[m.seat], fontWeight: 800 }} aria-hidden>
|
||||||
|
●{' '}
|
||||||
|
</span>
|
||||||
|
{moveText(m, swapFor(v.mySeat))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(v: QuoridorView, me: string | null, players: Record<string, PublicUser>): string {
|
||||||
|
if (v.phase === 'finished') return v.result?.summaryKo ?? '게임이 끝났어요';
|
||||||
|
const mine = v.seats.find((x) => x.seat === v.mySeat);
|
||||||
|
if (mine && mine.status === 'playing') {
|
||||||
|
if (v.undo.pending && v.undo.pending.by !== mine.seat && !v.undo.pending.accepted.includes(mine.seat)) return '무르기 요청이 왔어요';
|
||||||
|
if (v.draw.pendingBy && v.draw.pendingBy !== mine.seat && !v.draw.accepted.includes(mine.seat)) return '무승부 제안이 왔어요';
|
||||||
|
}
|
||||||
|
if (mine && v.turn === mine.seat && mine.status === 'playing') {
|
||||||
|
return mine.wallsLeft > 0 ? '내 차례예요. 말을 움직이거나 벽을 세워요' : '내 차례예요. 남은 벽이 없어서 말을 움직여요';
|
||||||
|
}
|
||||||
|
const turnSeat = v.seats.find((x) => x.seat === v.turn)!;
|
||||||
|
const nick = players[turnSeat.playerId]?.nickname ?? SEAT_KO[v.turn];
|
||||||
|
if (mine?.status === 'finished') return `도착했어요! ${nick}님 차례예요`;
|
||||||
|
return mine ? `${nick}님(${COLOR_KO[v.turn]})이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- options
|
||||||
|
|
||||||
|
function Seg<T extends string | number>({ value, options, onChange, label: aria }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="segmented" role="group" aria-label={aria}>
|
||||||
|
{options.map(([k, l]) => (
|
||||||
|
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label: title, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_PRESETS: [string, string, Partial<QuoridorOptions['timeControl']>][] = [
|
||||||
|
['pm15', '한 수 15초', { kind: 'perMove', perMoveSec: 15 }],
|
||||||
|
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
|
||||||
|
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
|
||||||
|
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
|
||||||
|
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
|
||||||
|
['none', '시간 제한 없음', { kind: 'none' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
function timeKey(tc: QuoridorOptions['timeControl']): string {
|
||||||
|
if (tc.kind === 'none') return 'none';
|
||||||
|
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
|
||||||
|
return `f${Math.round(tc.baseSec / 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeLabel(tc: QuoridorOptions['timeControl']): string {
|
||||||
|
if (tc.kind === 'none') return '시간 제한 없음';
|
||||||
|
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
|
||||||
|
const m = Math.floor(tc.baseSec / 60);
|
||||||
|
const sec = tc.baseSec % 60;
|
||||||
|
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Options({ value: o, onChange }: OptionsProps<QuoridorOptions>) {
|
||||||
|
const [more, setMore] = useState(false);
|
||||||
|
const set = (patch: Partial<QuoridorOptions>) => onChange({ ...o, ...patch });
|
||||||
|
const onOff = (v: boolean) => (v ? 'on' : 'off');
|
||||||
|
const known = TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl));
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 16 }}>
|
||||||
|
<Field label="시간">
|
||||||
|
<Seg
|
||||||
|
label="시간"
|
||||||
|
value={timeKey(o.timeControl)}
|
||||||
|
options={[...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]), ...(known ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]])]}
|
||||||
|
onChange={(k) => {
|
||||||
|
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||||
|
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="2명일 때 1인당 벽 수">
|
||||||
|
<Seg
|
||||||
|
label="2명일 때 1인당 벽 수"
|
||||||
|
value={o.wallsPerPlayer}
|
||||||
|
options={[...([10, 8, 6] as const).map((n) => [n, n === 10 ? '10개(기본)' : `${n}개(빠른 게임)`] as [number, string]), ...([10, 8, 6].includes(o.wallsPerPlayer) ? [] : [[o.wallsPerPlayer, `${o.wallsPerPlayer}개`] as [number, string]])]}
|
||||||
|
onChange={(wallsPerPlayer) => set({ wallsPerPlayer })}
|
||||||
|
/>
|
||||||
|
<span className="small muted">4명이면 1인당 5개로 정해져 있어요. 3명은 할 수 없어요.</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="내 최단 길 힌트">
|
||||||
|
<Seg label="내 최단 길 힌트" value={onOff(o.showPath)} options={[['on', '점선으로 보여 주기'], ['off', '안 보여 주기']]} onChange={(k) => set({ showPath: k === 'on' })} />
|
||||||
|
</Field>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||||
|
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||||
|
</button>
|
||||||
|
{more && (
|
||||||
|
<>
|
||||||
|
<Field label="대각선으로 넘어갈 수 있는 때">
|
||||||
|
<Seg
|
||||||
|
label="대각선으로 넘어갈 수 있는 때"
|
||||||
|
value={o.diagonalJumpTrigger}
|
||||||
|
options={[
|
||||||
|
['wallEdgePawn', '말 뒤가 벽·판 끝·다른 말'],
|
||||||
|
['wallOnly', '말 뒤가 벽일 때만'],
|
||||||
|
]}
|
||||||
|
onChange={(diagonalJumpTrigger) => set({ diagonalJumpTrigger })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="시작 자리">
|
||||||
|
<Seg label="시작 자리" value={o.seatOrder} options={[['random', '무작위'], ['joinOrder', '들어온 순서']]} onChange={(seatOrder) => set({ seatOrder })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="4명일 때 1등이 나오면">
|
||||||
|
<Seg label="4명일 때 1등이 나오면" value={onOff(o.continueForRanking)} options={[['off', '바로 끝내기'], ['on', '계속해서 2·3등 가리기']]} onChange={(k) => set({ continueForRanking: k === 'on' })} />
|
||||||
|
</Field>
|
||||||
|
{o.timeControl.kind === 'perMove' && (
|
||||||
|
<Field label="시간이 다 되면">
|
||||||
|
<Seg label="시간이 다 되면" value={o.timeoutPolicy} options={[['autoMove', '최단 길로 한 칸 자동 이동'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="무르기">
|
||||||
|
<Seg label="무르기" value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="무승부 제안">
|
||||||
|
<Seg label="무승부 제안" value={onOff(o.drawOffer)} options={[['on', '허용'], ['off', '안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionsSummary(o: QuoridorOptions): string {
|
||||||
|
return [timeLabel(o.timeControl), `벽 ${o.wallsPerPlayer}개(2명)`, ...(o.showPath ? ['길 힌트'] : []), ...(o.diagonalJumpTrigger === 'wallOnly' ? ['대각은 벽 뒤만'] : []), ...(o.continueForRanking ? ['순위 끝까지'] : [])].join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- rules
|
||||||
|
|
||||||
|
/** 규칙 그림: 5×5 조각판. pawns: [열, 행(위=0), 자리], walls: [앵커 열, 앵커 행, 방향], marks: 갈 수 있는 칸. */
|
||||||
|
function Diagram({ pawns, walls = [], marks = [], caption }: { pawns: [number, number, Seat][]; walls?: [number, number, O, boolean?][]; marks?: [number, number][]; caption: string }) {
|
||||||
|
const n = 5;
|
||||||
|
return (
|
||||||
|
<figure style={{ margin: 0, textAlign: 'center' }}>
|
||||||
|
<svg viewBox={`-0.1 -0.1 ${n + 0.2} ${n + 0.2}`} style={{ width: '100%', maxWidth: 170, display: 'block', margin: '0 auto', background: 'var(--wood)', borderRadius: 8 }} aria-hidden>
|
||||||
|
{Array.from({ length: n * n }, (_, i) => (
|
||||||
|
<rect key={i} x={(i % n) + G / 2} y={Math.floor(i / n) + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.35)" />
|
||||||
|
))}
|
||||||
|
{marks.map(([x, y]) => (
|
||||||
|
<g key={`m${x}${y}`}>
|
||||||
|
<rect x={x + G / 2} y={y + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.92)" stroke="var(--primary)" strokeWidth={0.06} />
|
||||||
|
<circle cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{walls.map(([x, y, o, red], i) => (
|
||||||
|
<rect key={`w${i}`} {...wallRect({ x, y }, o)} rx={0.05} fill={red ? 'rgba(220, 38, 38, 0.7)' : '#3b2412'} stroke={red ? '#7f1d1d' : '#f3e2c0'} strokeWidth={0.03} strokeDasharray={red ? '0.12 0.08' : undefined} />
|
||||||
|
))}
|
||||||
|
{pawns.map(([x, y, s]) => (
|
||||||
|
<PawnMark key={`p${x}${y}`} seat={s} cx={x + 0.5} cy={y + 0.5} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<figcaption className="small muted" style={{ marginTop: 4 }}>
|
||||||
|
{caption}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rules({ options }: { options?: QuoridorOptions }) {
|
||||||
|
const wallOnly = options?.diagonalJumpTrigger === 'wallOnly';
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<p>
|
||||||
|
9×9 판에서 <strong>내 말을 반대편 끝줄까지 먼저 보내면</strong> 이겨요. 2명이면 마주 보고, 4명이면 네 방향에서 시작해요(3명은 할 수 없어요). 화면에서는 내 목표 줄이 항상 위쪽이고, 판 가장자리의 색 띠가 각자의 목표 줄이에요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
내 차례에는 <strong>말을 한 칸 움직이거나</strong>, <strong>벽을 하나 세워요</strong>(둘 중 하나만). 벽은 2명이면 1인당 {options?.wallsPerPlayer ?? 10}개, 4명이면 5개예요. 한 번 세운 벽은 옮기거나 치울 수 없어요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>말 이동</strong>: 위·아래·왼쪽·오른쪽으로 한 칸. 벽이 있거나 다른 말이 있는 칸으로는 못 가요. 대각선 이동은 기본으로는 안 돼요.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>뛰어넘기</strong>: 바로 앞에 다른 말이 있으면 그 말을 곧게 뛰어넘을 수 있어요. 넘을 말 뒤가 {wallOnly ? '벽으로 막혀 있으면' : '벽·판 끝·다른 말로 막혀 있으면'} 그 말의 왼쪽이나 오른쪽(대각선)으로 갈 수 있어요. 곧게 넘을 수 있을 때는 대각선으로 못 가고, 한 번에 말 하나만 넘을 수 있어요.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||||
|
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} marks={[[2, 1], [1, 3], [3, 3], [2, 4]]} caption="① 앞의 말을 곧게 뛰어넘기" />
|
||||||
|
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h']]} marks={[[1, 2], [3, 2], [1, 3], [3, 3], [2, 4]]} caption="② 뒤가 벽이면 대각선으로" />
|
||||||
|
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h'], [1, 1, 'v']]} marks={[[3, 2], [1, 3], [3, 3], [2, 4]]} caption="③ 한쪽도 벽이면 다른 쪽만" />
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong>벽 세우기</strong>: 벽은 칸 2개 길이로, 칸 사이 홈에 세워요. 다른 벽과 겹치거나 같은 자리에서 십자로 엇갈릴 수 없어요. 그리고 <strong>누구의 길도 완전히 막으면 안 돼요</strong>. 돌아가더라도 모든 사람에게 목표 줄까지 가는 길이 하나는 남아 있어야 해요(말은 길을 막는 것으로 치지 않아요).
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||||
|
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h'], [3, 0, 'v', true]]} caption="④ 길을 완전히 막는 벽은 안 돼요" />
|
||||||
|
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h']]} marks={[[3, 0]]} caption="⑤ 돌아갈 길이 남으면 괜찮아요" />
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<strong>세우는 방법</strong>: "벽 세우기"를 누르고 칸 사이 교차점을 누르면 벽이 반투명하게 미리 보여요. 같은 곳을 한 번 더 누르거나 "↻" 버튼으로 가로·세로를 바꾸고, "벽 세우기 확인"을 누르면 세워져요. 세울 수 없는 자리면 빨갛게 보이고 이유가 나와요. 키보드로는 방향키로 고르고 R로 방향을 바꾸고 Enter로 세워요(W로 모드 바꾸기).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>이기는 방법</strong>: 목표 줄의 아무 칸에나 닿는 순간 이겨요. 4명일 때 나머지 순위는 남은 길이가 짧은 순서예요{options?.continueForRanking ? '(이 방은 1등이 나와도 계속해서 2·3등을 가려요)' : ''}. 움직일 곳도 세울 벽도 없으면 자동으로 차례를 넘겨요.
|
||||||
|
</p>
|
||||||
|
<p className="muted">팁: 벽은 아끼는 것도 전략이에요. 상대가 목표에 가까워졌을 때 길을 크게 돌아가게 만드는 벽이 가장 효과가 커요. 미리보기에 "상대 길 +몇 칸"이 나와요.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ui: GameUI<QuoridorView, QuoridorOptions> = {
|
||||||
|
Board: QuoridorBoard,
|
||||||
|
Side: QuoridorSide,
|
||||||
|
statusText,
|
||||||
|
isMyTurn: (v, me) => v.phase === 'playing' && !!v.mySeat && v.seats.some((x) => x.seat === v.turn && x.playerId === me && x.status === 'playing'),
|
||||||
|
Options,
|
||||||
|
optionsSummary,
|
||||||
|
Rules,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ui;
|
||||||
@@ -19,6 +19,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
bang: () => import('./bang'),
|
bang: () => import('./bang'),
|
||||||
'halli-galli': () => import('./halli-galli'),
|
'halli-galli': () => import('./halli-galli'),
|
||||||
marble: () => import('./marble'),
|
marble: () => import('./marble'),
|
||||||
|
quoridor: () => import('./quoridor'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const GAME_ICON: Record<string, string> = {
|
|||||||
marble: '🏙️',
|
marble: '🏙️',
|
||||||
bang: '🤠',
|
bang: '🤠',
|
||||||
'halli-galli': '🔔',
|
'halli-galli': '🔔',
|
||||||
|
quoridor: '🧱',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
|
|||||||
114
e2e/games.e2e.ts
114
e2e/games.e2e.ts
@@ -260,6 +260,56 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 벽 쌓기 길찾기: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */
|
||||||
|
const qdSvg = (p: Page) => p.locator('svg[data-my-seat]');
|
||||||
|
async function qdTap(p: Page, target: ReturnType<Page['locator']>) {
|
||||||
|
if (tag(p) === 'phone') await target.tap();
|
||||||
|
else await target.click();
|
||||||
|
}
|
||||||
|
async function qdWaitTurn(p: Page) {
|
||||||
|
await p.waitForFunction(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
/** 벽 세우기 모드에서 앵커를 고르고 방향을 맞춘 뒤 미리보기 값("v:d8" / "h:e7:bad")을 돌려준다. */
|
||||||
|
async function qdPreviewWall(p: Page, anchor: string, o: 'h' | 'v'): Promise<string> {
|
||||||
|
await qdWaitTurn(p);
|
||||||
|
if ((await qdSvg(p).getAttribute('data-mode')) !== 'wall') await qdTap(p, p.getByRole('button', { name: /^🧱 벽 세우기/ }));
|
||||||
|
await qdTap(p, p.locator(`[data-anchor="${anchor}"]`));
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const pv = (await qdSvg(p).getAttribute('data-preview')) ?? '';
|
||||||
|
if (pv.startsWith(`${o}:${anchor}`)) return pv;
|
||||||
|
await qdTap(p, p.getByRole('button', { name: /벽 방향 바꾸기/ }));
|
||||||
|
}
|
||||||
|
throw new Error(`wall preview did not become ${o}:${anchor}`);
|
||||||
|
}
|
||||||
|
async function qdConfirmWall(p: Page) {
|
||||||
|
const before = await p.locator('[data-wall]').count();
|
||||||
|
await qdTap(p, p.getByRole('button', { name: '벽 세우기 확인' }));
|
||||||
|
await p.waitForFunction((n) => document.querySelectorAll('[data-wall]').length > n, before, { timeout: 5000 });
|
||||||
|
}
|
||||||
|
/** 내 최단 길(data-path)에서 가장 멀리 가는 이동 가능 칸으로 한 수. 점프였으면 true. */
|
||||||
|
async function qdStep(p: Page, keyboard: boolean): Promise<boolean> {
|
||||||
|
await qdWaitTurn(p);
|
||||||
|
if ((await qdSvg(p).getAttribute('data-mode')) !== 'move') await qdTap(p, p.getByRole('button', { name: /말 움직이기/ }));
|
||||||
|
const seat = await qdSvg(p).getAttribute('data-my-seat');
|
||||||
|
const from = (await p.locator(`[data-pawn="${seat}"]`).getAttribute('data-at'))!;
|
||||||
|
const legal = await p.locator('[data-legal]').evaluateAll((els) => els.map((e) => e.getAttribute('data-cell')!));
|
||||||
|
const path = ((await qdSvg(p).getAttribute('data-path')) ?? '').split(' ');
|
||||||
|
let to = legal[0]!;
|
||||||
|
let best = -1;
|
||||||
|
for (const c of legal) if (path.indexOf(c) > best) [best, to] = [path.indexOf(c), c];
|
||||||
|
if (keyboard && to === path[0]) {
|
||||||
|
// 커서는 내 말에서 시작하고 화면 위쪽이 항상 내 목표 방향.
|
||||||
|
await keys(p, ['ArrowUp', 'Enter']);
|
||||||
|
} else {
|
||||||
|
await qdTap(p, p.locator(`[data-cell="${to}"]`));
|
||||||
|
const confirm = p.getByRole('button', { name: /으로 이동$/ });
|
||||||
|
if (await confirm.count()) await qdTap(p, confirm);
|
||||||
|
}
|
||||||
|
await p.waitForFunction((t) => document.querySelector(`[data-pawn] [data-at="${t}"], [data-at="${t}"]`) !== null, to, { timeout: 5000 });
|
||||||
|
const d = Math.abs(from.charCodeAt(0) - to.charCodeAt(0)) + Math.abs(Number(from.slice(1)) - Number(to.slice(1)));
|
||||||
|
return d === 2;
|
||||||
|
}
|
||||||
|
|
||||||
const PLANS: Record<string, Plan> = {
|
const PLANS: Record<string, Plan> = {
|
||||||
rummikub: {
|
rummikub: {
|
||||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||||
@@ -879,6 +929,70 @@ const PLANS: Record<string, Plan> = {
|
|||||||
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
quoridor: {
|
||||||
|
// PC 방장 + 휴대폰 친구(2인, 자리는 무작위). 남: 세로벽 d8 → 북: 세로벽 e8(북이 e8·e9 상자 안) → 남: 가로벽 e7은
|
||||||
|
// 북의 길을 완전히 막으므로 빨간 미리보기·이유와 함께 거부 → 다른 가로벽 a3. 그 뒤 둘 다 e열 최단 길로 달려
|
||||||
|
// 마주치면 뛰어넘고, 먼저 끝줄에 닿은 쪽이 이긴다(첫 이동은 키보드 ↑+Enter).
|
||||||
|
drive: async (pages) => {
|
||||||
|
const [host, guest] = pages as [Page, Page];
|
||||||
|
await qdSvg(host).waitFor();
|
||||||
|
await qdSvg(guest).waitFor();
|
||||||
|
const south = (await qdSvg(host).getAttribute('data-my-seat')) === 'S' ? host : guest;
|
||||||
|
const north = south === host ? guest : host;
|
||||||
|
if ((await qdSvg(north).getAttribute('data-my-seat')) !== 'N') throw new Error('seats are not S/N');
|
||||||
|
await qdPreviewWall(south, 'd8', 'v');
|
||||||
|
await qdConfirmWall(south);
|
||||||
|
await qdPreviewWall(north, 'e8', 'v');
|
||||||
|
await north.waitForTimeout(200);
|
||||||
|
await north.screenshot({ path: join(SHOTS, `quoridor-wall-preview-${tag(north)}.png`) });
|
||||||
|
await qdConfirmWall(north);
|
||||||
|
const bad = await qdPreviewWall(south, 'e7', 'h');
|
||||||
|
if (!bad.endsWith(':bad')) throw new Error(`blocking wall was not refused (${bad})`);
|
||||||
|
await south.getByText('누군가의 길을 완전히 막을 수 없어요').first().waitFor();
|
||||||
|
if (await south.getByRole('button', { name: '벽 세우기 확인' }).isEnabled()) throw new Error('confirm enabled for an illegal wall');
|
||||||
|
await south.waitForTimeout(300);
|
||||||
|
await south.screenshot({ path: join(SHOTS, `quoridor-illegal-wall-${tag(south)}.png`) });
|
||||||
|
if ((await south.locator('[data-wall]').count()) !== 2) throw new Error('illegal wall was placed');
|
||||||
|
await qdPreviewWall(south, 'a3', 'h');
|
||||||
|
await qdConfirmWall(south);
|
||||||
|
let jumps = 0;
|
||||||
|
let steps = 0;
|
||||||
|
let shot = false;
|
||||||
|
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||||
|
const first = new Set<Page>();
|
||||||
|
for (let turn = 0; turn < 40 && !(await over()); turn++) {
|
||||||
|
const mover = turn % 2 === 0 ? north : south;
|
||||||
|
const useKeys = mover === host && !first.has(mover);
|
||||||
|
first.add(mover);
|
||||||
|
if (await qdStep(mover, useKeys)) jumps++;
|
||||||
|
steps++;
|
||||||
|
if (steps === 6 && !shot) {
|
||||||
|
shot = true;
|
||||||
|
await host.waitForTimeout(300);
|
||||||
|
await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc.png') });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone.png') });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-full.png'), fullPage: true });
|
||||||
|
await host.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await host.waitForTimeout(300);
|
||||||
|
await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc-dark.png') });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-dark.png') });
|
||||||
|
await host.emulateMedia({ colorScheme: 'light' });
|
||||||
|
await guest.emulateMedia({ colorScheme: 'light' });
|
||||||
|
}
|
||||||
|
await host.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
await host.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
|
||||||
|
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
|
||||||
|
await host.waitForTimeout(600);
|
||||||
|
await host.screenshot({ path: join(SHOTS, 'quoridor-result-pc.png') });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'quoridor-result-phone.png') });
|
||||||
|
const summary = await host.getByRole('dialog', { name: '게임 결과' }).innerText();
|
||||||
|
if (!/승리 — \d+수 만에 도착/.test(summary)) throw new Error(`unexpected result: ${summary.slice(0, 80)}`);
|
||||||
|
if (jumps < 1) throw new Error('no jump happened');
|
||||||
|
console.log(` quoridor: 3 walls + 1 refused wall, ${steps} pawn moves (${jumps} jump), ${summary.split('\n').find((l) => l.includes('승리'))}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { gostop } from './gostop';
|
|||||||
import { bang } from './bang';
|
import { bang } from './bang';
|
||||||
import { halliGalli } from './halli-galli';
|
import { halliGalli } from './halli-galli';
|
||||||
import { marble } from './marble';
|
import { marble } from './marble';
|
||||||
|
import { quoridor } from './quoridor';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
@@ -35,4 +36,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
|||||||
[bang.id]: bang,
|
[bang.id]: bang,
|
||||||
[halliGalli.id]: halliGalli,
|
[halliGalli.id]: halliGalli,
|
||||||
[marble.id]: marble,
|
[marble.id]: marble,
|
||||||
|
[quoridor.id]: quoridor,
|
||||||
};
|
};
|
||||||
|
|||||||
222
packages/games/src/quoridor/board.ts
Normal file
222
packages/games/src/quoridor/board.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* 벽 쌓기 길찾기 판 계산 (docs/games/quoridor.md §3, §5): 좌표, 벽 충돌, 말 이동, BFS 경로.
|
||||||
|
* 칸 (c, r) 0~8, r=0이 1행(아래). 벽 앵커 (c, r) 0~7은 칸 (c,r)(c+1,r)(c,r+1)(c+1,r+1)의 가운데 교차점.
|
||||||
|
*/
|
||||||
|
export type Seat = 'S' | 'W' | 'N' | 'E';
|
||||||
|
export interface Cell {
|
||||||
|
c: number;
|
||||||
|
r: number;
|
||||||
|
}
|
||||||
|
export type Orientation = 'h' | 'v';
|
||||||
|
export interface Wall {
|
||||||
|
c: number;
|
||||||
|
r: number;
|
||||||
|
o: Orientation;
|
||||||
|
}
|
||||||
|
/** 8×8 앵커별 점유, 인덱스 r*8+c. */
|
||||||
|
export interface WallIndex {
|
||||||
|
h: boolean[];
|
||||||
|
v: boolean[];
|
||||||
|
}
|
||||||
|
export type JumpTrigger = 'wallEdgePawn' | 'wallOnly';
|
||||||
|
|
||||||
|
export const SIZE = 9;
|
||||||
|
export const ANCHORS = 8;
|
||||||
|
const CELLS = SIZE * SIZE;
|
||||||
|
|
||||||
|
export const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
|
||||||
|
export const START: Record<Seat, Cell> = { S: { c: 4, r: 0 }, W: { c: 0, r: 4 }, N: { c: 4, r: 8 }, E: { c: 8, r: 4 } };
|
||||||
|
/** 목표 줄: 남 → 9행, 북 → 1행, 서 → i열, 동 → a열. */
|
||||||
|
export const isGoal = (seat: Seat, c: number, r: number): boolean =>
|
||||||
|
seat === 'S' ? r === SIZE - 1 : seat === 'N' ? r === 0 : seat === 'W' ? c === SIZE - 1 : c === 0;
|
||||||
|
|
||||||
|
export const emptyWallIndex = (): WallIndex => ({ h: new Array<boolean>(64).fill(false), v: new Array<boolean>(64).fill(false) });
|
||||||
|
export const onBoard = (c: number, r: number): boolean => c >= 0 && r >= 0 && c < SIZE && r < SIZE;
|
||||||
|
export const anchorInRange = (c: number, r: number): boolean => Number.isInteger(c) && Number.isInteger(r) && c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS;
|
||||||
|
export const cellIdx = (c: number, r: number): number => r * SIZE + c;
|
||||||
|
export const sameCell = (a: Cell, b: Cell): boolean => a.c === b.c && a.r === b.r;
|
||||||
|
export const cellLabel = (p: Cell): string => `${'abcdefghi'[p.c]}${p.r + 1}`;
|
||||||
|
export const wallLabel = (w: Wall): string => `${w.o === 'h' ? '가로' : '세로'} ${cellLabel(w)}`;
|
||||||
|
|
||||||
|
const hasH = (w: WallIndex, c: number, r: number): boolean => c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS && w.h[r * ANCHORS + c]!;
|
||||||
|
const hasV = (w: WallIndex, c: number, r: number): boolean => c >= 0 && r >= 0 && c < ANCHORS && r < ANCHORS && w.v[r * ANCHORS + c]!;
|
||||||
|
|
||||||
|
/** (c,r)에서 (dc,dr) 방향 이웃으로 가는 길이 벽에 막혔는지. 판 안 여부는 호출하는 쪽이 본다. */
|
||||||
|
export function blocked(w: WallIndex, c: number, r: number, dc: number, dr: number): boolean {
|
||||||
|
if (dr === 1) return hasH(w, c, r) || hasH(w, c - 1, r);
|
||||||
|
if (dr === -1) return hasH(w, c, r - 1) || hasH(w, c - 1, r - 1);
|
||||||
|
if (dc === 1) return hasV(w, c, r) || hasV(w, c, r - 1);
|
||||||
|
return hasV(w, c - 1, r) || hasV(w, c - 1, r - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 북·남·동·서 순서(결정적). */
|
||||||
|
export const DIRS: readonly [number, number][] = [
|
||||||
|
[0, 1],
|
||||||
|
[0, -1],
|
||||||
|
[1, 0],
|
||||||
|
[-1, 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 기존 벽과 겹치는지(§5.3 조건 3): 같은 방향 반 칸 겹침, 같은 앵커 십자 교차. */
|
||||||
|
export function overlaps(w: WallIndex, c: number, r: number, o: Orientation): boolean {
|
||||||
|
if (o === 'h') return hasH(w, c - 1, r) || hasH(w, c, r) || hasH(w, c + 1, r) || hasV(w, c, r);
|
||||||
|
return hasV(w, c, r - 1) || hasV(w, c, r) || hasV(w, c, r + 1) || hasH(w, c, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWall(w: WallIndex, wall: Wall, on: boolean): void {
|
||||||
|
(wall.o === 'h' ? w.h : w.v)[wall.r * ANCHORS + wall.c] = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS 작업 공간(호출마다 stamp로 초기화 → 할당 없음). 순수 함수 결과에는 영향 없음.
|
||||||
|
const queue = new Int16Array(CELLS);
|
||||||
|
const seen = new Int32Array(CELLS);
|
||||||
|
const dist = new Int16Array(CELLS);
|
||||||
|
const parent = new Int16Array(CELLS);
|
||||||
|
let stamp = 0;
|
||||||
|
|
||||||
|
/** 말은 무시하고 벽만 보는 최단 거리(칸 수). 길이 없으면 -1. §5.3 경로 검사. */
|
||||||
|
export function pathLength(w: WallIndex, start: Cell, seat: Seat): number {
|
||||||
|
if (isGoal(seat, start.c, start.r)) return 0;
|
||||||
|
stamp++;
|
||||||
|
let head = 0;
|
||||||
|
let tail = 0;
|
||||||
|
const s0 = cellIdx(start.c, start.r);
|
||||||
|
queue[tail++] = s0;
|
||||||
|
seen[s0] = stamp;
|
||||||
|
dist[s0] = 0;
|
||||||
|
while (head < tail) {
|
||||||
|
const cur = queue[head++]!;
|
||||||
|
const c = cur % SIZE;
|
||||||
|
const r = (cur - c) / SIZE;
|
||||||
|
for (const [dc, dr] of DIRS) {
|
||||||
|
const nc = c + dc;
|
||||||
|
const nr = r + dr;
|
||||||
|
if (!onBoard(nc, nr) || blocked(w, c, r, dc, dr)) continue;
|
||||||
|
const ni = nr * SIZE + nc;
|
||||||
|
if (seen[ni] === stamp) continue;
|
||||||
|
seen[ni] = stamp;
|
||||||
|
dist[ni] = dist[cur]! + 1;
|
||||||
|
if (isGoal(seat, nc, nr)) return dist[ni]!;
|
||||||
|
queue[tail++] = ni;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 출발 칸 다음부터 목표 줄의 칸까지 최단 경로(결정적). 길이 없으면 null. */
|
||||||
|
export function shortestPath(w: WallIndex, start: Cell, seat: Seat): Cell[] | null {
|
||||||
|
if (isGoal(seat, start.c, start.r)) return [];
|
||||||
|
stamp++;
|
||||||
|
let head = 0;
|
||||||
|
let tail = 0;
|
||||||
|
const s0 = cellIdx(start.c, start.r);
|
||||||
|
queue[tail++] = s0;
|
||||||
|
seen[s0] = stamp;
|
||||||
|
parent[s0] = -1;
|
||||||
|
while (head < tail) {
|
||||||
|
const cur = queue[head++]!;
|
||||||
|
const c = cur % SIZE;
|
||||||
|
const r = (cur - c) / SIZE;
|
||||||
|
for (const [dc, dr] of DIRS) {
|
||||||
|
const nc = c + dc;
|
||||||
|
const nr = r + dr;
|
||||||
|
if (!onBoard(nc, nr) || blocked(w, c, r, dc, dr)) continue;
|
||||||
|
const ni = nr * SIZE + nc;
|
||||||
|
if (seen[ni] === stamp) continue;
|
||||||
|
seen[ni] = stamp;
|
||||||
|
parent[ni] = cur;
|
||||||
|
if (isGoal(seat, nc, nr)) {
|
||||||
|
const out: Cell[] = [];
|
||||||
|
for (let i = ni; i !== s0; i = parent[i]!) out.push({ c: i % SIZE, r: Math.floor(i / SIZE) });
|
||||||
|
return out.reverse();
|
||||||
|
}
|
||||||
|
queue[tail++] = ni;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pawn {
|
||||||
|
seat: Seat;
|
||||||
|
pos: Cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §5.2 이동 가능 칸(중복 없음, 결정적 순서). `pawns`는 판에 있는 모든 말(나 포함). */
|
||||||
|
export function legalPawnMoves(w: WallIndex, pawns: readonly Pawn[], me: Cell, trigger: JumpTrigger): Cell[] {
|
||||||
|
const occ = new Set(pawns.map((p) => cellIdx(p.pos.c, p.pos.r)));
|
||||||
|
const out: Cell[] = [];
|
||||||
|
const seenOut = new Set<number>();
|
||||||
|
const add = (c: number, r: number) => {
|
||||||
|
const i = cellIdx(c, r);
|
||||||
|
if (!seenOut.has(i)) {
|
||||||
|
seenOut.add(i);
|
||||||
|
out.push({ c, r });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const [dc, dr] of DIRS) {
|
||||||
|
const nc = me.c + dc;
|
||||||
|
const nr = me.r + dr;
|
||||||
|
if (!onBoard(nc, nr) || blocked(w, me.c, me.r, dc, dr)) continue;
|
||||||
|
if (!occ.has(cellIdx(nc, nr))) {
|
||||||
|
add(nc, nr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 인접 칸에 말 Q: 곧게 뛰어넘기 → 안 되면 조건에 따라 대각.
|
||||||
|
const jc = nc + dc;
|
||||||
|
const jr = nr + dr;
|
||||||
|
const jOn = onBoard(jc, jr);
|
||||||
|
const wallBehind = jOn && blocked(w, nc, nr, dc, dr);
|
||||||
|
if (jOn && !wallBehind && !occ.has(cellIdx(jc, jr))) {
|
||||||
|
add(jc, jr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trigger === 'wallOnly' && !wallBehind) continue;
|
||||||
|
for (const [pc, pr] of dc === 0 ? [[1, 0], [-1, 0]] : [[0, 1], [0, -1]]) {
|
||||||
|
const tc = nc + pc!;
|
||||||
|
const tr = nr + pr!;
|
||||||
|
if (onBoard(tc, tr) && !blocked(w, nc, nr, pc!, pr!) && !occ.has(cellIdx(tc, tr))) add(tc, tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이동할 수 없는 칸의 이유(한국어). */
|
||||||
|
export function moveRefusal(w: WallIndex, pawns: readonly Pawn[], me: Cell, to: Cell, trigger: JumpTrigger): string {
|
||||||
|
if (!onBoard(to.c, to.r)) return '판 밖이에요.';
|
||||||
|
if (sameCell(me, to)) return '지금 있는 칸이에요.';
|
||||||
|
const occ = new Set(pawns.map((p) => cellIdx(p.pos.c, p.pos.r)));
|
||||||
|
if (occ.has(cellIdx(to.c, to.r))) return '다른 말이 있는 칸이에요.';
|
||||||
|
const dc = to.c - me.c;
|
||||||
|
const dr = to.r - me.r;
|
||||||
|
const ad = Math.abs(dc) + Math.abs(dr);
|
||||||
|
if (ad === 1) return blocked(w, me.c, me.r, dc, dr) ? '벽이 가로막고 있어요.' : '그 칸으로는 갈 수 없어요.';
|
||||||
|
if (Math.abs(dc) === 1 && Math.abs(dr) === 1) {
|
||||||
|
// 대각: 옆에 말이 있고 곧게 넘을 수 있으면 대각은 안 됨.
|
||||||
|
for (const [sc, sr] of [
|
||||||
|
[dc, 0],
|
||||||
|
[0, dr],
|
||||||
|
] as const) {
|
||||||
|
if (!occ.has(cellIdx(me.c + sc, me.r + sr)) || blocked(w, me.c, me.r, sc, sr)) continue;
|
||||||
|
const jc = me.c + 2 * sc;
|
||||||
|
const jr = me.r + 2 * sr;
|
||||||
|
if (onBoard(jc, jr) && !blocked(w, me.c + sc, me.r + sr, sc, sr) && !occ.has(cellIdx(jc, jr))) {
|
||||||
|
return '곧게 뛰어넘을 수 있을 때는 대각선으로 갈 수 없어요.';
|
||||||
|
}
|
||||||
|
if (trigger === 'wallOnly' && !(onBoard(jc, jr) && blocked(w, me.c + sc, me.r + sr, sc, sr))) {
|
||||||
|
return '이 방은 넘을 말 뒤에 벽이 있을 때만 대각선으로 갈 수 있어요.';
|
||||||
|
}
|
||||||
|
const mc = me.c + sc;
|
||||||
|
const mr = me.r + sr;
|
||||||
|
if (blocked(w, mc, mr, to.c - mc, to.r - mr)) return '벽이 가로막고 있어요.';
|
||||||
|
}
|
||||||
|
return '대각선으로는 상대 말을 넘을 때만 갈 수 있어요.';
|
||||||
|
}
|
||||||
|
if (ad === 2 && (dc === 0 || dr === 0)) {
|
||||||
|
const sc = Math.sign(dc);
|
||||||
|
const sr = Math.sign(dr);
|
||||||
|
if (!occ.has(cellIdx(me.c + sc, me.r + sr))) return '한 번에 한 칸씩만 움직일 수 있어요.';
|
||||||
|
return '벽이 가로막고 있어요.';
|
||||||
|
}
|
||||||
|
if (ad === 3 && (dc === 0 || dr === 0)) return '말은 한 번에 하나만 뛰어넘을 수 있어요.';
|
||||||
|
return '그 칸으로는 갈 수 없어요.';
|
||||||
|
}
|
||||||
838
packages/games/src/quoridor/index.ts
Normal file
838
packages/games/src/quoridor/index.ts
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
/** 벽 쌓기 길찾기 (docs/games/quoridor.md). 내부 id 'quoridor'는 화면에 보이지 않는다(§12). */
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
|
||||||
|
import {
|
||||||
|
ANCHORS,
|
||||||
|
SEAT_KO,
|
||||||
|
SIZE,
|
||||||
|
START,
|
||||||
|
anchorInRange,
|
||||||
|
emptyWallIndex,
|
||||||
|
isGoal,
|
||||||
|
legalPawnMoves,
|
||||||
|
moveRefusal,
|
||||||
|
overlaps,
|
||||||
|
pathLength,
|
||||||
|
sameCell,
|
||||||
|
setWall,
|
||||||
|
shortestPath,
|
||||||
|
type Cell,
|
||||||
|
type Pawn,
|
||||||
|
type Seat,
|
||||||
|
type Wall,
|
||||||
|
type WallIndex,
|
||||||
|
} from './board';
|
||||||
|
|
||||||
|
export type { Cell, Seat, Wall, Orientation, JumpTrigger } from './board';
|
||||||
|
export { SEAT_KO, cellLabel, wallLabel } from './board';
|
||||||
|
|
||||||
|
const ORDER_2: Seat[] = ['S', 'N'];
|
||||||
|
/** 위에서 볼 때 시계 방향: 남 → 서 → 북 → 동. */
|
||||||
|
const ORDER_4: Seat[] = ['S', 'W', 'N', 'E'];
|
||||||
|
const ALL_SEATS: Seat[] = ['S', 'W', 'N', 'E'];
|
||||||
|
export const WALLS_4P = 5;
|
||||||
|
const COUNT_ERROR = '2명 또는 4명이 필요해요.';
|
||||||
|
|
||||||
|
export const quoridorOptionsSchema = z.object({
|
||||||
|
wallsPerPlayer: z.number().int().min(6).max(10).default(10).meta({ title: '2명일 때 1인당 벽 수(4명은 5개 고정)' }),
|
||||||
|
diagonalJumpTrigger: z
|
||||||
|
.enum(['wallEdgePawn', 'wallOnly'])
|
||||||
|
.default('wallEdgePawn')
|
||||||
|
.meta({
|
||||||
|
title: '대각선으로 넘어갈 수 있는 때',
|
||||||
|
labels: { wallEdgePawn: '넘을 말 뒤가 벽·판 끝·다른 말일 때', wallOnly: '넘을 말 뒤가 벽일 때만' },
|
||||||
|
}),
|
||||||
|
seatOrder: z.enum(['random', 'joinOrder']).default('random').meta({ title: '시작 자리·순서', labels: { random: '무작위', joinOrder: '들어온 순서' } }),
|
||||||
|
continueForRanking: z.boolean().default(false).meta({ title: '4명일 때 1등이 나와도 계속해서 2·3등 가리기' }),
|
||||||
|
timeControl: z
|
||||||
|
.object({
|
||||||
|
kind: z
|
||||||
|
.enum(['none', 'perMove', 'fischer'])
|
||||||
|
.default('perMove')
|
||||||
|
.meta({ title: '시간 방식', labels: { none: '제한 없음', perMove: '한 수당 제한', fischer: '전체 시간 + 수당 추가' } }),
|
||||||
|
perMoveSec: z.number().int().min(5).max(600).default(30).meta({ title: '한 수 제한(초)' }),
|
||||||
|
baseSec: z.number().int().min(60).max(3600).default(300).meta({ title: '전체 시간(초)' }),
|
||||||
|
incSec: z.number().int().min(0).max(60).default(3).meta({ title: '한 수마다 추가(초)' }),
|
||||||
|
})
|
||||||
|
.default({ kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 })
|
||||||
|
.meta({ title: '시간 제한' }),
|
||||||
|
timeoutPolicy: z
|
||||||
|
.enum(['autoMove', 'lose'])
|
||||||
|
.default('autoMove')
|
||||||
|
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '최단 길로 말 한 칸 자동 이동', lose: '시간패' } }),
|
||||||
|
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 이동 허용 횟수(넘으면 시간패)' }),
|
||||||
|
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
|
||||||
|
undo: z
|
||||||
|
.enum(['off', '1', '3', 'unlimited'])
|
||||||
|
.default('3')
|
||||||
|
.meta({ title: '무르기(1인당)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
|
||||||
|
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
||||||
|
showPath: z.boolean().default(true).meta({ title: '내 최단 길 보여 주기(힌트)' }),
|
||||||
|
});
|
||||||
|
export type QuoridorOptions = z.infer<typeof quoridorOptionsSchema>;
|
||||||
|
|
||||||
|
const coord = z.number().int().min(-20).max(20);
|
||||||
|
export const quoridorActionSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({ type: z.literal('move'), to: z.object({ c: coord, r: coord }) }),
|
||||||
|
z.object({ type: z.literal('placeWall'), c: coord, r: coord, o: z.enum(['h', 'v']) }),
|
||||||
|
z.object({ type: z.literal('requestUndo') }),
|
||||||
|
z.object({ type: z.literal('respondUndo'), accept: z.boolean() }),
|
||||||
|
z.object({ type: z.literal('offerDraw') }),
|
||||||
|
z.object({ type: z.literal('respondDraw'), accept: z.boolean() }),
|
||||||
|
z.object({ type: z.literal('cancelOffer') }),
|
||||||
|
z.object({ type: z.literal('resign') }),
|
||||||
|
]);
|
||||||
|
/** 클라이언트 행동 + 시스템 전용 timeout(스키마에 없음). */
|
||||||
|
export type QuoridorAction = z.infer<typeof quoridorActionSchema> | { type: 'timeout' };
|
||||||
|
|
||||||
|
export type SeatStatus = 'playing' | 'finished' | 'resigned' | 'timedOut';
|
||||||
|
export interface SeatState {
|
||||||
|
seat: Seat;
|
||||||
|
playerId: PlayerId;
|
||||||
|
/** null = 판에서 제거(4인 기권·시간패, 순위 계속 모드의 도착). */
|
||||||
|
pos: Cell | null;
|
||||||
|
wallsLeft: number;
|
||||||
|
status: SeatStatus;
|
||||||
|
finishOrder: number | null;
|
||||||
|
/** 기권·시간패 순서(늦을수록 높은 순위). */
|
||||||
|
outOrder: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuoridorMove =
|
||||||
|
| { kind: 'move'; seat: Seat; from: Cell; to: Cell; auto: boolean }
|
||||||
|
| { kind: 'wall'; seat: Seat; wall: Wall }
|
||||||
|
| { kind: 'pass'; seat: Seat };
|
||||||
|
|
||||||
|
export type QuoridorReason = 'reachedGoal' | 'lastPlayerStanding' | 'resign' | 'timeLoss' | 'drawAgreed' | 'stalemate';
|
||||||
|
|
||||||
|
export interface QuoridorResult {
|
||||||
|
ranking: { playerId: PlayerId; rank: number }[];
|
||||||
|
reason: QuoridorReason;
|
||||||
|
perPlayer: { playerId: PlayerId; seat: Seat; distanceLeft: number | null; wallsLeft: number; status: SeatStatus }[];
|
||||||
|
moveCount: number;
|
||||||
|
summaryKo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoridorState {
|
||||||
|
options: QuoridorOptions;
|
||||||
|
seats: SeatState[];
|
||||||
|
turnIndex: number;
|
||||||
|
walls: Wall[];
|
||||||
|
wallIndex: WallIndex;
|
||||||
|
moves: QuoridorMove[];
|
||||||
|
phase: 'playing' | 'finished';
|
||||||
|
/** 네 자리 키가 항상 있음(2인이면 W/E는 쓰지 않음). */
|
||||||
|
clock: { remainingMs: Record<Seat, number>; turnStartedAt: number; autoMoveStreak: Record<Seat, number> };
|
||||||
|
/** `plies` = 수락 시 `moves` 끝에서 되돌릴 항목 수. */
|
||||||
|
undo: { usedBy: Record<Seat, number>; pending: { by: Seat; accepted: Seat[]; plies: number } | null };
|
||||||
|
/** lastOfferMove: 마지막 제안 때의 moves.length(-1 = 없음). */
|
||||||
|
draw: { pendingBy: Seat | null; accepted: Seat[]; lastOfferMove: Record<Seat, number> };
|
||||||
|
outCount: number;
|
||||||
|
result: QuoridorResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoridorLegalWall extends Wall {
|
||||||
|
/** 이 벽을 놓으면 상대(들) 최단 거리 합이 몇 칸 늘어나는지. */
|
||||||
|
opp: number;
|
||||||
|
/** 내 최단 거리가 몇 칸 늘어나는지. */
|
||||||
|
me: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoridorSeatView {
|
||||||
|
seat: Seat;
|
||||||
|
playerId: PlayerId;
|
||||||
|
pos: Cell | null;
|
||||||
|
wallsLeft: number;
|
||||||
|
status: SeatStatus;
|
||||||
|
finishOrder: number | null;
|
||||||
|
/** 말 무시, 벽만 본 최단 거리(판에 없으면 null). */
|
||||||
|
distance: number | null;
|
||||||
|
autoMoveStreak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoridorView {
|
||||||
|
size: number;
|
||||||
|
seats: QuoridorSeatView[];
|
||||||
|
mySeat: Seat | null;
|
||||||
|
turn: Seat;
|
||||||
|
phase: QuoridorState['phase'];
|
||||||
|
walls: Wall[];
|
||||||
|
moves: QuoridorMove[];
|
||||||
|
moveCount: number;
|
||||||
|
lastMove: QuoridorMove | null;
|
||||||
|
/** 차례인 플레이어에게만(그 외 null). */
|
||||||
|
legalPawnMoves: Cell[] | null;
|
||||||
|
legalWalls: QuoridorLegalWall[] | null;
|
||||||
|
myShortestPath: Cell[] | null;
|
||||||
|
wallsPerPlayer: number;
|
||||||
|
diagonalJumpTrigger: QuoridorOptions['diagonalJumpTrigger'];
|
||||||
|
continueForRanking: boolean;
|
||||||
|
showPath: boolean;
|
||||||
|
timeControl: QuoridorOptions['timeControl'];
|
||||||
|
timeoutPolicy: QuoridorOptions['timeoutPolicy'];
|
||||||
|
remainingMs: Record<Seat, number>;
|
||||||
|
turnStartedAt: number;
|
||||||
|
undo: { allowed: boolean; remaining: Record<Seat, number | null>; pending: { by: Seat; accepted: Seat[]; plies: number } | null; responders: Seat[] };
|
||||||
|
draw: { allowed: boolean; pendingBy: Seat | null; accepted: Seat[]; responders: Seat[] };
|
||||||
|
result: QuoridorResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
const undoLimit = (o: QuoridorOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
|
||||||
|
const seatIndexOf = (s: QuoridorState, p: PlayerId): number => s.seats.findIndex((x) => x.playerId === p);
|
||||||
|
const bySeat = (s: QuoridorState, seat: Seat): SeatState => s.seats.find((x) => x.seat === seat)!;
|
||||||
|
const playing = (s: QuoridorState): SeatState[] => s.seats.filter((x) => x.status === 'playing');
|
||||||
|
const pawnsOf = (s: QuoridorState): Pawn[] => s.seats.filter((x) => x.pos).map((x) => ({ seat: x.seat, pos: x.pos! }));
|
||||||
|
const recordOf = <T>(v: T): Record<Seat, T> => ({ S: v, W: v, N: v, E: v });
|
||||||
|
const four = (s: QuoridorState) => s.seats.length === 4;
|
||||||
|
export const wallsFor = (o: QuoridorOptions, players: number): number => (players === 4 ? WALLS_4P : o.wallsPerPlayer);
|
||||||
|
|
||||||
|
export function playerCountError(n: number): string | null {
|
||||||
|
return n === 2 || n === 4 ? null : COUNT_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pawnMovesFor(s: QuoridorState, i: number): Cell[] {
|
||||||
|
const x = s.seats[i]!;
|
||||||
|
if (!x.pos || x.status !== 'playing') return [];
|
||||||
|
return legalPawnMoves(s.wallIndex, pawnsOf(s), x.pos, s.options.diagonalJumpTrigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 앵커 범위·남은 벽·겹침(§5.3 조건 1~3)만 본 이유. 통과하면 null. */
|
||||||
|
function wallShapeRefusal(s: QuoridorState, i: number, c: number, r: number, o: 'h' | 'v'): string | null {
|
||||||
|
if (s.seats[i]!.wallsLeft <= 0) return '남은 벽이 없어요.';
|
||||||
|
if (!anchorInRange(c, r)) return '벽은 판 밖으로 나갈 수 없어요.';
|
||||||
|
if (overlaps(s.wallIndex, c, r, o)) return '다른 벽과 겹쳐요.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 후보 벽을 잠시 세우고 남아 있는 모든 플레이어의 최단 거리를 잰다(-1 = 막힘). */
|
||||||
|
function distancesWith(s: QuoridorState, w: Wall | null): number[] {
|
||||||
|
if (w) setWall(s.wallIndex, w, true);
|
||||||
|
const out = s.seats.map((x) => (x.status === 'playing' && x.pos ? pathLength(s.wallIndex, x.pos, x.seat) : 0));
|
||||||
|
if (w) setWall(s.wallIndex, w, false);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §5.3 조건 4. `s.wallIndex`를 잠시 바꿨다가 되돌린다 — 반드시 사본 상태로 부른다. */
|
||||||
|
function blocksSomeone(s: QuoridorState, w: Wall): boolean {
|
||||||
|
setWall(s.wallIndex, w, true);
|
||||||
|
let bad = false;
|
||||||
|
for (const x of s.seats) {
|
||||||
|
if (x.status === 'playing' && x.pos && pathLength(s.wallIndex, x.pos, x.seat) < 0) {
|
||||||
|
bad = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWall(s.wallIndex, w, false);
|
||||||
|
return bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wallRefusal(s: QuoridorState, i: number, w: Wall): string | null {
|
||||||
|
const shape = wallShapeRefusal(s, i, w.c, w.r, w.o);
|
||||||
|
if (shape) return shape;
|
||||||
|
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
|
||||||
|
return blocksSomeone(t, { c: w.c, r: w.r, o: w.o }) ? '누군가의 길을 완전히 막을 수 없어요.' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 합법 벽 목록(최대 128 후보 × 남은 사람 수만큼 BFS). `withDelta`면 거리 변화량도. */
|
||||||
|
export function legalWallsFor(s: QuoridorState, i: number, withDelta = false): QuoridorLegalWall[] {
|
||||||
|
if (s.seats[i]!.wallsLeft <= 0 || s.seats[i]!.status !== 'playing') return [];
|
||||||
|
// wallIndex를 잠시 바꾸므로 사본에서 계산(state는 불변 취급).
|
||||||
|
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
|
||||||
|
const base = withDelta ? distancesWith(t, null) : [];
|
||||||
|
const out: QuoridorLegalWall[] = [];
|
||||||
|
for (let r = 0; r < ANCHORS; r++) {
|
||||||
|
for (let c = 0; c < ANCHORS; c++) {
|
||||||
|
for (const o of ['h', 'v'] as const) {
|
||||||
|
if (overlaps(t.wallIndex, c, r, o)) continue;
|
||||||
|
const w = { c, r, o };
|
||||||
|
if (!withDelta) {
|
||||||
|
if (!blocksSomeone(t, w)) out.push({ ...w, opp: 0, me: 0 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const d = distancesWith(t, w);
|
||||||
|
if (d.some((v) => v < 0)) continue;
|
||||||
|
let opp = 0;
|
||||||
|
t.seats.forEach((x, k) => {
|
||||||
|
if (k !== i && x.status === 'playing') opp += d[k]! - base[k]!;
|
||||||
|
});
|
||||||
|
out.push({ ...w, opp, me: d[i]! - base[i]! });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLegalWall(s: QuoridorState, i: number): boolean {
|
||||||
|
if (s.seats[i]!.wallsLeft <= 0) return false;
|
||||||
|
const t: QuoridorState = { ...s, wallIndex: { h: [...s.wallIndex.h], v: [...s.wallIndex.v] } };
|
||||||
|
for (let r = 0; r < ANCHORS; r++) {
|
||||||
|
for (let c = 0; c < ANCHORS; c++) {
|
||||||
|
for (const o of ['h', 'v'] as const) if (!overlaps(t.wallIndex, c, r, o) && !blocksSomeone(t, { c, r, o })) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAct = (s: QuoridorState, i: number): boolean => pawnMovesFor(s, i).length > 0 || hasLegalWall(s, i);
|
||||||
|
|
||||||
|
function distanceOf(s: QuoridorState, x: SeatState): number | null {
|
||||||
|
if (x.status === 'finished') return 0;
|
||||||
|
if (!x.pos) return null;
|
||||||
|
return pathLength(s.wallIndex, x.pos, x.seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 요청자 외 아직 남아 있는 플레이어. */
|
||||||
|
function respondersOf(s: QuoridorState, by: Seat): Seat[] {
|
||||||
|
return playing(s)
|
||||||
|
.map((x) => x.seat)
|
||||||
|
.filter((x) => x !== by);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPending(s: QuoridorState): void {
|
||||||
|
s.undo.pending = null;
|
||||||
|
s.draw.pendingBy = null;
|
||||||
|
s.draw.accepted = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- finishing
|
||||||
|
|
||||||
|
function rankAndFinish(s: QuoridorState, reason: QuoridorReason, events: GameEvent[]): void {
|
||||||
|
s.phase = 'finished';
|
||||||
|
clearPending(s);
|
||||||
|
const ranking: QuoridorResult['ranking'] = [];
|
||||||
|
let next = 1;
|
||||||
|
const pushGroup = (group: SeatState[]) => {
|
||||||
|
for (const x of group) ranking.push({ playerId: x.playerId, rank: next });
|
||||||
|
next += group.length;
|
||||||
|
};
|
||||||
|
const finished = s.seats.filter((x) => x.status === 'finished').sort((a, b) => a.finishOrder! - b.finishOrder!);
|
||||||
|
for (const x of finished) pushGroup([x]);
|
||||||
|
const alive = playing(s);
|
||||||
|
if (reason === 'drawAgreed') pushGroup(alive);
|
||||||
|
else {
|
||||||
|
const byDist = new Map<number, SeatState[]>();
|
||||||
|
for (const x of alive) {
|
||||||
|
const d = distanceOf(s, x) ?? 999;
|
||||||
|
byDist.set(d, [...(byDist.get(d) ?? []), x]);
|
||||||
|
}
|
||||||
|
for (const d of [...byDist.keys()].sort((a, b) => a - b)) pushGroup(byDist.get(d)!);
|
||||||
|
}
|
||||||
|
const out = s.seats.filter((x) => x.status === 'resigned' || x.status === 'timedOut').sort((a, b) => b.outOrder! - a.outOrder!);
|
||||||
|
for (const x of out) pushGroup([x]);
|
||||||
|
|
||||||
|
const moveCount = s.moves.filter((m) => m.kind !== 'pass').length;
|
||||||
|
const first = ranking.filter((r) => r.rank === 1).map((r) => s.seats.find((x) => x.playerId === r.playerId)!.seat);
|
||||||
|
const firstKo = first.map((x) => SEAT_KO[x]).join('·');
|
||||||
|
const lastOut = out[0];
|
||||||
|
let summaryKo: string;
|
||||||
|
switch (reason) {
|
||||||
|
case 'reachedGoal':
|
||||||
|
summaryKo = `${firstKo} 승리 — ${moveCount}수 만에 도착`;
|
||||||
|
break;
|
||||||
|
case 'lastPlayerStanding':
|
||||||
|
summaryKo = `${finished.map((x) => SEAT_KO[x.seat]).join(' → ')} 순서로 도착`;
|
||||||
|
break;
|
||||||
|
case 'resign':
|
||||||
|
summaryKo = four(s) && out.length > 1 ? `다른 사람이 모두 기권해 ${firstKo} 승리` : `${SEAT_KO[lastOut!.seat]} 기권으로 ${firstKo} 승리`;
|
||||||
|
break;
|
||||||
|
case 'timeLoss':
|
||||||
|
summaryKo = `${SEAT_KO[lastOut!.seat]} 시간패로 ${firstKo} 승리`;
|
||||||
|
break;
|
||||||
|
case 'drawAgreed':
|
||||||
|
summaryKo = '합의 무승부';
|
||||||
|
break;
|
||||||
|
case 'stalemate':
|
||||||
|
summaryKo = '아무도 움직일 수 없어 남은 거리로 순위를 정했어요';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
s.result = {
|
||||||
|
ranking,
|
||||||
|
reason,
|
||||||
|
perPlayer: s.seats.map((x) => ({ playerId: x.playerId, seat: x.seat, distanceLeft: distanceOf(s, x), wallsLeft: x.wallsLeft, status: x.status })),
|
||||||
|
moveCount,
|
||||||
|
summaryKo,
|
||||||
|
};
|
||||||
|
events.push({ type: 'gameEnded', reason, ranking });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차례 넘기기(§8.2): `from` 다음의 남은 플레이어. 이동·벽 모두 불가하면 자동 패스.
|
||||||
|
* 모두가 아무것도 못 하면(극히 드묾) 남은 거리로 순위를 정해 끝낸다.
|
||||||
|
*/
|
||||||
|
function advance(s: QuoridorState, from: number, now: number, events: GameEvent[]): void {
|
||||||
|
const n = s.seats.length;
|
||||||
|
let i = from;
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
i = nextSeat(i, n, (j) => s.seats[j]!.status !== 'playing');
|
||||||
|
if (canAct(s, i)) {
|
||||||
|
s.turnIndex = i;
|
||||||
|
s.clock.turnStartedAt = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seat = s.seats[i]!.seat;
|
||||||
|
s.moves.push({ kind: 'pass', seat });
|
||||||
|
events.push({ type: 'forcedPass', seat });
|
||||||
|
}
|
||||||
|
s.turnIndex = i;
|
||||||
|
rankAndFinish(s, 'stalemate', events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기권·시간패 처리(§6, §8.5). 2인은 상대 승, 4인은 말 제거·남은 벽 소멸. */
|
||||||
|
function removeSeat(s: QuoridorState, i: number, status: 'resigned' | 'timedOut', now: number, events: GameEvent[]): void {
|
||||||
|
const x = s.seats[i]!;
|
||||||
|
clearPending(s);
|
||||||
|
x.status = status;
|
||||||
|
x.outOrder = ++s.outCount;
|
||||||
|
events.push({ type: status === 'resigned' ? 'playerResigned' : 'timeLoss', seat: x.seat });
|
||||||
|
if (four(s)) {
|
||||||
|
x.pos = null;
|
||||||
|
x.wallsLeft = 0;
|
||||||
|
}
|
||||||
|
if (playing(s).length <= 1) {
|
||||||
|
rankAndFinish(s, status === 'resigned' ? 'resign' : 'timeLoss', events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s.turnIndex === i) advance(s, i, now, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이미 검증된 말 이동. 도착 판정 → 종료 또는 계속 → 차례 넘기기. */
|
||||||
|
function movePawn(s: QuoridorState, i: number, to: Cell, auto: boolean, now: number, events: GameEvent[]): void {
|
||||||
|
const x = s.seats[i]!;
|
||||||
|
const from = x.pos!;
|
||||||
|
x.pos = { c: to.c, r: to.r };
|
||||||
|
s.moves.push({ kind: 'move', seat: x.seat, from, to: x.pos, auto });
|
||||||
|
clearPending(s);
|
||||||
|
events.push({ type: 'pawnMoved', seat: x.seat, from, to: x.pos, auto });
|
||||||
|
if (isGoal(x.seat, to.c, to.r)) {
|
||||||
|
x.status = 'finished';
|
||||||
|
x.finishOrder = s.seats.filter((y) => y.finishOrder !== null).length + 1;
|
||||||
|
events.push({ type: 'playerFinished', seat: x.seat, order: x.finishOrder });
|
||||||
|
if (!(four(s) && s.options.continueForRanking)) {
|
||||||
|
rankAndFinish(s, 'reachedGoal', events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
x.pos = null;
|
||||||
|
if (playing(s).length <= 1) {
|
||||||
|
// 마지막 1명은 자동으로 다음 순위.
|
||||||
|
rankAndFinish(s, 'lastPlayerStanding', events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advance(s, i, now, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeWall(s: QuoridorState, i: number, w: Wall, now: number, events: GameEvent[]): void {
|
||||||
|
const x = s.seats[i]!;
|
||||||
|
const wall = { c: w.c, r: w.r, o: w.o };
|
||||||
|
s.walls.push(wall);
|
||||||
|
setWall(s.wallIndex, wall, true);
|
||||||
|
x.wallsLeft--;
|
||||||
|
s.moves.push({ kind: 'wall', seat: x.seat, wall });
|
||||||
|
clearPending(s);
|
||||||
|
events.push({ type: 'wallPlaced', seat: x.seat, wall });
|
||||||
|
advance(s, i, now, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §8.5 autoMove: 목표까지 거리가 가장 짧아지는 칸(동률은 RNG). */
|
||||||
|
function autoTarget(s: QuoridorState, i: number, rng: Rng): Cell | null {
|
||||||
|
const x = s.seats[i]!;
|
||||||
|
const cands = pawnMovesFor(s, i);
|
||||||
|
if (cands.length === 0) return null;
|
||||||
|
let best = Infinity;
|
||||||
|
let pool: Cell[] = [];
|
||||||
|
for (const c of cands) {
|
||||||
|
const d = pathLength(s.wallIndex, c, x.seat);
|
||||||
|
const dd = d < 0 ? 999 : d;
|
||||||
|
if (dd < best) {
|
||||||
|
best = dd;
|
||||||
|
pool = [c];
|
||||||
|
} else if (dd === best) pool.push(c);
|
||||||
|
}
|
||||||
|
return pool[rng.int(pool.length)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 직전 행동부터 되돌릴 기록 시작 인덱스. 없으면 -1. */
|
||||||
|
function undoStart(s: QuoridorState, seat: Seat): number {
|
||||||
|
let lastAction = -1;
|
||||||
|
for (let k = s.moves.length - 1; k >= 0; k--) {
|
||||||
|
if (s.moves[k]!.kind !== 'pass') {
|
||||||
|
lastAction = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastAction < 0) return -1;
|
||||||
|
if (four(s)) return s.moves[lastAction]!.seat === seat ? lastAction : -1;
|
||||||
|
for (let k = s.moves.length - 1; k >= 0; k--) {
|
||||||
|
const m = s.moves[k]!;
|
||||||
|
if (m.kind !== 'pass' && m.seat === seat) return k;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기록 끝에서 `plies`개를 거꾸로 되돌린다(벽 개수 포함). */
|
||||||
|
function rewind(s: QuoridorState, plies: number): void {
|
||||||
|
for (let k = 0; k < plies; k++) {
|
||||||
|
const m = s.moves.pop()!;
|
||||||
|
if (m.kind === 'move') {
|
||||||
|
const x = bySeat(s, m.seat);
|
||||||
|
if (!x.pos || !sameCell(x.pos, m.to)) throw new Error('undo: move record does not match the board');
|
||||||
|
x.pos = { ...m.from };
|
||||||
|
} else if (m.kind === 'wall') {
|
||||||
|
const last = s.walls.pop();
|
||||||
|
if (!last || last.c !== m.wall.c || last.r !== m.wall.r || last.o !== m.wall.o) throw new Error('undo: wall record mismatch');
|
||||||
|
setWall(s.wallIndex, last, false);
|
||||||
|
bySeat(s, m.seat).wallsLeft++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargeClock(s: QuoridorState, i: number, now: number): boolean {
|
||||||
|
if (s.options.timeControl.kind !== 'fischer') return true;
|
||||||
|
const seat = s.seats[i]!.seat;
|
||||||
|
s.clock.remainingMs[seat] -= now - s.clock.turnStartedAt;
|
||||||
|
if (s.clock.remainingMs[seat] <= 0) {
|
||||||
|
s.clock.remainingMs[seat] = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
s.clock.remainingMs[seat] += s.options.timeControl.incSec * 1000;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- definition
|
||||||
|
|
||||||
|
export const quoridor: GameDefinition<QuoridorState, QuoridorAction, QuoridorView, QuoridorOptions> = {
|
||||||
|
id: 'quoridor',
|
||||||
|
nameKo: '벽 쌓기 길찾기',
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 4,
|
||||||
|
stateVersion: 1,
|
||||||
|
defaultOptions: quoridorOptionsSchema.parse({}),
|
||||||
|
optionsSchema: quoridorOptionsSchema as unknown as z.ZodType<QuoridorOptions>,
|
||||||
|
actionSchema: quoridorActionSchema as z.ZodType<QuoridorAction>,
|
||||||
|
playerCountError: (_o, n) => playerCountError(n),
|
||||||
|
|
||||||
|
setup({ players, options, rng, now }) {
|
||||||
|
const err = playerCountError(players.length);
|
||||||
|
if (err) throw new Error(`quoridor: ${err}`);
|
||||||
|
const order = players.length === 4 ? ORDER_4 : ORDER_2;
|
||||||
|
const assigned = options.seatOrder === 'random' ? rng.shuffle(players) : [...players];
|
||||||
|
const walls = wallsFor(options, players.length);
|
||||||
|
const base = options.timeControl.baseSec * 1000;
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
seats: order.map((seat, k) => ({ seat, playerId: assigned[k]!, pos: { ...START[seat] }, wallsLeft: walls, status: 'playing' as const, finishOrder: null, outOrder: null })),
|
||||||
|
turnIndex: 0,
|
||||||
|
walls: [],
|
||||||
|
wallIndex: emptyWallIndex(),
|
||||||
|
moves: [],
|
||||||
|
phase: 'playing',
|
||||||
|
clock: { remainingMs: recordOf(base), turnStartedAt: now, autoMoveStreak: recordOf(0) },
|
||||||
|
undo: { usedBy: recordOf(0), pending: null },
|
||||||
|
draw: { pendingBy: null, accepted: [], lastOfferMove: recordOf(-1) },
|
||||||
|
outCount: 0,
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
validate(s, actor, a) {
|
||||||
|
const i = seatIndexOf(s, actor);
|
||||||
|
if (i < 0) return fail('이 게임의 플레이어가 아니에요.');
|
||||||
|
if (s.phase !== 'playing') return fail('게임이 이미 끝났어요.');
|
||||||
|
const x = s.seats[i]!;
|
||||||
|
if (x.status !== 'playing') return fail(x.status === 'finished' ? '이미 목표에 도착했어요.' : '이미 게임에서 빠졌어요.');
|
||||||
|
const myTurn = s.turnIndex === i;
|
||||||
|
switch (a.type) {
|
||||||
|
case 'move': {
|
||||||
|
if (!myTurn) return fail('지금은 내 차례가 아니에요.');
|
||||||
|
const to = a.to;
|
||||||
|
if (pawnMovesFor(s, i).some((m) => sameCell(m, to))) return ok;
|
||||||
|
return fail(moveRefusal(s.wallIndex, pawnsOf(s), x.pos!, to, s.options.diagonalJumpTrigger));
|
||||||
|
}
|
||||||
|
case 'placeWall': {
|
||||||
|
if (!myTurn) return fail('지금은 내 차례가 아니에요.');
|
||||||
|
const why = wallRefusal(s, i, { c: a.c, r: a.r, o: a.o });
|
||||||
|
return why ? fail(why) : ok;
|
||||||
|
}
|
||||||
|
case 'timeout':
|
||||||
|
return myTurn ? ok : fail('지금은 내 차례가 아니에요.');
|
||||||
|
case 'requestUndo': {
|
||||||
|
if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.');
|
||||||
|
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||||
|
if (s.undo.usedBy[x.seat] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.');
|
||||||
|
if (undoStart(s, x.seat) < 0) return fail(four(s) ? '방금 내가 한 행동만, 다음 사람이 두기 전에 무를 수 있어요.' : '아직 무를 수가 없어요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case 'respondUndo': {
|
||||||
|
const p = s.undo.pending;
|
||||||
|
if (!p || p.by === x.seat || p.accepted.includes(x.seat)) return fail('응답할 무르기 요청이 없어요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case 'offerDraw': {
|
||||||
|
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
|
||||||
|
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||||
|
const last = s.draw.lastOfferMove[x.seat];
|
||||||
|
if (last >= 0 && !s.moves.slice(last).some((m) => m.kind !== 'pass' && m.seat === x.seat)) {
|
||||||
|
return fail('무승부 제안은 한 번 더 둔 뒤에 다시 할 수 있어요.');
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case 'respondDraw': {
|
||||||
|
const by = s.draw.pendingBy;
|
||||||
|
if (!by || by === x.seat || s.draw.accepted.includes(x.seat)) return fail('응답할 무승부 제안이 없어요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
case 'cancelOffer':
|
||||||
|
if (s.undo.pending?.by !== x.seat && s.draw.pendingBy !== x.seat) return fail('취소할 요청이 없어요.');
|
||||||
|
return ok;
|
||||||
|
case 'resign':
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
return fail('알 수 없는 행동이에요.');
|
||||||
|
},
|
||||||
|
|
||||||
|
apply(prev, actor, a, { rng, now }) {
|
||||||
|
const s = structuredClone(prev);
|
||||||
|
const i = seatIndexOf(s, actor);
|
||||||
|
const x = s.seats[i]!;
|
||||||
|
const seat = x.seat;
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
switch (a.type) {
|
||||||
|
case 'move':
|
||||||
|
case 'placeWall': {
|
||||||
|
if (!chargeClock(s, i, now)) {
|
||||||
|
removeSeat(s, i, 'timedOut', now, events);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
s.clock.autoMoveStreak[seat] = 0;
|
||||||
|
if (a.type === 'move') {
|
||||||
|
if (!pawnMovesFor(s, i).some((m) => sameCell(m, a.to))) throw new Error('illegal pawn move');
|
||||||
|
movePawn(s, i, a.to, false, now, events);
|
||||||
|
} else {
|
||||||
|
if (wallRefusal(s, i, a)) throw new Error('illegal wall');
|
||||||
|
placeWall(s, i, a, now, events);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'timeout': {
|
||||||
|
const tc = s.options.timeControl.kind;
|
||||||
|
if (tc === 'perMove' && s.options.timeoutPolicy === 'autoMove' && s.clock.autoMoveStreak[seat] < s.options.autoMoveLimit) {
|
||||||
|
s.clock.autoMoveStreak[seat]++;
|
||||||
|
events.push({ type: 'timeoutAutoMove', seat });
|
||||||
|
const to = autoTarget(s, i, rng);
|
||||||
|
if (to) movePawn(s, i, to, true, now, events);
|
||||||
|
else {
|
||||||
|
s.moves.push({ kind: 'pass', seat });
|
||||||
|
clearPending(s);
|
||||||
|
events.push({ type: 'forcedPass', seat });
|
||||||
|
advance(s, i, now, events);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tc === 'fischer') s.clock.remainingMs[seat] = 0;
|
||||||
|
removeSeat(s, i, 'timedOut', now, events);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'requestUndo': {
|
||||||
|
const start = undoStart(s, seat);
|
||||||
|
s.undo.pending = { by: seat, accepted: [], plies: s.moves.length - start };
|
||||||
|
events.push({ type: 'undoRequested', seat });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'respondUndo': {
|
||||||
|
const p = s.undo.pending!;
|
||||||
|
if (!a.accept) {
|
||||||
|
s.undo.pending = null;
|
||||||
|
events.push({ type: 'undoDeclined', seat: p.by, by: seat });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
p.accepted.push(seat);
|
||||||
|
if (!respondersOf(s, p.by).every((y) => p.accepted.includes(y))) {
|
||||||
|
events.push({ type: 'undoAcceptedBy', seat: p.by, by: seat });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
s.undo.pending = null;
|
||||||
|
// 생각하던 사람이 쓴 시간은 차감하고, 시계는 그대로 둔다.
|
||||||
|
if (s.options.timeControl.kind === 'fischer') {
|
||||||
|
const thinker = s.seats[s.turnIndex]!.seat;
|
||||||
|
s.clock.remainingMs[thinker] = Math.max(0, s.clock.remainingMs[thinker] - (now - s.clock.turnStartedAt));
|
||||||
|
}
|
||||||
|
rewind(s, p.plies);
|
||||||
|
s.undo.usedBy[p.by]++;
|
||||||
|
s.turnIndex = s.seats.findIndex((y) => y.seat === p.by);
|
||||||
|
s.clock.turnStartedAt = now;
|
||||||
|
for (const y of ALL_SEATS) s.draw.lastOfferMove[y] = Math.min(s.draw.lastOfferMove[y], s.moves.length);
|
||||||
|
events.push({ type: 'undoAccepted', seat: p.by, plies: p.plies });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'offerDraw':
|
||||||
|
s.draw.pendingBy = seat;
|
||||||
|
s.draw.accepted = [];
|
||||||
|
s.draw.lastOfferMove[seat] = s.moves.length;
|
||||||
|
events.push({ type: 'drawOffered', seat });
|
||||||
|
break;
|
||||||
|
case 'respondDraw': {
|
||||||
|
if (!a.accept) {
|
||||||
|
s.draw.pendingBy = null;
|
||||||
|
s.draw.accepted = [];
|
||||||
|
events.push({ type: 'drawDeclined', by: seat });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
s.draw.accepted.push(seat);
|
||||||
|
if (respondersOf(s, s.draw.pendingBy!).every((y) => s.draw.accepted.includes(y))) {
|
||||||
|
events.push({ type: 'drawAccepted' });
|
||||||
|
rankAndFinish(s, 'drawAgreed', events);
|
||||||
|
} else events.push({ type: 'drawAcceptedBy', by: seat });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'cancelOffer':
|
||||||
|
if (s.undo.pending?.by === seat) s.undo.pending = null;
|
||||||
|
if (s.draw.pendingBy === seat) {
|
||||||
|
s.draw.pendingBy = null;
|
||||||
|
s.draw.accepted = [];
|
||||||
|
}
|
||||||
|
events.push({ type: 'offerCancelled', seat });
|
||||||
|
break;
|
||||||
|
case 'resign':
|
||||||
|
removeSeat(s, i, 'resigned', now, events);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return { state: s, events };
|
||||||
|
},
|
||||||
|
|
||||||
|
view(s, viewer) {
|
||||||
|
const vi = viewer === null ? -1 : seatIndexOf(s, viewer);
|
||||||
|
const mySeat = vi >= 0 ? s.seats[vi]!.seat : null;
|
||||||
|
const mine = s.phase === 'playing' && vi >= 0 && vi === s.turnIndex && s.seats[vi]!.status === 'playing';
|
||||||
|
const limit = undoLimit(s.options);
|
||||||
|
const me = vi >= 0 ? s.seats[vi]! : null;
|
||||||
|
let lastMove: QuoridorMove | null = null;
|
||||||
|
for (let k = s.moves.length - 1; k >= 0; k--) {
|
||||||
|
if (s.moves[k]!.kind !== 'pass') {
|
||||||
|
lastMove = s.moves[k]!;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
size: SIZE,
|
||||||
|
seats: s.seats.map((x) => ({
|
||||||
|
seat: x.seat,
|
||||||
|
playerId: x.playerId,
|
||||||
|
pos: x.pos,
|
||||||
|
wallsLeft: x.wallsLeft,
|
||||||
|
status: x.status,
|
||||||
|
finishOrder: x.finishOrder,
|
||||||
|
distance: distanceOf(s, x),
|
||||||
|
autoMoveStreak: s.clock.autoMoveStreak[x.seat],
|
||||||
|
})),
|
||||||
|
mySeat,
|
||||||
|
turn: s.seats[s.turnIndex]!.seat,
|
||||||
|
phase: s.phase,
|
||||||
|
walls: s.walls,
|
||||||
|
moves: s.moves,
|
||||||
|
moveCount: s.moves.length,
|
||||||
|
lastMove,
|
||||||
|
legalPawnMoves: mine ? pawnMovesFor(s, vi) : null,
|
||||||
|
legalWalls: mine ? legalWallsFor(s, vi, true) : null,
|
||||||
|
myShortestPath: mine && s.options.showPath && me?.pos ? shortestPath(s.wallIndex, me.pos, me.seat) : null,
|
||||||
|
wallsPerPlayer: wallsFor(s.options, s.seats.length),
|
||||||
|
diagonalJumpTrigger: s.options.diagonalJumpTrigger,
|
||||||
|
continueForRanking: four(s) && s.options.continueForRanking,
|
||||||
|
showPath: s.options.showPath,
|
||||||
|
timeControl: s.options.timeControl,
|
||||||
|
timeoutPolicy: s.options.timeoutPolicy,
|
||||||
|
remainingMs: s.clock.remainingMs,
|
||||||
|
turnStartedAt: s.clock.turnStartedAt,
|
||||||
|
undo: {
|
||||||
|
allowed: s.options.undo !== 'off',
|
||||||
|
remaining: Object.fromEntries(ALL_SEATS.map((y) => [y, Number.isFinite(limit) ? limit - s.undo.usedBy[y] : null])) as Record<Seat, number | null>,
|
||||||
|
pending: s.undo.pending,
|
||||||
|
responders: s.undo.pending ? respondersOf(s, s.undo.pending.by) : [],
|
||||||
|
},
|
||||||
|
draw: {
|
||||||
|
allowed: s.options.drawOffer,
|
||||||
|
pendingBy: s.draw.pendingBy,
|
||||||
|
accepted: s.draw.accepted,
|
||||||
|
responders: s.draw.pendingBy ? respondersOf(s, s.draw.pendingBy) : [],
|
||||||
|
},
|
||||||
|
result: s.result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
activePlayers(s) {
|
||||||
|
if (s.phase !== 'playing') return [];
|
||||||
|
const out = [s.seats[s.turnIndex]!.playerId];
|
||||||
|
const p = s.undo.pending;
|
||||||
|
const waiting = p ? respondersOf(s, p.by).filter((y) => !p.accepted.includes(y)) : s.draw.pendingBy ? respondersOf(s, s.draw.pendingBy).filter((y) => !s.draw.accepted.includes(y)) : [];
|
||||||
|
for (const y of waiting) {
|
||||||
|
const id = bySeat(s, y).playerId;
|
||||||
|
if (!out.includes(id)) out.push(id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
deadline(s) {
|
||||||
|
if (s.phase !== 'playing') return null;
|
||||||
|
const tc = s.options.timeControl;
|
||||||
|
const start = s.clock.turnStartedAt;
|
||||||
|
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
|
||||||
|
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.seats[s.turnIndex]!.seat];
|
||||||
|
return start + s.options.idleLimitSec * 1000;
|
||||||
|
},
|
||||||
|
|
||||||
|
timeoutPlayers(s) {
|
||||||
|
return s.phase === 'playing' ? [s.seats[s.turnIndex]!.playerId] : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
onTimeout() {
|
||||||
|
return { type: 'timeout' };
|
||||||
|
},
|
||||||
|
|
||||||
|
onLeave(s, player) {
|
||||||
|
const i = seatIndexOf(s, player);
|
||||||
|
if (i < 0 || s.phase !== 'playing' || s.seats[i]!.status !== 'playing') return null;
|
||||||
|
return { type: 'resign' };
|
||||||
|
},
|
||||||
|
|
||||||
|
result(s): GameResult | null {
|
||||||
|
const r = s.result;
|
||||||
|
if (!r) return null;
|
||||||
|
const groups = new Map<number, PlayerId[]>();
|
||||||
|
for (const x of r.ranking) groups.set(x.rank, [...(groups.get(x.rank) ?? []), x.playerId]);
|
||||||
|
const reason: Record<QuoridorReason, GameResult['reason']> = {
|
||||||
|
reachedGoal: 'normal',
|
||||||
|
lastPlayerStanding: 'normal',
|
||||||
|
stalemate: 'normal',
|
||||||
|
resign: 'resign',
|
||||||
|
timeLoss: 'timeout',
|
||||||
|
drawAgreed: 'draw-agreed',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps),
|
||||||
|
summary: r.summaryKo,
|
||||||
|
reason: reason[r.reason],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 테스트용: 차례인 사람의 모든 말 이동·벽, 대기 중인 요청의 응답. */
|
||||||
|
legalActions(s, p) {
|
||||||
|
const i = seatIndexOf(s, p);
|
||||||
|
if (i < 0 || s.phase !== 'playing' || s.seats[i]!.status !== 'playing') return [];
|
||||||
|
const seat = s.seats[i]!.seat;
|
||||||
|
const out: QuoridorAction[] = [];
|
||||||
|
const u = s.undo.pending;
|
||||||
|
if (u && u.by !== seat && !u.accepted.includes(seat)) out.push({ type: 'respondUndo', accept: true }, { type: 'respondUndo', accept: false });
|
||||||
|
const d = s.draw.pendingBy;
|
||||||
|
if (d && d !== seat && !s.draw.accepted.includes(seat)) out.push({ type: 'respondDraw', accept: false });
|
||||||
|
if (s.turnIndex !== i) return out;
|
||||||
|
for (const to of pawnMovesFor(s, i)) out.push({ type: 'move', to });
|
||||||
|
for (const w of legalWallsFor(s, i)) out.push({ type: 'placeWall', c: w.c, r: w.r, o: w.o });
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 완전 정보 게임: 숨길 패가 없다(RNG는 상태 밖, view의 힌트는 차례인 사람에게만 — 테스트에서 따로 확인). */
|
||||||
|
secretsOf() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
696
packages/games/src/quoridor/quoridor.test.ts
Normal file
696
packages/games/src/quoridor/quoridor.test.ts
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
|
||||||
|
import {
|
||||||
|
legalWallsFor,
|
||||||
|
pawnMovesFor,
|
||||||
|
quoridor,
|
||||||
|
type Cell,
|
||||||
|
type QuoridorAction,
|
||||||
|
type QuoridorOptions,
|
||||||
|
type QuoridorState,
|
||||||
|
type Seat,
|
||||||
|
} from './index';
|
||||||
|
import { cellLabel, emptyWallIndex, isGoal, pathLength, setWall, type Wall } from './board';
|
||||||
|
|
||||||
|
const rng = (n = 1) => SeededRng.fromSeed([n, n + 1, n + 2, n + 3]);
|
||||||
|
/** 'e5' → { c: 4, r: 4 } (열 a~i 왼→오, 행 1~9 아래→위). */
|
||||||
|
const at = (l: string): Cell => ({ c: 'abcdefghi'.indexOf(l[0]!), r: Number(l.slice(1)) - 1 });
|
||||||
|
/** 'h:d4' → 앵커 d4 (c=3, r=3)의 가로벽. */
|
||||||
|
const wall = (w: string): Wall => ({ o: w[0] as 'h' | 'v', ...at(w.slice(2)) });
|
||||||
|
|
||||||
|
/** joinOrder: 2인 A=남 B=북, 4인 A=남 B=서 C=북 D=동. */
|
||||||
|
function make(n: 2 | 4 = 2, opts: Partial<QuoridorOptions> = {}): QuoridorState {
|
||||||
|
const options = quoridor.optionsSchema.parse({ seatOrder: 'joinOrder', ...opts });
|
||||||
|
const players = n === 2 ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
|
||||||
|
return quoridor.setup({ players, options, rng: rng(), now: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 말 위치·벽·차례를 직접 정한 국면. */
|
||||||
|
function position(s: QuoridorState, pos: Partial<Record<Seat, string | null>>, walls: string[] = [], turn?: Seat): QuoridorState {
|
||||||
|
for (const x of s.seats) if (x.seat in pos) x.pos = pos[x.seat] ? at(pos[x.seat]!) : null;
|
||||||
|
s.walls = walls.map(wall);
|
||||||
|
s.wallIndex = emptyWallIndex();
|
||||||
|
for (const w of s.walls) setWall(s.wallIndex, w, true);
|
||||||
|
if (turn) s.turnIndex = s.seats.findIndex((x) => x.seat === turn);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function act(s: QuoridorState, actor: string, a: QuoridorAction, now = 1000, seed = 9): QuoridorState {
|
||||||
|
const v = quoridor.validate(s, actor, a);
|
||||||
|
if (!v.ok) throw new Error(v.reason);
|
||||||
|
return quoridor.apply(s, actor, a, { rng: rng(seed), now }).state;
|
||||||
|
}
|
||||||
|
const turnId = (s: QuoridorState) => s.seats[s.turnIndex]!.playerId;
|
||||||
|
const go = (s: QuoridorState, to: string, now = 1000) => act(s, turnId(s), { type: 'move', to: at(to) }, now);
|
||||||
|
const put = (s: QuoridorState, w: string, now = 1000) => act(s, turnId(s), { type: 'placeWall', ...wall(w) }, now);
|
||||||
|
const tryGo = (s: QuoridorState, to: string) => quoridor.validate(s, turnId(s), { type: 'move', to: at(to) });
|
||||||
|
const tryPut = (s: QuoridorState, w: string, actor = turnId(s)) => quoridor.validate(s, actor, { type: 'placeWall', ...wall(w) });
|
||||||
|
const moves = (s: QuoridorState) => pawnMovesFor(s, s.turnIndex).map(cellLabel).sort();
|
||||||
|
const seatOf = (s: QuoridorState, seat: Seat) => s.seats.find((x) => x.seat === seat)!;
|
||||||
|
const reason = (v: { ok: boolean; reason?: string }) => (v.ok ? 'ok' : v.reason);
|
||||||
|
|
||||||
|
describe('setup', () => {
|
||||||
|
test('2인: 남 e1 → 9행, 북 e9 → 1행, 남 먼저, 벽 10개씩', () => {
|
||||||
|
const s = make(2);
|
||||||
|
expect(s.seats.map((x) => [x.seat, x.playerId, cellLabel(x.pos!), x.wallsLeft])).toEqual([
|
||||||
|
['S', 'A', 'e1', 10],
|
||||||
|
['N', 'B', 'e9', 10],
|
||||||
|
]);
|
||||||
|
expect(turnId(s)).toBe('A');
|
||||||
|
expect(isGoal('S', 0, 8) && isGoal('N', 8, 0) && !isGoal('S', 4, 7)).toBe(true);
|
||||||
|
expect(quoridor.view(s, null).seats.map((x) => x.distance)).toEqual([8, 8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4인: 남 e1, 서 a5, 북 e9, 동 i5, 시계 방향 차례, 벽 5개씩; 하우스 룰 2인 벽 수', () => {
|
||||||
|
let s = make(4);
|
||||||
|
expect(s.seats.map((x) => `${x.seat}${cellLabel(x.pos!)}${x.wallsLeft}`)).toEqual(['Se15', 'Wa55', 'Ne95', 'Ei55']);
|
||||||
|
expect(isGoal('W', 8, 3) && isGoal('E', 0, 3)).toBe(true);
|
||||||
|
s = go(s, 'e2');
|
||||||
|
expect(turnId(s)).toBe('B');
|
||||||
|
s = go(s, 'b5');
|
||||||
|
expect(turnId(s)).toBe('C');
|
||||||
|
s = go(s, 'e8');
|
||||||
|
expect(turnId(s)).toBe('D');
|
||||||
|
s = go(s, 'h5');
|
||||||
|
expect(turnId(s)).toBe('A');
|
||||||
|
expect(make(2, { wallsPerPlayer: 6 }).seats[0]!.wallsLeft).toBe(6);
|
||||||
|
expect(make(4, { wallsPerPlayer: 6 }).seats[0]!.wallsLeft).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3인은 시작할 수 없어요', () => {
|
||||||
|
expect(quoridor.playerCountError!(quoridor.defaultOptions, 3)).toBe('2명 또는 4명이 필요해요.');
|
||||||
|
expect(quoridor.playerCountError!(quoridor.defaultOptions, 2)).toBeNull();
|
||||||
|
expect(quoridor.playerCountError!(quoridor.defaultOptions, 4)).toBeNull();
|
||||||
|
expect(() => quoridor.setup({ players: ['A', 'B', 'C'], options: quoridor.defaultOptions, rng: rng(), now: 0 })).toThrow('2명 또는 4명');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('random 자리 배정은 RNG로, 같은 시드면 같은 결과', () => {
|
||||||
|
const seats = (n: number) => quoridor.setup({ players: ['A', 'B', 'C', 'D'], options: quoridor.defaultOptions, rng: rng(n), now: 0 }).seats.map((x) => x.playerId).join('');
|
||||||
|
expect(seats(3)).toBe(seats(3));
|
||||||
|
expect(new Set([1, 2, 3, 4, 5, 6, 7, 8].map(seats)).size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pawn moves (§5.2)', () => {
|
||||||
|
test('기본 이동 4방향; 벽·판 끝·다른 말 때문에 막힘', () => {
|
||||||
|
let s = position(make(), { S: 'e5', N: 'e9' });
|
||||||
|
expect(moves(s)).toEqual(['d5', 'e4', 'e6', 'f5']);
|
||||||
|
s = position(make(), { S: 'e5', N: 'e9' }, ['h:e5']); // e5↔e6, f5↔f6 차단
|
||||||
|
expect(moves(s)).toEqual(['d5', 'e4', 'f5']);
|
||||||
|
expect(reason(tryGo(s, 'e6'))).toBe('벽이 가로막고 있어요.');
|
||||||
|
s = position(make(), { S: 'a1', N: 'e9' });
|
||||||
|
expect(moves(s)).toEqual(['a2', 'b1']);
|
||||||
|
s = position(make(), { S: 'e5', N: 'e9' }, ['v:d4']); // d4↔e4 … 세로벽 d4: (d4|e4), (d5|e5)
|
||||||
|
expect(moves(s)).toEqual(['e4', 'e6', 'f5']);
|
||||||
|
expect(reason(tryGo(s, 'd5'))).toBe('벽이 가로막고 있어요.');
|
||||||
|
expect(reason(tryGo(s, 'e7'))).toBe('한 번에 한 칸씩만 움직일 수 있어요.');
|
||||||
|
expect(reason(tryGo(s, 'f6'))).toBe('대각선으로는 상대 말을 넘을 때만 갈 수 있어요.');
|
||||||
|
expect(reason(tryGo(s, 'e9'))).toBe('다른 말이 있는 칸이에요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('직선 점프 성공', () => {
|
||||||
|
const s = position(make(), { S: 'e5', N: 'e6' });
|
||||||
|
expect(moves(s)).toEqual(['d5', 'e4', 'e7', 'f5']);
|
||||||
|
expect(seatOf(go(s, 'e7'), 'S').pos).toEqual(at('e7'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('점프할 칸 뒤가 벽 / 판 끝 / 다른 말이면 대각 2곳, 한쪽이 벽이면 1곳', () => {
|
||||||
|
// 뒤가 벽
|
||||||
|
let s = position(make(), { S: 'e5', N: 'e6' }, ['h:d6']); // d6↔d7, e6↔e7 차단
|
||||||
|
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
|
||||||
|
// 뒤가 판 끝
|
||||||
|
s = position(make(), { S: 'e8', N: 'e9' });
|
||||||
|
expect(moves(s)).toEqual(['d8', 'd9', 'e7', 'f8', 'f9']);
|
||||||
|
// 뒤가 다른 말(4인)
|
||||||
|
s = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
|
||||||
|
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
|
||||||
|
// 대각 한쪽이 벽: 북 말과 d6 사이 세로벽(d6|e6)
|
||||||
|
expect(reason(tryPut(position(make(), { S: 'e5', N: 'e6' }, ['h:d6']), 'v:d6'))).toBe('다른 벽과 겹쳐요.');
|
||||||
|
s = position(make(), { S: 'e5', N: 'e6' }, ['h:e6', 'v:d6']); // e6↔e7, f6↔f7 / (d6|e6),(d7|e7)
|
||||||
|
expect(moves(s)).toEqual(['d5', 'e4', 'f5', 'f6']);
|
||||||
|
expect(reason(tryGo(s, 'd6'))).toBe('벽이 가로막고 있어요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wallOnly: 판 끝·다른 말 뒤로는 대각 불가, 벽 뒤로는 가능', () => {
|
||||||
|
let s = position(make(2, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e8', N: 'e9' });
|
||||||
|
expect(moves(s)).toEqual(['d8', 'e7', 'f8']);
|
||||||
|
expect(reason(tryGo(s, 'd9'))).toBe('이 방은 넘을 말 뒤에 벽이 있을 때만 대각선으로 갈 수 있어요.');
|
||||||
|
s = position(make(4, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
|
||||||
|
expect(moves(s)).toEqual(['d5', 'e4', 'f5']);
|
||||||
|
s = position(make(2, { diagonalJumpTrigger: 'wallOnly' }), { S: 'e5', N: 'e6' }, ['h:d6']);
|
||||||
|
expect(moves(s)).toEqual(['d5', 'd6', 'e4', 'f5', 'f6']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('직선 점프가 되면 대각은 거부, 말 2개 연속 점프 거부', () => {
|
||||||
|
let s = position(make(), { S: 'e5', N: 'e6' });
|
||||||
|
expect(reason(tryGo(s, 'd6'))).toBe('곧게 뛰어넘을 수 있을 때는 대각선으로 갈 수 없어요.');
|
||||||
|
s = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
|
||||||
|
expect(reason(tryGo(s, 'e8'))).toBe('말은 한 번에 하나만 뛰어넘을 수 있어요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('판에서 빠진 말은 장애물이 아니에요', () => {
|
||||||
|
const s = position(make(4), { S: 'e5', N: 'e6', W: null, E: 'i1' }, [], 'S');
|
||||||
|
seatOf(s, 'W').status = 'resigned';
|
||||||
|
expect(moves(s)).toEqual(['d5', 'e4', 'e7', 'f5']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('walls (§5.3)', () => {
|
||||||
|
test('벽 설치: 개수 감소, 기록, 차례 넘김', () => {
|
||||||
|
const s = put(make(), 'h:e5');
|
||||||
|
expect(s.walls).toEqual([wall('h:e5')]);
|
||||||
|
expect(seatOf(s, 'S').wallsLeft).toBe(9);
|
||||||
|
expect(s.moves.at(-1)).toEqual({ kind: 'wall', seat: 'S', wall: wall('h:e5') });
|
||||||
|
expect(turnId(s)).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('겹침: 같은 방향 반 칸 겹침·같은 앵커 십자 거부, 다른 앵커의 T자·끝 맞닿음은 허용', () => {
|
||||||
|
const s = position(make(), { S: 'e1', N: 'e9' }, ['h:d4']);
|
||||||
|
for (const w of ['h:c4', 'h:d4', 'h:e4', 'v:d4']) expect(reason(tryPut(s, w))).toBe('다른 벽과 겹쳐요.');
|
||||||
|
expect(reason(tryPut(s, 'h:b4'))).toBe('ok'); // 끝이 맞닿는 이웃
|
||||||
|
expect(reason(tryPut(s, 'v:d5'))).toBe('ok'); // T자 (d4 앵커 가로벽의 가운데에 세로벽 끝)
|
||||||
|
expect(reason(tryPut(s, 'v:e4'))).toBe('ok'); // 오른쪽 끝에 세로벽 가운데(ㅓ 모양)
|
||||||
|
const v = position(make(), { S: 'e1', N: 'e9' }, ['v:d4']);
|
||||||
|
for (const w of ['v:d3', 'v:d4', 'v:d5', 'h:d4']) expect(reason(tryPut(v, w))).toBe('다른 벽과 겹쳐요.');
|
||||||
|
expect(reason(tryPut(v, 'v:d6'))).toBe('ok');
|
||||||
|
expect(reason(tryPut(v, 'h:c5'))).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('판 밖 앵커(0~7 밖) 거부, 남은 벽 0개면 거부', () => {
|
||||||
|
const s = make();
|
||||||
|
for (const [c, r] of [
|
||||||
|
[-1, 0],
|
||||||
|
[8, 0],
|
||||||
|
[0, 8],
|
||||||
|
[0, -1],
|
||||||
|
]) {
|
||||||
|
expect(reason(quoridor.validate(s, 'A', { type: 'placeWall', c: c!, r: r!, o: 'h' }))).toBe('벽은 판 밖으로 나갈 수 없어요.');
|
||||||
|
}
|
||||||
|
expect(quoridor.validate(s, 'A', { type: 'placeWall', c: 7, r: 7, o: 'v' }).ok).toBe(true);
|
||||||
|
seatOf(s, 'S').wallsLeft = 0;
|
||||||
|
expect(reason(tryPut(s, 'h:a1'))).toBe('남은 벽이 없어요.');
|
||||||
|
expect(legalWallsFor(s, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 북(e9) 주변 상자: e8·e9를 좌우 세로벽으로 감싸고 e7↔e8 가로벽으로 닫으면 북은 1행에 못 간다.
|
||||||
|
const box = ['v:d8', 'v:e8'];
|
||||||
|
test('상대 길을 완전히 막는 벽 거부, 자기 길을 막는 벽 거부', () => {
|
||||||
|
const s = position(make(), { S: 'a1', N: 'e9' }, box, 'S');
|
||||||
|
expect(reason(tryPut(s, 'h:e7'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
|
||||||
|
const n = position(make(), { S: 'a1', N: 'e9' }, box, 'N');
|
||||||
|
expect(reason(tryPut(n, 'h:e7'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
|
||||||
|
expect(reason(tryPut(n, 'h:a7'))).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4인에서 제3자(서)의 길을 막는 벽 거부', () => {
|
||||||
|
const s = position(make(4), {}, ['h:a5', 'h:a4'], 'S'); // 서 a5·b5를 위아래로 감쌈
|
||||||
|
expect(reason(tryPut(s, 'v:b5'))).toBe('누군가의 길을 완전히 막을 수 없어요.');
|
||||||
|
expect(legalWallsFor(s, 0).some((w) => w.o === 'v' && w.c === 1 && w.r === 4)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('말 때문에만 막히는 경우는 허용(경로 검사는 벽만 봄)', () => {
|
||||||
|
// 북의 유일한 출구 e7을 남의 말이 막고 있어도 벽 설치는 합법.
|
||||||
|
const s = position(make(), { S: 'e7', N: 'e9' }, box, 'S');
|
||||||
|
expect(reason(tryPut(s, 'h:a1'))).toBe('ok');
|
||||||
|
expect(pathLength(s.wallIndex, at('e9'), 'N')).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('경로 검사에서 기권한 사람은 빠져요', () => {
|
||||||
|
let s = position(make(4), {}, ['h:a5', 'h:a4'], 'S');
|
||||||
|
s = act(s, 'B', { type: 'resign' });
|
||||||
|
expect(seatOf(s, 'W').pos).toBeNull();
|
||||||
|
expect(reason(tryPut(s, 'v:b5'))).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legalWalls 미리보기: 상대 거리 변화량', () => {
|
||||||
|
const s = make();
|
||||||
|
const lw = quoridor.view(s, 'A').legalWalls!;
|
||||||
|
expect(lw.length).toBe(128);
|
||||||
|
// e8↔e9·f8↔f9 가로벽은 북과 남의 길을 모두 1칸 늘린다(둘 다 e열을 지나므로).
|
||||||
|
expect(lw.find((w) => w.o === 'h' && w.c === 4 && w.r === 7)).toMatchObject({ opp: 1, me: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('goal and game end (§6)', () => {
|
||||||
|
test('목표 줄 도착 즉시 승리', () => {
|
||||||
|
const s = go(position(make(), { S: 'c8', N: 'a5' }), 'c9');
|
||||||
|
expect(s.phase).toBe('finished');
|
||||||
|
expect(s.result).toMatchObject({ reason: 'reachedGoal' });
|
||||||
|
expect(quoridor.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal' });
|
||||||
|
expect(s.result!.summaryKo).toBe('남 승리 — 1수 만에 도착');
|
||||||
|
expect(s.result!.perPlayer.map((p) => p.distanceLeft)).toEqual([0, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('점프로 도착해도 승리', () => {
|
||||||
|
const s = go(position(make(), { S: 'e7', N: 'e8' }), 'e9');
|
||||||
|
expect(s.result?.ranking[0]).toEqual({ playerId: 'A', rank: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('북도 1행에 닿으면 승리', () => {
|
||||||
|
let s = position(make(), { S: 'a1', N: 'e2' }, [], 'N');
|
||||||
|
s = go(s, 'e1');
|
||||||
|
expect(quoridor.result(s)!.ranking).toEqual([['B'], ['A']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4인 결과 순위: 도착자 1등, 나머지는 남은 거리 순, 같으면 공동', () => {
|
||||||
|
let s = position(make(4), { S: 'e8', W: 'f5', N: 'c4', E: 'f3' }, [], 'S');
|
||||||
|
s = go(s, 'e9');
|
||||||
|
expect(s.result!.ranking).toEqual([
|
||||||
|
{ playerId: 'A', rank: 1 },
|
||||||
|
{ playerId: 'B', rank: 2 },
|
||||||
|
{ playerId: 'C', rank: 2 },
|
||||||
|
{ playerId: 'D', rank: 4 },
|
||||||
|
]);
|
||||||
|
expect(quoridor.result(s)!.ranking).toEqual([['A'], ['B', 'C'], ['D']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4인 기권: 말 제거, 남은 벽 소멸, 설치한 벽 유지, 1명 남으면 그 사람 1등', () => {
|
||||||
|
let s = make(4);
|
||||||
|
s = put(s, 'h:a1'); // 남
|
||||||
|
s = put(s, 'h:c1'); // 서
|
||||||
|
s = act(s, 'B', { type: 'resign' });
|
||||||
|
expect(seatOf(s, 'W')).toMatchObject({ pos: null, wallsLeft: 0, status: 'resigned' });
|
||||||
|
expect(s.walls).toHaveLength(2);
|
||||||
|
expect(turnId(s)).toBe('C');
|
||||||
|
s = act(s, 'C', { type: 'resign' }); // 차례인 사람이 기권 → 다음(동)
|
||||||
|
expect(turnId(s)).toBe('D');
|
||||||
|
s = act(s, 'A', { type: 'resign' });
|
||||||
|
expect(s.phase).toBe('finished');
|
||||||
|
expect(quoridor.result(s)).toMatchObject({ ranking: [['D'], ['A'], ['C'], ['B']], reason: 'resign' });
|
||||||
|
expect(s.result!.summaryKo).toBe('다른 사람이 모두 기권해 동 승리');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2인 기권은 상대 승', () => {
|
||||||
|
const s = act(make(), 'A', { type: 'resign' });
|
||||||
|
expect(quoridor.result(s)).toMatchObject({ ranking: [['B'], ['A']], reason: 'resign', summary: '남 기권으로 북 승리' });
|
||||||
|
expect(quoridor.onLeave!(s, 'B')).toBeNull();
|
||||||
|
expect(quoridor.onLeave!(make(), 'A')).toEqual({ type: 'resign' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('continueForRanking: 도착자를 판에서 빼고 계속, 도착 순서대로 순위', () => {
|
||||||
|
let s = position(make(4, { continueForRanking: true }), { S: 'e8', W: 'h5', N: 'c2', E: 'f3' }, [], 'S');
|
||||||
|
s = go(s, 'e9');
|
||||||
|
expect(s.phase).toBe('playing');
|
||||||
|
expect(seatOf(s, 'S')).toMatchObject({ status: 'finished', finishOrder: 1, pos: null });
|
||||||
|
expect(turnId(s)).toBe('B');
|
||||||
|
s = go(s, 'i5'); // 서 도착
|
||||||
|
expect(turnId(s)).toBe('C');
|
||||||
|
expect(quoridor.validate(s, 'A', { type: 'resign' }).ok).toBe(false);
|
||||||
|
s = go(s, 'c1'); // 북 도착 → 동만 남음
|
||||||
|
expect(s.phase).toBe('finished');
|
||||||
|
expect(quoridor.result(s)!.ranking).toEqual([['A'], ['B'], ['C'], ['D']]);
|
||||||
|
expect(s.result!.summaryKo).toBe('남 → 서 → 북 순서로 도착');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('무승부 합의: 남은 사람 모두 동의해야, 다음 사람이 두면 자동 취소', () => {
|
||||||
|
let s = act(make(4), 'B', { type: 'offerDraw' });
|
||||||
|
expect(quoridor.activePlayers(s)).toEqual(['A', 'C', 'D']);
|
||||||
|
s = act(s, 'A', { type: 'respondDraw', accept: true });
|
||||||
|
s = act(s, 'C', { type: 'respondDraw', accept: true });
|
||||||
|
expect(s.phase).toBe('playing');
|
||||||
|
const moved = go(s, 'e2');
|
||||||
|
expect(moved.draw.pendingBy).toBeNull();
|
||||||
|
expect(reason(quoridor.validate(moved, 'B', { type: 'offerDraw' }))).toBe('무승부 제안은 한 번 더 둔 뒤에 다시 할 수 있어요.');
|
||||||
|
s = act(s, 'D', { type: 'respondDraw', accept: true });
|
||||||
|
expect(quoridor.result(s)).toMatchObject({ ranking: [['A', 'B', 'C', 'D']], reason: 'draw-agreed', summary: '합의 무승부' });
|
||||||
|
const declined = act(act(make(), 'A', { type: 'offerDraw' }), 'B', { type: 'respondDraw', accept: false });
|
||||||
|
expect(declined.draw.pendingBy).toBeNull();
|
||||||
|
expect(quoridor.validate(make(2, { drawOffer: false }), 'A', { type: 'offerDraw' }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undo', () => {
|
||||||
|
test('2인: 상대가 이미 뒀으면 2개 되돌림(벽 개수 포함), 거절하면 그대로, 횟수 제한', () => {
|
||||||
|
let s = put(make(2, { undo: '1' }), 'h:e5'); // 남 벽
|
||||||
|
s = go(s, 'e8'); // 북 이동
|
||||||
|
s = act(s, 'A', { type: 'requestUndo' });
|
||||||
|
expect(s.undo.pending).toEqual({ by: 'S', accepted: [], plies: 2 });
|
||||||
|
expect(reason(quoridor.validate(s, 'A', { type: 'respondUndo', accept: true }))).toBe('응답할 무르기 요청이 없어요.');
|
||||||
|
const no = act(s, 'B', { type: 'respondUndo', accept: false });
|
||||||
|
expect(no.walls).toHaveLength(1);
|
||||||
|
expect(no.undo.pending).toBeNull();
|
||||||
|
s = act(s, 'B', { type: 'respondUndo', accept: true });
|
||||||
|
expect(s.walls).toEqual([]);
|
||||||
|
expect(seatOf(s, 'S').wallsLeft).toBe(10);
|
||||||
|
expect(seatOf(s, 'N').pos).toEqual(at('e9'));
|
||||||
|
expect(turnId(s)).toBe('A');
|
||||||
|
expect(s.moves).toEqual([]);
|
||||||
|
s = go(s, 'e2');
|
||||||
|
expect(reason(quoridor.validate(s, 'A', { type: 'requestUndo' }))).toBe('무르기 횟수를 다 썼어요.');
|
||||||
|
expect(quoridor.validate(make(2, { undo: 'off' }), 'A', { type: 'requestUndo' }).ok).toBe(false);
|
||||||
|
expect(reason(quoridor.validate(make(), 'A', { type: 'requestUndo' }))).toBe('아직 무를 수가 없어요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4인: 다른 3명 전원 수락 시에만, 다음 사람이 행동한 뒤에는 요청 불가', () => {
|
||||||
|
let s = go(make(4), 'e2');
|
||||||
|
s = act(s, 'A', { type: 'requestUndo' });
|
||||||
|
expect(quoridor.activePlayers(s)).toEqual(['B', 'C', 'D']);
|
||||||
|
s = act(s, 'B', { type: 'respondUndo', accept: true });
|
||||||
|
s = act(s, 'C', { type: 'respondUndo', accept: true });
|
||||||
|
expect(seatOf(s, 'S').pos).toEqual(at('e2'));
|
||||||
|
expect(reason(quoridor.validate(s, 'C', { type: 'respondUndo', accept: true }))).toBe('응답할 무르기 요청이 없어요.');
|
||||||
|
const declined = act(s, 'D', { type: 'respondUndo', accept: false });
|
||||||
|
expect(seatOf(declined, 'S').pos).toEqual(at('e2'));
|
||||||
|
expect(declined.undo.pending).toBeNull();
|
||||||
|
s = act(s, 'D', { type: 'respondUndo', accept: true });
|
||||||
|
expect(seatOf(s, 'S').pos).toEqual(at('e1'));
|
||||||
|
expect(turnId(s)).toBe('A');
|
||||||
|
expect(s.undo.usedBy.S).toBe(1);
|
||||||
|
// 다음 사람(서)이 행동한 뒤에는 남이 무를 수 없다.
|
||||||
|
s = go(go(s, 'e2'), 'b5');
|
||||||
|
expect(reason(quoridor.validate(s, 'A', { type: 'requestUndo' }))).toBe('방금 내가 한 행동만, 다음 사람이 두기 전에 무를 수 있어요.');
|
||||||
|
expect(quoridor.validate(s, 'B', { type: 'requestUndo' }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('forced pass (§5.4)', () => {
|
||||||
|
// 4인: 남 a1, 서 a2(위), 북 a3(서 뒤), 동 b2. 세로벽 a1|b1·a2|b2. 남은 벽이 없으면 말·벽 모두 불가.
|
||||||
|
const stuck = (wallsLeft: number) => {
|
||||||
|
const s = position(make(4), { S: 'a1', W: 'a2', N: 'a3', E: 'b2' }, ['v:a1'], 'E');
|
||||||
|
seatOf(s, 'S').wallsLeft = wallsLeft;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
test('말 이동도 벽도 불가하면 자동 패스', () => {
|
||||||
|
const s0 = stuck(0);
|
||||||
|
expect(pawnMovesFor(s0, 0)).toEqual([]);
|
||||||
|
const s = go(s0, 'c2'); // 동이 움직여도 남은 여전히 갇힘
|
||||||
|
expect(s.moves.slice(-2)).toEqual([
|
||||||
|
{ kind: 'move', seat: 'E', from: at('b2'), to: at('c2'), auto: false },
|
||||||
|
{ kind: 'pass', seat: 'S' },
|
||||||
|
]);
|
||||||
|
expect(turnId(s)).toBe('B');
|
||||||
|
});
|
||||||
|
test('벽이 남아 있으면 패스하지 않음', () => {
|
||||||
|
const s = go(stuck(1), 'c2');
|
||||||
|
expect(turnId(s)).toBe('A');
|
||||||
|
expect(quoridor.view(s, 'A').legalPawnMoves).toEqual([]);
|
||||||
|
expect(quoridor.view(s, 'A').legalWalls!.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('time (§8.5)', () => {
|
||||||
|
test('autoMove: 최단 거리가 줄어드는 칸으로 이동', () => {
|
||||||
|
const s = act(make(), 'A', { type: 'timeout' });
|
||||||
|
expect(seatOf(s, 'S').pos).toEqual(at('e2'));
|
||||||
|
expect(s.moves[0]).toMatchObject({ kind: 'move', auto: true });
|
||||||
|
expect(s.clock.autoMoveStreak.S).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoMove 동률은 RNG: 같은 시드 같은 결과, 시드에 따라 둘 다 나옴', () => {
|
||||||
|
// 남 e5 앞에 북 e6, 그 뒤에 서 e7 → 대각 d6·f6 모두 거리 3.
|
||||||
|
const base = position(make(4), { S: 'e5', N: 'e6', W: 'e7', E: 'i1' }, [], 'S');
|
||||||
|
const pick = (seed: number) => cellLabel(seatOf(act(base, 'A', { type: 'timeout' }, 1000, seed), 'S').pos!);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let seed = 1; seed <= 20; seed++) {
|
||||||
|
expect(pick(seed)).toBe(pick(seed));
|
||||||
|
seen.add(pick(seed));
|
||||||
|
}
|
||||||
|
expect([...seen].sort()).toEqual(['d6', 'f6']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3번 연속 시간 초과면 시간패, 직접 두면 연속 횟수 초기화', () => {
|
||||||
|
let s = act(make(), 'A', { type: 'timeout' });
|
||||||
|
s = go(s, 'e8');
|
||||||
|
s = act(s, 'A', { type: 'timeout' });
|
||||||
|
s = go(s, 'e7');
|
||||||
|
const reset = go(s, 'd3');
|
||||||
|
expect(reset.clock.autoMoveStreak.S).toBe(0);
|
||||||
|
s = act(s, 'A', { type: 'timeout' });
|
||||||
|
expect(s.phase).toBe('finished');
|
||||||
|
expect(quoridor.result(s)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout', summary: '남 시간패로 북 승리' });
|
||||||
|
expect(seatOf(s, 'S').status).toBe('timedOut');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4인 시간패는 말 제거 후 다음 사람 차례', () => {
|
||||||
|
const s = act(make(4, { timeoutPolicy: 'lose' }), 'A', { type: 'timeout' });
|
||||||
|
expect(seatOf(s, 'S')).toMatchObject({ status: 'timedOut', pos: null, wallsLeft: 0 });
|
||||||
|
expect(turnId(s)).toBe('B');
|
||||||
|
expect(s.phase).toBe('playing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lose 정책·idle 한도는 첫 시간 초과에 시간패; 차례인 사람만', () => {
|
||||||
|
expect(act(make(2, { timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).phase).toBe('finished');
|
||||||
|
expect(act(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }), 'A', { type: 'timeout' }).phase).toBe('finished');
|
||||||
|
expect(quoridor.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false);
|
||||||
|
let s = act(go(make(), 'e2'), 'A', { type: 'requestUndo' });
|
||||||
|
expect(quoridor.timeoutPlayers!(s)).toEqual(['B']);
|
||||||
|
s = act(s, 'B', { type: 'timeout' }); // 자동 이동이 요청을 지운다
|
||||||
|
expect(s.undo.pending).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deadline: perMove / none / fischer', () => {
|
||||||
|
expect(quoridor.deadline!(go(make(), 'e2', 7000))).toBe(37_000);
|
||||||
|
expect(quoridor.deadline!(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }))).toBe(300_000);
|
||||||
|
const tc = { kind: 'fischer' as const, perMoveSec: 30, baseSec: 60, incSec: 5 };
|
||||||
|
let s = make(2, { timeControl: tc });
|
||||||
|
expect(quoridor.deadline!(s)).toBe(60_000);
|
||||||
|
s = go(s, 'e2', 10_000);
|
||||||
|
expect(s.clock.remainingMs.S).toBe(55_000);
|
||||||
|
expect(quoridor.deadline!(s)).toBe(70_000);
|
||||||
|
const late = go(s, 'e8', 80_000);
|
||||||
|
expect(late.result).toMatchObject({ reason: 'timeLoss' });
|
||||||
|
expect(late.clock.remainingMs.N).toBe(0);
|
||||||
|
const t = act(s, 'B', { type: 'timeout' });
|
||||||
|
expect(quoridor.result(t)).toMatchObject({ ranking: [['A'], ['B']], reason: 'timeout' });
|
||||||
|
expect(quoridor.deadline!(t)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('views (§8.3)', () => {
|
||||||
|
test('legalPawnMoves/legalWalls/myShortestPath는 차례인 사람에게만, rng 없음', () => {
|
||||||
|
const s = go(make(4), 'e2');
|
||||||
|
const v = quoridor.view(s, 'B');
|
||||||
|
expect(v.legalPawnMoves!.map(cellLabel).sort()).toEqual(['a4', 'a6', 'b5']);
|
||||||
|
expect(v.legalWalls!.length).toBe(128);
|
||||||
|
expect(v.myShortestPath!.length).toBe(8);
|
||||||
|
expect(v.mySeat).toBe('W');
|
||||||
|
for (const viewer of ['A', 'C', 'D', null]) {
|
||||||
|
const o = quoridor.view(s, viewer);
|
||||||
|
expect(o.legalPawnMoves).toBeNull();
|
||||||
|
expect(o.legalWalls).toBeNull();
|
||||||
|
expect(o.myShortestPath).toBeNull();
|
||||||
|
expect(o.seats.map((x) => x.distance)).toEqual([7, 8, 8, 8]);
|
||||||
|
}
|
||||||
|
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(quoridor.view(s, viewer))).not.toMatch(/rng/i);
|
||||||
|
expect(JSON.stringify(s)).not.toMatch(/rng/i);
|
||||||
|
expect(quoridor.view(make(2, { showPath: false }), 'A').myShortestPath).toBeNull();
|
||||||
|
expect(quoridor.view(s, null).mySeat).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('성능: 합법 벽 목록(128 후보 × BFS 4회)이 1ms 수준', () => {
|
||||||
|
// 벽 몇 개가 있는 4인 중반 국면.
|
||||||
|
let s = make(4);
|
||||||
|
for (const w of ['h:a3', 'h:c3', 'v:e2', 'h:f6', 'v:b6', 'h:d7', 'v:g3', 'h:g1']) s = put(s, w);
|
||||||
|
const t0 = performance.now();
|
||||||
|
const N = 200;
|
||||||
|
let n = 0;
|
||||||
|
for (let k = 0; k < N; k++) n += legalWallsFor(s, s.turnIndex, true).length;
|
||||||
|
const ms = (performance.now() - t0) / N;
|
||||||
|
console.log(` legalWalls (4인, 변화량 포함): ${ms.toFixed(3)}ms / 회, 후보 ${n / N}개`);
|
||||||
|
expect(n / N).toBeGreaterThan(80);
|
||||||
|
expect(ms).toBeLessThan(5); // 부하가 있는 CI에서도 여유 있게
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- random playouts
|
||||||
|
|
||||||
|
const TOTAL_SEATS = (s: QuoridorState) => s.seats.length;
|
||||||
|
|
||||||
|
/** 매 단계 불변식: 벽 보존, 인덱스 일치, 겹침 없음, 남은 사람 모두 길 있음, 말 겹침 없음, 정보 누출 없음. */
|
||||||
|
function invariant(s: QuoridorState): void {
|
||||||
|
const per = s.seats.length === 4 ? 5 : s.options.wallsPerPlayer;
|
||||||
|
let placed = 0;
|
||||||
|
for (const x of s.seats) {
|
||||||
|
const mine = s.moves.filter((m) => m.kind === 'wall' && m.seat === x.seat).length;
|
||||||
|
placed += mine;
|
||||||
|
if (x.status === 'resigned' || x.status === 'timedOut') {
|
||||||
|
if (s.seats.length === 4 && x.wallsLeft !== 0) throw new Error('removed seat kept walls');
|
||||||
|
} else if (mine + x.wallsLeft !== per) throw new Error(`wall conservation broken for ${x.seat}`);
|
||||||
|
}
|
||||||
|
if (placed !== s.walls.length) throw new Error('walls list != wall moves');
|
||||||
|
const idx = emptyWallIndex();
|
||||||
|
for (const w of s.walls) {
|
||||||
|
if (w.c < 0 || w.c > 7 || w.r < 0 || w.r > 7) throw new Error('wall anchor out of range');
|
||||||
|
setWall(idx, w, true);
|
||||||
|
}
|
||||||
|
if (JSON.stringify(idx) !== JSON.stringify(s.wallIndex)) throw new Error('wallIndex out of sync');
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const w of s.walls) {
|
||||||
|
// 같은 방향 반 칸 겹침, 같은 앵커 십자.
|
||||||
|
const k = w.o === 'h' ? [`h${w.c}${w.r}`, `h${w.c + 1}${w.r}`, `x${w.c}${w.r}`] : [`v${w.c}${w.r}`, `v${w.c}${w.r + 1}`, `x${w.c}${w.r}`];
|
||||||
|
for (const key of k) {
|
||||||
|
if (keys.has(key)) throw new Error('overlapping walls');
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cells = new Set<string>();
|
||||||
|
for (const x of s.seats) {
|
||||||
|
if (!x.pos) continue;
|
||||||
|
const k = cellLabel(x.pos);
|
||||||
|
if (cells.has(k)) throw new Error('two pawns on one cell');
|
||||||
|
cells.add(k);
|
||||||
|
if (x.status === 'playing' && pathLength(s.wallIndex, x.pos, x.seat) < 0) throw new Error(`${x.seat} has no path`);
|
||||||
|
}
|
||||||
|
if (s.phase === 'playing') {
|
||||||
|
const cur = s.seats[s.turnIndex]!;
|
||||||
|
if (cur.status !== 'playing') throw new Error('turn on a removed seat');
|
||||||
|
if (s.seats.filter((x) => x.status === 'playing').length < 2) throw new Error('game should have ended');
|
||||||
|
for (const p of quoridor.timeoutPlayers!(s)) {
|
||||||
|
const v = quoridor.validate(s, p, quoridor.onTimeout(s, p));
|
||||||
|
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
|
||||||
|
}
|
||||||
|
} else if (!s.result || !quoridor.result(s)) throw new Error('finished without result');
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkViews(s: QuoridorState): void {
|
||||||
|
for (const viewer of [...s.seats.map((x) => x.playerId), null]) {
|
||||||
|
const v = quoridor.view(s, viewer);
|
||||||
|
const mine = s.phase === 'playing' && viewer === s.seats[s.turnIndex]!.playerId;
|
||||||
|
if (!mine && (v.legalWalls !== null || v.legalPawnMoves !== null || v.myShortestPath !== null)) throw new Error('turn-only hint leaked');
|
||||||
|
if (/rng/i.test(JSON.stringify(v))) throw new Error('rng in view');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 빠른 무작위 대국: 최단 길 이동 / 무작위 이동 / 무작위 벽(거부 샘플링) / 가끔 시간 초과·무르기·무승부·기권. */
|
||||||
|
function fastPlayout(seed: number, n: 2 | 4, options: QuoridorOptions, checkEvery: number) {
|
||||||
|
const r = SeededRng.fromSeed(seedN(seed));
|
||||||
|
const pick = SeededRng.fromSeed(seedN(seed + 77_777));
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
const players = n === 2 ? ['A', 'B'] : ['A', 'B', 'C', 'D'];
|
||||||
|
let s = quoridor.setup({ players, options, rng: r, now });
|
||||||
|
const trace: { actor: string; action: QuoridorAction }[] = [];
|
||||||
|
const step = (actor: string, action: QuoridorAction) => {
|
||||||
|
const v = quoridor.validate(s, actor, action);
|
||||||
|
if (!v.ok) throw new Error(`seed ${seed}: rejected ${JSON.stringify(action)}: ${v.reason}`);
|
||||||
|
now += 1000;
|
||||||
|
s = quoridor.apply(s, actor, action, { rng: r, now }).state;
|
||||||
|
trace.push({ actor, action });
|
||||||
|
};
|
||||||
|
for (let k = 0; k < 3000 && s.phase === 'playing'; k++) {
|
||||||
|
if (k % checkEvery === 0) {
|
||||||
|
invariant(s);
|
||||||
|
checkViews(s);
|
||||||
|
if (JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
|
||||||
|
}
|
||||||
|
// 대기 중인 요청이 있으면 응답부터.
|
||||||
|
const pend = s.undo.pending ?? (s.draw.pendingBy ? { by: s.draw.pendingBy, accepted: s.draw.accepted } : null);
|
||||||
|
if (pend) {
|
||||||
|
const responder = s.seats.find((x) => x.status === 'playing' && x.seat !== pend.by && !pend.accepted.includes(x.seat));
|
||||||
|
if (responder) {
|
||||||
|
const accept = pick.next() < 0.7;
|
||||||
|
step(responder.playerId, s.undo.pending ? { type: 'respondUndo', accept } : { type: 'respondDraw', accept: accept && pick.next() < 0.3 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const i = s.turnIndex;
|
||||||
|
const me = s.seats[i]!;
|
||||||
|
const roll = pick.next();
|
||||||
|
if (roll < 0.03) {
|
||||||
|
step(me.playerId, { type: 'timeout' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (roll < 0.04) {
|
||||||
|
const any = s.seats.filter((x) => x.status === 'playing')[pick.int(s.seats.filter((x) => x.status === 'playing').length)]!;
|
||||||
|
const a: QuoridorAction = pick.next() < 0.5 ? { type: 'requestUndo' } : { type: 'offerDraw' };
|
||||||
|
if (quoridor.validate(s, any.playerId, a).ok) {
|
||||||
|
step(any.playerId, a);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (roll < 0.042 && n === 4) {
|
||||||
|
step(s.seats.filter((x) => x.status === 'playing')[0]!.playerId, { type: 'resign' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (roll < 0.4 && me.wallsLeft > 0) {
|
||||||
|
let placed = false;
|
||||||
|
for (let t = 0; t < 12 && !placed; t++) {
|
||||||
|
const a: QuoridorAction = { type: 'placeWall', c: pick.int(8), r: pick.int(8), o: pick.int(2) ? 'h' : 'v' };
|
||||||
|
if (quoridor.validate(s, me.playerId, a).ok) {
|
||||||
|
step(me.playerId, a);
|
||||||
|
placed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (placed) continue;
|
||||||
|
}
|
||||||
|
const cands = pawnMovesFor(s, i);
|
||||||
|
if (cands.length === 0) {
|
||||||
|
const lw = legalWallsFor(s, i);
|
||||||
|
step(me.playerId, lw.length ? { type: 'placeWall', c: lw[0]!.c, r: lw[0]!.r, o: lw[0]!.o } : { type: 'timeout' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let to = cands[pick.int(cands.length)]!;
|
||||||
|
if (pick.next() < 0.6) {
|
||||||
|
let best = Infinity;
|
||||||
|
for (const c of cands) {
|
||||||
|
const d = pathLength(s.wallIndex, c, me.seat);
|
||||||
|
if (d >= 0 && d < best) {
|
||||||
|
best = d;
|
||||||
|
to = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step(me.playerId, { type: 'move', to });
|
||||||
|
}
|
||||||
|
invariant(s);
|
||||||
|
return { state: s, trace, players };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('random playouts (docs/09 §7)', () => {
|
||||||
|
test('1,000판 무작위 대국: 끝까지, 불변식, 시간 초과 합법, 정보 누출 없음', () => {
|
||||||
|
const t0 = performance.now();
|
||||||
|
let finished = 0;
|
||||||
|
let steps = 0;
|
||||||
|
const reasons: Record<string, number> = {};
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const n = i % 3 === 0 ? 4 : 2;
|
||||||
|
const options = quoridor.optionsSchema.parse({
|
||||||
|
seatOrder: i % 2 ? 'random' : 'joinOrder',
|
||||||
|
diagonalJumpTrigger: i % 5 === 0 ? 'wallOnly' : 'wallEdgePawn',
|
||||||
|
continueForRanking: i % 6 === 0,
|
||||||
|
wallsPerPlayer: 6 + (i % 5),
|
||||||
|
undo: i % 7 === 0 ? 'unlimited' : '3',
|
||||||
|
timeControl: i % 11 === 0 ? { kind: 'fischer', perMoveSec: 30, baseSec: 300, incSec: 3 } : { kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 },
|
||||||
|
});
|
||||||
|
const { state, trace } = fastPlayout(i, n, options, i < 100 ? 1 : 7);
|
||||||
|
if (state.phase === 'finished') finished++;
|
||||||
|
steps += trace.length;
|
||||||
|
reasons[state.result?.reason ?? 'none'] = (reasons[state.result?.reason ?? 'none'] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
console.log(` 1000 playouts: ${finished} finished, avg ${(steps / 1000).toFixed(1)} actions, ${(performance.now() - t0).toFixed(0)}ms`, reasons);
|
||||||
|
expect(finished).toBe(1000);
|
||||||
|
expect(reasons.reachedGoal).toBeGreaterThan(500);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test('결정성: 같은 시드·같은 행동이면 같은 결과, 기록 재생도 같은 상태', () => {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const n = i % 2 ? 4 : 2;
|
||||||
|
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 4 === 1 });
|
||||||
|
const a = fastPlayout(5000 + i, n, options, 1000);
|
||||||
|
const b = fastPlayout(5000 + i, n, options, 1000);
|
||||||
|
expect(JSON.stringify(a.state)).toBe(JSON.stringify(b.state));
|
||||||
|
const r = SeededRng.fromSeed(seedN(5000 + i));
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let s = quoridor.setup({ players: a.players, options, rng: r, now });
|
||||||
|
for (const { actor, action } of a.trace) {
|
||||||
|
now += 1000;
|
||||||
|
s = quoridor.apply(s, actor, action, { rng: r, now }).state;
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(s)).toBe(JSON.stringify(a.state));
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test('엔진 randomPlayout(완전 무작위 합법 행동)으로도 끝까지', () => {
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const players = i % 3 === 0 ? ['A', 'B', 'C', 'D'] : ['A', 'B'];
|
||||||
|
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 2 === 0, wallsPerPlayer: 6 });
|
||||||
|
const inv = (st: unknown) => {
|
||||||
|
const s = st as QuoridorState;
|
||||||
|
if (TOTAL_SEATS(s) !== players.length) throw new Error('seat count changed');
|
||||||
|
invariant(s);
|
||||||
|
};
|
||||||
|
const rep = randomPlayout(quoridor, { players, options, seed: seedN(900 + i), invariant: inv, maxSteps: 20_000, maxApplyMs: 50 });
|
||||||
|
expect(rep.finished).toBe(true);
|
||||||
|
expect(quoridor.result(rep.finalState as QuoridorState)).not.toBeNull();
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ export const CATALOG: CatalogEntry[] = [
|
|||||||
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
||||||
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
|
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
|
||||||
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
|
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
|
||||||
|
{ id: 'quoridor', nameKo: '벽 쌓기 길찾기', minPlayers: 2, maxPlayers: 4, minutes: '10~20', level: '쉬움', blurb: '말을 움직이거나 벽을 세워 먼저 반대편 끝줄로! · 2명 또는 4명', available: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||||
|
|||||||
Reference in New Issue
Block a user