/** 벽 쌓기 길찾기 (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 []; }, };