diff --git a/apps/web/src/games/quoridor/index.tsx b/apps/web/src/games/quoridor/index.tsx new file mode 100644 index 0000000..ebd5343 --- /dev/null +++ b/apps/web/src/games/quoridor/index.tsx @@ -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 = { S: '남', W: '서', N: '북', E: '동' }; +const SEAT_COLOR: Record = { S: '#dc2626', W: '#16a34a', N: '#2563eb', E: '#d97706' }; +const COLOR_KO: Record = { 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 = { 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 ( + + {!ghost && } + + + {SEAT_KO[seat]} + + + ); +} + +type Mode = 'move' | 'wall'; + +function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProps) { + 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('move'); + const [orient, setOrient] = useState('h'); + const [wallAt, setWallAt] = useState(null); // 화면 앵커(고정된 미리보기) + const [hoverAnchor, setHoverAnchor] = useState(null); + const [hoverCell, setHoverCell] = useState(null); + const [movePreview, setMovePreview] = useState(null); + const [cursor, setCursor] = useState({ x: 4, y: 8 }); + const [kbd, setKbd] = useState(false); + const [announce, setAnnounce] = useState(''); + const svgRef = useRef(null); + const prevCount = useRef(v.moveCount); + const prevWalls = useRef(v.walls.length); + const [newWall, setNewWall] = useState(null); + + const legalCells = useMemo(() => { + const m = new Set(); + 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(); + 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): { 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 = { 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 ( +
+ + { + 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 ; + })} + {Array.from({ length: N }, (_, i) => { + const bottom = t.screenToCell({ x: i, y: N - 1 }); + const left = t.screenToCell({ x: 0, y: i }); + return ( + + + {t.swap ? bottom.r + 1 : 'abcdefghi'[bottom.c]} + + + {t.swap ? 'abcdefghi'[left.c] : left.r + 1} + + + ); + })} + {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 ( + + ); + })} + {lastFrom && } + {path && path.length > 1 && ( + `${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 ; + })} + {v.walls.map((w) => { + const s = t.wallToScreen(w); + const k = wallKey(w); + return ( + + ); + })} + {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 ( + + {turn && } + + + ); + })} + {lastTo && } + {ghostCell && me && } + {preview && previewAnchor && ( + + )} + {myTurn && mode === 'wall' && ( + + {Array.from({ length: 64 }, (_, i) => { + const a = { x: i % 8, y: Math.floor(i / 8) }; + const w = t.screenToWall(a, 'h'); + return ; + })} + + )} + {myTurn && kbd && mode === 'move' && ( + + )} + {myTurn && kbd && mode === 'wall' && } + +

+ {announce} +

+ {me && v.phase === 'playing' && me.status === 'playing' && ( +
+ + +
+ )} + {myTurn && mode === 'wall' && ( +
+ + {!preview + ? '판의 홈(칸 사이) 교차점을 눌러 벽 자리를 골라요.' + : preview.why + ? `${wallText(preview.wall, t.swap)}: ${preview.why}` + : `${wallText(preview.wall, t.swap)} · 상대 길 +${preview.legal!.opp}칸${preview.legal!.me ? ` · 내 길 +${preview.legal!.me}칸` : ''}`} + + + +
+ )} + {myTurn && mode === 'move' && movePreview && ( +
+ + +
+ )} + {me && v.phase === 'playing' && !(myTurn && mode === 'wall') && ( +

+ {me.status === 'playing' + ? `내 말은 ${COLOR_KO[me.seat]}(${SEAT_KO[me.seat]})이에요. ${myGoal} 줄(${COLOR_KO[me.seat]} 띠)에 먼저 닿으면 이겨요.` + : me.status === 'finished' + ? '목표에 도착했어요! 남은 사람들의 순위가 정해질 때까지 지켜봐요.' + : '게임에서 빠졌어요. 계속 지켜볼 수 있어요.'} +

+ )} + {!me && ( +

+ {v.seats.map((x) => `${nick(x.playerId)}: ${COLOR_KO[x.seat]} → ${SIDE_KO[t.goalSide(x.seat)]} 줄`).join(' · ')} +

+ )} +
+ ); +} + +// ---------------------------------------------------------------- side panel + +function WallBars({ left, total }: { left: number; total: number }) { + return ( + + {Array.from({ length: total }, (_, i) => ( + + ))} + + ); +} + +function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: QuoridorSeatView; players: Record; 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 = ( + + {Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} + + ); + } else if (active) { + const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000; + clock = ; + } + const state = + x.status === 'finished' + ? `${x.finishOrder}등 도착` + : x.status === 'resigned' + ? '기권' + : x.status === 'timedOut' + ? '시간패' + : `남은 길 ${x.distance}칸${x.autoMoveStreak > 0 ? ` · 자리 비움 ${x.autoMoveStreak}번` : ''}`; + return ( +
+ + + + +
+
+ {u.nickname} + {x.playerId === me && (나)} +
+
+ {COLOR_KO[x.seat]} · {state} +
+
+ + + {x.wallsLeft} + + {clock} +
+ ); +} + +function QuoridorSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [confirm, setConfirm] = useState(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) => ( + + ))} + {live && ( +
+ {v.undo.allowed && ( + + )} + {v.draw.allowed && ( + + )} + +
+ )} + {waitingOthers && ( +
+ 다른 사람의 답을 기다리는 중… + {outgoing && ( + + )} +
+ )} + + {incoming && requester && ( + {}} label="다른 사람의 요청"> +

{incoming === 'undo' ? `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무르기를 요청했어요` : `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무승부를 제안했어요`}

+

+ {incoming === 'undo' + ? `수락하면 ${v.undo.pending!.plies}개의 행동이 되돌려져요.${four ? ' 다른 사람도 모두 수락해야 해요.' : ''}` + : `수락하면 이 판은 무승부로 끝나요.${four ? ' 남은 사람이 모두 수락해야 해요.' : ''}`} +

+
+ + +
+
+ )} + {confirm === 'resign' && ( + act({ type: 'resign' })} + onClose={() => setConfirm(null)} + /> + )} + {confirm === 'draw' && act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />} + + ); +} + +function MoveList({ view: v }: { view: QuoridorView }) { + const ref = useRef(null); + useEffect(() => { + ref.current?.scrollTo({ top: ref.current.scrollHeight }); + }, [v.moveCount]); + if (v.moveCount === 0) return null; + let n = 0; + return ( +
+ 기록 ({v.moves.filter((m) => m.kind !== 'pass').length}수) +
    + {v.moves.map((m, i) => ( +
  1. + {m.kind !== 'pass' && {++n}. } + + ●{' '} + + {moveText(m, swapFor(v.mySeat))} +
  2. + ))} +
+
+ ); +} + +function statusText(v: QuoridorView, me: string | null, players: Record): 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({ value, options, onChange, label: aria }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label: title, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {title} + {children} +
+ ); +} + +const TIME_PRESETS: [string, string, Partial][] = [ + ['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) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const onOff = (v: boolean) => (v ? 'on' : 'off'); + const known = TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)); + return ( +
+ + [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] } }); + }} + /> + + + [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 })} + /> + 4명이면 1인당 5개로 정해져 있어요. 3명은 할 수 없어요. + + + set({ showPath: k === 'on' })} /> + + + {more && ( + <> + + set({ diagonalJumpTrigger })} + /> + + + set({ seatOrder })} /> + + + set({ continueForRanking: k === 'on' })} /> + + {o.timeControl.kind === 'perMove' && ( + + set({ timeoutPolicy })} /> + + )} + + set({ undo })} /> + + + set({ drawOffer: k === 'on' })} /> + + + )} +
+ ); +} + +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 ( +
+ + {Array.from({ length: n * n }, (_, i) => ( + + ))} + {marks.map(([x, y]) => ( + + + + + ))} + {walls.map(([x, y, o, red], i) => ( + + ))} + {pawns.map(([x, y, s]) => ( + + ))} + +
+ {caption} +
+
+ ); +} + +function Rules({ options }: { options?: QuoridorOptions }) { + const wallOnly = options?.diagonalJumpTrigger === 'wallOnly'; + return ( +
+

+ 9×9 판에서 내 말을 반대편 끝줄까지 먼저 보내면 이겨요. 2명이면 마주 보고, 4명이면 네 방향에서 시작해요(3명은 할 수 없어요). 화면에서는 내 목표 줄이 항상 위쪽이고, 판 가장자리의 색 띠가 각자의 목표 줄이에요. +

+

+ 내 차례에는 말을 한 칸 움직이거나, 벽을 하나 세워요(둘 중 하나만). 벽은 2명이면 1인당 {options?.wallsPerPlayer ?? 10}개, 4명이면 5개예요. 한 번 세운 벽은 옮기거나 치울 수 없어요. +

+

+ 말 이동: 위·아래·왼쪽·오른쪽으로 한 칸. 벽이 있거나 다른 말이 있는 칸으로는 못 가요. 대각선 이동은 기본으로는 안 돼요. +

+

+ 뛰어넘기: 바로 앞에 다른 말이 있으면 그 말을 곧게 뛰어넘을 수 있어요. 넘을 말 뒤가 {wallOnly ? '벽으로 막혀 있으면' : '벽·판 끝·다른 말로 막혀 있으면'} 그 말의 왼쪽이나 오른쪽(대각선)으로 갈 수 있어요. 곧게 넘을 수 있을 때는 대각선으로 못 가고, 한 번에 말 하나만 넘을 수 있어요. +

+
+ + + +
+

+ 벽 세우기: 벽은 칸 2개 길이로, 칸 사이 홈에 세워요. 다른 벽과 겹치거나 같은 자리에서 십자로 엇갈릴 수 없어요. 그리고 누구의 길도 완전히 막으면 안 돼요. 돌아가더라도 모든 사람에게 목표 줄까지 가는 길이 하나는 남아 있어야 해요(말은 길을 막는 것으로 치지 않아요). +

+
+ + +
+

+ 세우는 방법: "벽 세우기"를 누르고 칸 사이 교차점을 누르면 벽이 반투명하게 미리 보여요. 같은 곳을 한 번 더 누르거나 "↻" 버튼으로 가로·세로를 바꾸고, "벽 세우기 확인"을 누르면 세워져요. 세울 수 없는 자리면 빨갛게 보이고 이유가 나와요. 키보드로는 방향키로 고르고 R로 방향을 바꾸고 Enter로 세워요(W로 모드 바꾸기). +

+

+ 이기는 방법: 목표 줄의 아무 칸에나 닿는 순간 이겨요. 4명일 때 나머지 순위는 남은 길이가 짧은 순서예요{options?.continueForRanking ? '(이 방은 1등이 나와도 계속해서 2·3등을 가려요)' : ''}. 움직일 곳도 세울 벽도 없으면 자동으로 차례를 넘겨요. +

+

팁: 벽은 아끼는 것도 전략이에요. 상대가 목표에 가까워졌을 때 길을 크게 돌아가게 만드는 벽이 가장 효과가 커요. 미리보기에 "상대 길 +몇 칸"이 나와요.

+
+ ); +} + +const ui: GameUI = { + 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; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 2a1cc79..60b0d3c 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -19,6 +19,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { bang: () => import('./bang'), 'halli-galli': () => import('./halli-galli'), marble: () => import('./marble'), + quoridor: () => import('./quoridor'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index a88513f..115208e 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -16,6 +16,7 @@ export const GAME_ICON: Record = { marble: '🏙️', bang: '🤠', 'halli-galli': '🔔', + quoridor: '🧱', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index d1d9937..6418196 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -260,6 +260,56 @@ async function marbleStep(p: Page, stats: Record): Promise p.locator('svg[data-my-seat]'); +async function qdTap(p: Page, target: ReturnType) { + 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 { + 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 { + 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 = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -879,6 +929,70 @@ const PLANS: Record = { 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(); + 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); diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 180c602..6bf9bd0 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -16,6 +16,7 @@ import { gostop } from './gostop'; import { bang } from './bang'; import { halliGalli } from './halli-galli'; import { marble } from './marble'; +import { quoridor } from './quoridor'; export const GAMES: Record = { [omok.id]: omok, @@ -35,4 +36,5 @@ export const GAMES: Record = { [bang.id]: bang, [halliGalli.id]: halliGalli, [marble.id]: marble, + [quoridor.id]: quoridor, }; diff --git a/packages/games/src/quoridor/board.ts b/packages/games/src/quoridor/board.ts new file mode 100644 index 0000000..0e0ccb1 --- /dev/null +++ b/packages/games/src/quoridor/board.ts @@ -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 = { S: '남', W: '서', N: '북', E: '동' }; +export const START: Record = { 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(64).fill(false), v: new Array(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(); + 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 '그 칸으로는 갈 수 없어요.'; +} diff --git a/packages/games/src/quoridor/index.ts b/packages/games/src/quoridor/index.ts new file mode 100644 index 0000000..66644b6 --- /dev/null +++ b/packages/games/src/quoridor/index.ts @@ -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; + +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 | { 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; turnStartedAt: number; autoMoveStreak: Record }; + /** `plies` = 수락 시 `moves` 끝에서 되돌릴 항목 수. */ + undo: { usedBy: Record; pending: { by: Seat; accepted: Seat[]; plies: number } | null }; + /** lastOfferMove: 마지막 제안 때의 moves.length(-1 = 없음). */ + draw: { pendingBy: Seat | null; accepted: Seat[]; lastOfferMove: Record }; + 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; + turnStartedAt: number; + undo: { allowed: boolean; remaining: Record; 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 = (v: T): Record => ({ 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(); + 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 = { + id: 'quoridor', + nameKo: '벽 쌓기 길찾기', + minPlayers: 2, + maxPlayers: 4, + stateVersion: 1, + defaultOptions: quoridorOptionsSchema.parse({}), + optionsSchema: quoridorOptionsSchema as unknown as z.ZodType, + actionSchema: quoridorActionSchema as z.ZodType, + 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, + 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(); + for (const x of r.ranking) groups.set(x.rank, [...(groups.get(x.rank) ?? []), x.playerId]); + const reason: Record = { + 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 []; + }, +}; diff --git a/packages/games/src/quoridor/quoridor.test.ts b/packages/games/src/quoridor/quoridor.test.ts new file mode 100644 index 0000000..bc6bc70 --- /dev/null +++ b/packages/games/src/quoridor/quoridor.test.ts @@ -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 = {}): 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>, 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(); + 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(); + 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(); + 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 = {}; + 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); +}); diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index dded0fa..70c2cdf 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -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: '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: '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);