diff --git a/apps/server/src/rooms/room.test.ts b/apps/server/src/rooms/room.test.ts index 28203a6..61689fe 100644 --- a/apps/server/src/rooms/room.test.ts +++ b/apps/server/src/rooms/room.test.ts @@ -182,3 +182,47 @@ describe('Room isolation and lobby', () => { expect(room.seatOf('B')).toBe(-1); }); }); + +describe('윷놀이 room rules', () => { + const yutGames = () => ({ yut: GAMES.yut! }); + + test('team mode follows the game range to 8 seats and refuses odd counts via playerCountError', () => { + const { room, conn } = harness(yutGames()); + room.join(conn('A'), 'player'); + expect(room.configure('A', { maxPlayers: 4 })).toBeNull(); + expect(room.configure('A', { options: { ...GAMES.yut!.defaultOptions, mode: 'team' } })).toBeNull(); + expect(room.seats).toHaveLength(8); + for (const u of ['B', 'C', 'D', 'E']) room.join(conn(u), 'player'); + expect(room.start('A')).toBe('팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.'); + room.join(conn('F'), 'player'); + expect(room.start('A')).toBeNull(); + room.clearTimer(); + expect((room.game!.state as any).sides.map((s: any) => s.members)).toEqual([ + ['A', 'C', 'E'], + ['B', 'D', 'F'], + ]); + }); + + test('a disconnected player is auto-played after the grace period; reconnecting gets the current view', () => { + const { room, clock, conn } = harness(yutGames()); + const conns = { A: conn('A'), B: conn('B') }; + room.join(conns.A, 'player'); + room.join(conns.B, 'player'); + room.configure('A', { options: { ...GAMES.yut!.defaultOptions, turnSeconds: null } }); + expect(room.start('A')).toBeNull(); + room.clearTimer(); + const cur = (room.game!.state as any).current as 'A' | 'B'; + room.disconnect(conns[cur]); + room.clearTimer(); + expect(room.effectiveDeadline()).toBe(clock.now() + 60_000); + clock.advance(60_000); + room.onTimer(); + room.clearTimer(); + expect((room.game!.state as any).turnSeq).toBe(1); + const back = conn(cur); + room.join(back, 'player'); + room.clearTimer(); + const st = back.msgs.find((m) => m.t === 'state') as any; + expect(st.view.turnSeq).toBe((room.game!.state as any).turnSeq); + }); +}); diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index a42c2f5..3825d96 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -484,6 +484,8 @@ export class Room { if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths)); const range = this.rangeFor(next.gameId, next.options); if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers; + // A room that had every seat its game allows keeps doing so when an option changes the range (e.g. 윷놀이 팀전 8명). + else if (next.gameId === this.config.gameId && this.config.maxPlayers === this.playerRange().max) next.maxPlayers = range.max; next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max); if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`; for (const k of ['visibility', 'allowSpectators', 'chatEnabled', 'chatFilter'] as const) { @@ -598,6 +600,8 @@ export class Room { const lk = this.locked(def.id); const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options); if (!parsed.success) return '설정 값이 올바르지 않아요.'; + const countErr = def.playerCountError?.(parsed.data, players.length); + if (countErr) return countErr; const eff = this.deps.settings?.game(def.id); if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.'; this.config = { ...this.config, options: parsed.data }; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index ccf4031..9e91250 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -10,6 +10,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { seotda: () => import('./seotda'), poker: () => import('./poker'), yacht: () => import('./yacht'), + yut: () => import('./yut'), }; const cache = new Map(); diff --git a/apps/web/src/games/yut/index.tsx b/apps/web/src/games/yut/index.tsx new file mode 100644 index 0000000..f5fca28 --- /dev/null +++ b/apps/web/src/games/yut/index.tsx @@ -0,0 +1,866 @@ +/** 윷놀이 화면 (docs/games/yut.md §9). Board, sticks and pieces are drawn here (SVG/CSS only). */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { Throw, YutLegalMove, YutOptions, YutSideView, YutView } from '@bg/games/yut'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +type From = number | 'home'; +type GameEvent = YutView['log'][number]; + +// The rules module is imported for types only (no zod in this chunk); these small tables mirror packages/games/src/yut/board.ts. +const NODE_NAMES = [ + '참먹이', '도', '개', '걸', '윷', '모', '뒷도', '뒷개', '뒷걸', '뒷윷', + '뒷모', '찌도', '찌개', '찌걸', '찌윷', '찌모', '날도', '날개', '날걸', '날윷', + '앞모도', '앞모개', '방', '속윷', '속모', '뒷모도', '뒷모개', '사려', '안찌', +]; +const THROW_KO: Record = { do: '도', gae: '개', geol: '걸', yut: '윷', mo: '모', backdo: '빽도' }; +const STEPS: Record = { do: 1, gae: 2, geol: 3, yut: 4, mo: 5, backdo: -1 }; +const stepText = (t: Throw) => (t === 'backdo' ? '뒤로 1칸' : `${STEPS[t]}칸`); +const BIG = new Set([0, 5, 10, 15, 22]); +const BIG_LABELS: [number, string][] = [[0, '출발'], [5, '모'], [10, '뒷모'], [15, '찌모'], [22, '방']]; + +// Board geometry (viewBox 0..100): 참먹이 bottom-right, 모 top-right, 뒷모 top-left, 찌모 bottom-left; pieces go anticlockwise. +const LO = 8; +const HI = 92; +const GAP = (HI - LO) / 5; +const POS: [number, number][] = (() => { + const p: [number, number][] = []; + p[0] = [HI, HI]; + for (let i = 1; i <= 4; i++) p[i] = [HI, HI - GAP * i]; + p[5] = [HI, LO]; + for (let i = 6; i <= 9; i++) p[i] = [HI - GAP * (i - 5), LO]; + p[10] = [LO, LO]; + for (let i = 11; i <= 14; i++) p[i] = [LO, LO + GAP * (i - 10)]; + p[15] = [LO, HI]; + for (let i = 16; i <= 19; i++) p[i] = [LO + GAP * (i - 15), HI]; + const lerp = (a: [number, number], b: [number, number], t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + [20, 21, 22, 23, 24].forEach((n, k) => (p[n] = lerp(p[5]!, p[15]!, (k + 1) / 6))); + [25, 26].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 1) / 6))); + [27, 28].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 4) / 6))); + return p; +})(); +/** Where pieces come from / go to (just outside 참먹이). */ +const HOME_PT: [number, number] = [HI + 4, HI + 7]; +const LINES: [number, number][] = [ + ...Array.from({ length: 20 }, (_, i) => [i, (i + 1) % 20] as [number, number]), + [5, 20], [20, 21], [21, 22], [22, 23], [23, 24], [24, 15], + [10, 25], [25, 26], [26, 22], [22, 27], [27, 28], [28, 0], +]; +const R_NODE = 5.1; +const R_BIG = 6.9; + +// Side look: colour + shape (colour-blind safe: every side also has its own shape). +const SOLO_COLORS = ['#dc2626', '#2563eb', '#16a34a', '#eab308']; +const TEAM_COLORS = ['#2563eb', '#dc2626']; +const SHAPES = ['circle', 'triangle', 'square', 'star'] as const; +const SHAPE_KO = { circle: '동그라미', triangle: '세모', square: '네모', star: '별' } as const; +const sideColor = (team: boolean, side: number) => (team ? TEAM_COLORS : SOLO_COLORS)[side] ?? '#888'; +const sideShape = (side: number) => SHAPES[side % SHAPES.length]!; + +const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; +const nodeName = (n: number | 'home' | 'goal') => (n === 'home' ? '대기' : n === 'goal' ? '남' : NODE_NAMES[n]!); + +const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + +const CSS = ` +.yut-wrap { width: 100%; display: grid; gap: 14px; align-items: start; } +.yut-board-box { width: 100%; max-width: min(100%, 640px); margin: 0 auto; } +.yut-board { width: 100%; display: block; border-radius: 18px; box-shadow: var(--shadow); touch-action: manipulation; } +.yut-ctrl { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +@media (min-width: 1100px) { + .yut-wrap { grid-template-columns: minmax(0, 1fr) 300px; align-items: center; } + .yut-board-box { max-width: min(100%, calc(100vh - 64px - 52px - 40px)); } +} +.yut-chips { display: flex; flex-wrap: wrap; gap: 8px; } +.yut-chip { min-width: 64px; min-height: 64px; border-radius: 14px; border: 3px solid var(--border); background: var(--surface); color: var(--text); + display: inline-flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1.1; cursor: pointer; padding: 4px 8px; font-weight: 800; } +.yut-chip b { font-size: 1.5rem; } +.yut-chip small { font-size: .8rem; font-weight: 700; color: var(--text-sub); } +.yut-chip[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); } +.yut-chip.back b { color: var(--danger); } +.yut-chip:disabled { cursor: default; opacity: .85; } +.yut-throw { min-height: 72px; font-size: 1.35rem; } +.yut-tray { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.yut-tray-btn { min-height: 56px; border-radius: 14px; border: 3px dashed var(--border); background: var(--surface); color: var(--text); padding: 4px 12px; + display: inline-flex; align-items: center; gap: 8px; font-weight: 700; cursor: pointer; } +.yut-tray-btn.can { border-style: solid; border-color: var(--focus); animation: yut-glow 1.1s ease-in-out infinite; } +.yut-tray-btn.sel { border-style: solid; border-color: var(--primary); background: var(--primary-soft); animation: none; } +.yut-tray-btn:disabled { cursor: default; } +.yut-sticks-box { display: flex; align-items: center; gap: 12px; min-height: 84px; } +.yut-sticks { width: 120px; height: 80px; flex: none; overflow: visible; } +.yut-result { font-size: 2rem; font-weight: 900; min-width: 3ch; } +.yut-toss .yut-stick { transform-box: fill-box; transform-origin: center; animation: yut-toss .8s cubic-bezier(.25,.7,.35,1) both; animation-delay: var(--d, 0ms); } +@keyframes yut-toss { 0% { transform: translateY(0) rotate(0deg); } 45% { transform: translateY(-34px) rotate(260deg); } 75% { transform: translateY(2px) rotate(350deg); } 100% { transform: translateY(0) rotate(360deg); } } +@keyframes yut-glow { 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, .0); } 50% { box-shadow: 0 0 0 5px rgba(245, 158, 11, .35); } } +@keyframes yut-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } } +@keyframes yut-hop { 0% { transform: translateY(0); } 50% { transform: translateY(-5px); } 100% { transform: translateY(0); } } +@keyframes yut-pop { from { transform: scale(.4); opacity: 0; } to { transform: scale(1); opacity: 1; } } +.yut-can { animation: yut-pulse 1s ease-in-out infinite; } +.yut-hop { transform-box: fill-box; transform-origin: center bottom; animation: yut-hop .17s ease-out; } +.yut-pop { transform-box: fill-box; transform-origin: center; animation: yut-pop .25s ease-out both; } +.yut-hit { cursor: pointer; outline: none; } +.yut-hit .yut-focus { opacity: 0; } +.yut-hit:focus-visible .yut-focus { opacity: 1; } +.yut-log { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow: auto; font-size: .92rem; } +.yut-side-card { padding: 10px 12px; border: 3px solid transparent; transition: border-color .2s; } +.yut-side-card.turn { border-color: var(--primary); } +.yut-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; color: #fff; } +@media (prefers-reduced-motion: reduce) { + .yut-toss .yut-stick, .yut-can, .yut-hop, .yut-pop, .yut-tray-btn.can { animation: none; } +} +`; + +// ---------------------------------------------------------------- pieces & sticks + +function Token({ side, team, x, y, r, count = 1 }: { side: number; team: boolean; x: number; y: number; r: number; count?: number }) { + const color = sideColor(team, side); + const shape = sideShape(side); + const common = { fill: color, stroke: '#fff', strokeWidth: r * 0.16, strokeLinejoin: 'round' as const }; + let body: React.ReactNode; + if (shape === 'circle') body = ; + else if (shape === 'square') body = ; + else if (shape === 'triangle') body = ; + else { + const pts = Array.from({ length: 10 }, (_, i) => { + const a = -Math.PI / 2 + (i * Math.PI) / 5; + const rr = i % 2 === 0 ? r * 1.1 : r * 0.5; + return `${(Math.cos(a) * rr).toFixed(2)},${(Math.sin(a) * rr).toFixed(2)}`; + }); + body = ; + } + return ( + + + + + {body} + {count > 1 && ( + + + + {count} + + + )} + + ); +} + +function TokenIcon({ side, team, size = 22 }: { side: number; team: boolean; size?: number }) { + return ( + + + + ); +} + +/** One 윷가락: 배(flat side, light) or 등(round side, dark with marks). Stick 0 carries the 빽도 mark on its flat side. */ +function Stick({ x, flat, marked, delay }: { x: number; flat: boolean; marked: boolean; delay: number }) { + return ( + + + {flat ? ( + <> + + {marked && ( + + 빽 + + )} + + ) : ( + [20, 32, 44, 56].map((y) => ) + )} + + ); +} + +function Sticks({ view: v, players }: { view: YutView; players: Record }) { + const lt = v.lastThrow; + const [tossing, setTossing] = useState(false); + const seen = useRef(lt?.no ?? 0); + useEffect(() => { + if (!lt || lt.no === seen.current) return; + seen.current = lt.no; + if (reducedMotion()) return; + setTossing(true); + const t = setTimeout(() => setTossing(false), 820); + return () => clearTimeout(t); + }, [lt?.no]); // eslint-disable-line react-hooks/exhaustive-deps + const sticks = lt?.sticks ?? [false, true, false, true]; + const label = !lt ? '' : tossing ? '…' : lt.result === 'nak' ? '낙!' : `${THROW_KO[lt.result]}!`; + return ( +
+ + {lt?.result === 'nak' ? ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + + ) : ( + sticks.map((f, i) => ) + )} + +
+
+ {label} +
+ {lt && !tossing &&
{nick(players, lt.player)}님
} +
+
+ ); +} + +// ---------------------------------------------------------------- board + +interface Anim { + no: number; + pts: [number, number][]; + i: number; + side: number; + count: number; + to: number | 'goal'; + caught: boolean; +} + +function YutBoard({ view: v, me, players, act, pending, deadline }: BoardProps) { + const myTurn = v.phase !== 'finished' && v.current === me; + const moves = useMemo(() => (myTurn && v.phase === 'move' ? (v.legalMoves ?? []) : []), [myTurn, v.phase, v.legalMoves]); + const [selIdx, setSelIdx] = useState(null); + const [selFrom, setSelFrom] = useState(null); + const [announce, setAnnounce] = useState(''); + const [anim, setAnim] = useState(null); + const [flash, setFlash] = useState<{ text: string; at: [number, number]; key: number } | null>(null); + const lastMoveNo = useRef(v.lastMove?.no ?? 0); + const lastThrowNo = useRef(v.lastThrow?.no ?? 0); + const prevCurrent = useRef(v.current); + + const kinds = [...new Set(v.pending)]; + // Reset the selection whenever the state moves on; pick the result automatically when there is only one kind. + useEffect(() => { + setSelFrom(null); + if (myTurn && v.phase === 'move' && kinds.length === 1) setSelIdx(v.pending.indexOf(kinds[0]!)); + else setSelIdx(null); + }, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps + + const selThrow = selIdx !== null ? (v.pending[selIdx] ?? null) : null; + const forThrow = selThrow ? moves.filter((m) => m.throw === selThrow) : []; + const movable = new Set(forThrow.map((m) => m.from)); + const chosen: YutLegalMove | null = selFrom !== null ? (forThrow.find((m) => m.from === selFrom) ?? null) : null; + + // Throw / move feedback: sounds, screen-reader text, hop animation along the path. + useEffect(() => { + const lt = v.lastThrow; + if (lt && lt.no !== lastThrowNo.current) { + lastThrowNo.current = lt.no; + const who = lt.player === me ? '내가' : `${nick(players, lt.player)}님이`; + setAnnounce(lt.result === 'nak' ? `${who} 던진 윷이 판 밖으로 떨어졌어요(낙).` : `${who} ${THROW_KO[lt.result]}를 던졌어요.`); + sounds.stone(); + } + }, [v.lastThrow?.no]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const m = v.lastMove; + if (!m || m.no === lastMoveNo.current) return; + lastMoveNo.current = m.no; + const caught = m.captured.reduce((a, c) => a + c.count, 0); + const end = m.to === 'goal' ? HOME_PT : POS[m.to]!; + const parts = [`${nick(players, m.player)}님이 ${THROW_KO[m.throw]}로 ${nodeName(m.from)}에서 ${nodeName(m.to)}${m.to === 'goal' ? '' : ' 자리로'} 움직였어요${m.count > 1 ? `(${m.count}개 함께)` : ''}.`]; + if (caught) parts.push(`말 ${caught}개를 잡았어요!`); + if (m.stacked) parts.push('업었어요.'); + if (m.to === 'goal') parts.push(`말 ${m.count}개가 났어요.`); + setAnnounce(parts.join(' ')); + const text = caught ? '잡았다!' : m.to === 'goal' ? '났다!' : m.stacked ? '업었다!' : null; + if (text) setFlash({ text, at: end, key: m.no }); + if (caught) haptic([40, 30, 40]); + if (reducedMotion()) return; + const pts: [number, number][] = [m.from === 'home' ? HOME_PT : POS[m.from]!, ...m.path.map((n) => POS[n]!)]; + if (m.to === 'goal') pts.push(HOME_PT); + setAnim({ no: m.no, pts, i: 0, side: m.side, count: m.count, to: m.to, caught: caught > 0 }); + }, [v.lastMove?.no]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!anim) return; + if (anim.i >= anim.pts.length - 1) { + const t = setTimeout(() => setAnim(null), 120); + return () => clearTimeout(t); + } + const t = setTimeout(() => setAnim((a) => (a && a.no === anim.no ? { ...a, i: a.i + 1 } : a)), 170); + return () => clearTimeout(t); + }, [anim]); + + useEffect(() => { + if (!flash) return; + const t = setTimeout(() => setFlash(null), 1100); + return () => clearTimeout(t); + }, [flash]); + + useEffect(() => { + if (v.current !== prevCurrent.current && v.current === me && v.phase !== 'finished') { + haptic(40); + sounds.myTurn(); + } + prevCurrent.current = v.current; + }, [v.current]); // eslint-disable-line react-hooks/exhaustive-deps + + const doMove = (m: YutLegalMove) => { + act({ type: 'MOVE', throwIndex: m.throwIndex, from: m.from }); + setSelFrom(null); + }; + + /** First tap shows where it lands, second tap (or the destination / 이동하기) confirms. */ + const tapSource = (from: From) => { + if (!myTurn || pending) return; + if (!selThrow) { + setAnnounce('먼저 아래에서 쓸 윷 결과를 골라 주세요.'); + return; + } + if (!movable.has(from)) { + setAnnounce(selThrow === 'backdo' && from === 'home' ? '빽도로는 새 말을 올릴 수 없어요.' : '그 말은 지금 움직일 수 없어요.'); + return; + } + if (selFrom === from && chosen) doMove(chosen); + else { + setSelFrom(from); + const m = forThrow.find((x) => x.from === from)!; + setAnnounce(`${nodeName(m.to)}${m.to === 'goal' ? '' : ' 자리'}로 가요. ${badge(m) ?? ''} 한 번 더 누르면 움직여요.`); + } + }; + + const keyAct = (fn: () => void) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); // keep the chat's global "Enter focuses chat" shortcut from stealing focus + fn(); + } + }; + + // Stacks to draw (the arriving stack is drawn by the animation until it lands). + const stacks = v.stacks.filter((st) => !(anim && anim.to === st.node && st.side === anim.side)); + const previewPath = chosen ? [chosen.from === 'home' ? HOME_PT : POS[chosen.from]!, ...chosen.path.map((n) => POS[n]!)] : null; + const destPt = chosen ? (chosen.to === 'goal' ? HOME_PT : POS[chosen.to]!) : null; + const mySideView = v.mySide !== null ? v.sides[v.mySide] : null; + const lone = moves.length === 1 ? moves[0]! : null; + const totalMs = v.away.includes(v.current) ? 5000 : (v.options.turnSeconds ?? 0) * 1000; + + return ( +
+ +
+ + + {LINES.map(([a, b]) => ( + + ))} + {previewPath && ( + p.join(',')).join(' ')} fill="none" stroke="var(--primary)" strokeWidth={1.6} strokeDasharray="2.2 1.8" strokeLinecap="round" strokeLinejoin="round" /> + )} + {POS.map(([x, y], n) => ( + + + {BIG.has(n) && } + + ))} + {BIG_LABELS.map(([n, t]) => ( + + {t} + + ))} + {/* direction hint at the start */} + + {stacks.map((st) => { + const [x, y] = POS[st.node]!; + const mine = v.mySide === st.side; + const can = myTurn && mine && movable.has(st.node); + const sel = mine && selFrom === st.node; + const sideName = v.sides[st.side]!.name; + return ( + + {(can || sel) && } + + {mine && myTurn && v.phase === 'move' && ( + tapSource(st.node)} + onKeyDown={keyAct(() => tapSource(st.node))} + > + + + + )} + + ); + })} + {chosen && destPt && ( + !pending && doMove(chosen)} + onKeyDown={keyAct(() => !pending && doMove(chosen))} + > + + + {badge(chosen) && ( + + + + {badge(chosen)} + + + )} + + )} + {anim && ( + + + + )} + {flash && !anim && ( + + + {flash.text} + + + )} + +
+ +
+
+ + {v.phase !== 'finished' && } +
+ +
+ 모아 둔 결과{v.current !== me && v.phase !== 'finished' ? ` · ${nick(players, v.current)}님` : ''} + {v.pending.length === 0 ? ( + {v.phase === 'throw' ? '윷을 던지면 여기에 모여요.' : '없음'} + ) : ( +
+ {v.pending.map((t, i) => { + const usable = moves.some((m) => m.throw === t); + return ( + + ); + })} +
+ )} +
+ + {myTurn && v.phase === 'throw' && ( + + )} + + {myTurn && v.phase === 'move' && ( +
+ {chosen ? ( +
+ + +
+ ) : ( +

+ {selThrow ? '반짝이는 말을 누르면 갈 자리가 보여요. 한 번 더 누르면 움직여요.' : '쓸 결과를 먼저 골라 주세요.'} +

+ )} + {lone && !chosen && ( + + )} +
+ )} + + {mySideView && ( +
+ + + 🏁 난 말 {mySideView.done}/{v.options.pieces} + +
+ )} +
+

+ {announce} +

+
+ ); +} + +function badge(m: YutLegalMove): string | null { + if (m.captures) return '잡기!'; + if (m.finishes) return '날!'; + if (m.stacks) return '업기'; + return null; +} + +// ---------------------------------------------------------------- side panel + +function SideCard({ side, view: v, players, me, deadline }: { side: YutSideView; view: YutView; players: Record; me: string | null; deadline: number | null }) { + const turn = v.phase !== 'finished' && v.turnSide === side.id; + const shape = SHAPE_KO[sideShape(side.id)]; + const status = side.finishedRank !== null ? `${side.finishedRank}등` : side.out ? '기권' : null; + return ( +
+
+ + + {side.name} + {v.mySide === side.id && (우리 편)} + + {status && ( + + {status} + + )} + + 대기 {side.home} · 판 {side.onBoard} · 🏁 {side.done} + +
+
+ {side.members.map((id) => { + const u = userOf(players, id); + const cur = turn && v.current === id; + return ( +
+ + + {u.nickname} + {id === me && (나)} + {cur && · {v.phase === 'throw' ? '던지는 중' : '옮기는 중'}} + + {side.conceded.includes(id) && 기권} + {v.away.includes(id) && !side.conceded.includes(id) && 자리 비움} + {cur && } +
+ ); + })} +
+
+ ); +} + +function logLine(e: GameEvent, v: YutView, players: Record): string | null { + const who = typeof e.player === 'string' ? nick(players, e.player) : ''; + const sn = (s: unknown) => v.sides[s as number]?.name ?? ''; + switch (e.type) { + case 'thrown': + return `${who}: ${THROW_KO[e.result as Throw]}`; + case 'nak': + return `${who}: 낙! (무효)`; + case 'moved': + return `${who}: ${THROW_KO[e.throw as Throw]}로 ${nodeName(e.from as From)} → ${nodeName(e.to as number | 'goal')}${(e.count as number) > 1 ? ` (${e.count}개)` : ''}`; + case 'captured': + return `${sn(e.side)}이 ${sn(e.victimSide)} 말 ${e.count}개를 잡았어요`; + case 'stacked': + return `${sn(e.side)} 말 ${e.count}개가 업혔어요`; + case 'finishedPieces': + return `${sn(e.side)} 말 ${e.count}개가 났어요`; + case 'discarded': + return `쓸 곳이 없는 ${(e.throws as Throw[]).map((t) => THROW_KO[t]).join('·')}는 버렸어요`; + case 'bonusThrow': + return `${who}: 잡아서 한 번 더 던져요`; + case 'sideFinished': + return `${sn(e.side)} ${e.rank}등!`; + case 'conceded': + return `${who}님이 기권했어요`; + case 'timeout': + return `${who}님 시간 초과 · 자동으로 했어요`; + case 'away': + return `${who}님 자리 비움`; + case 'gameOver': + return String(e.summary ?? '게임이 끝났어요'); + default: + return null; + } +} + +function YutSide({ view: v, me, players, act, deadline }: BoardProps) { + const [confirm, setConfirm] = useState(false); + const ref = useRef(null); + const lines = v.log.map((e) => logLine(e, v, players)).filter((x): x is string => !!x); + useEffect(() => { + ref.current?.scrollTo({ top: ref.current.scrollHeight }); + }, [v.turnSeq]); + const mySide = v.mySide !== null ? v.sides[v.mySide] : null; + const canConcede = v.phase !== 'finished' && !!me && !!mySide && !mySide.conceded.includes(me) && mySide.finishedRank === null; + return ( + <> + {v.sides.map((x) => ( + + ))} + {v.log.length > 0 && ( +
+ 진행 기록 +
    + {lines.slice(-24).map((l, i) => ( +
  1. {l}
  2. + ))} +
+
+ )} + {canConcede && ( + + )} + {confirm && ( + act({ type: 'CONCEDE' })} + onClose={() => setConfirm(false)} + /> + )} + + ); +} + +// ---------------------------------------------------------------- status + +function statusText(v: YutView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + const cur = nick(players, v.current); + if (v.current === me) { + if (v.phase === 'throw') return v.pending.length || v.lastThrow?.player === me ? '내 차례예요. 한 번 더 던져요' : '내 차례예요. 윷을 던져 주세요'; + return '내 차례예요. 결과를 골라 말을 움직여 주세요'; + } + if (v.team && v.mySide === v.turnSide) return `${cur}님(우리 팀) 차례예요`; + return `${cur}님 차례예요`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + const onOff = (b: boolean) => (b ? 'on' : 'off'); + return ( +
+ + set({ mode })} /> + {o.mode === 'team' && 자리 순서대로 청팀·홍팀이 번갈아 앉아요(1·3·5·7번째 청팀). 자리를 바꿔 팀을 맞춰 주세요.} + + + set({ pieces })} /> + + + set({ turnSeconds: k === 'none' ? null : (Number(k) as 15 | 30 | 60) })} + /> + + + {more && ( + <> + + set({ backdo: k === 'on' })} /> + + {o.backdo && ( + <> + + set({ backdoFromDo })} /> + + + set({ backdoCaptureBonus: k === 'on' })} /> + + + )} + + set({ finishRule })} /> + + + set({ stickModel })} /> + + + set({ nakPercent })} /> + + {o.mode === 'individual' && ( + + set({ ranking })} /> + + )} + + )} +
+ ); +} + +function optionsSummary(o: YutOptions): string { + return [ + o.mode === 'team' ? '팀전' : '개인전', + `말 ${o.pieces}개`, + o.backdo ? '빽도 있음' : '빽도 없음', + o.finishRule === 'pass' ? '참먹이 지나야 남' : '참먹이 도착하면 남', + ...(o.nakPercent ? [`낙 ${o.nakPercent}%`] : []), + o.turnSeconds === null ? '시간 무제한' : `${o.turnSeconds}초`, + ].join(' · '); +} + +// ---------------------------------------------------------------- rules + +function MiniBoard() { + return ( + + {LINES.map(([a, b]) => ( + + ))} + POS[n]!.join(',')).join(' ')} fill="none" stroke="var(--primary)" strokeWidth={2} strokeDasharray="3 2" /> + {POS.map(([x, y], n) => ( + + ))} + {[ + [0, '출발'], + [5, '모'], + [10, '뒷모'], + [15, '찌모'], + [22, '방'], + ].map(([n, t]) => ( + + {t} + + ))} + + ); +} + +function StickRow({ flats, label }: { flats: boolean[]; label: string }) { + return ( +
+ + {flats.map((f, i) => ( + + ))} + +
+ {label} +
+
+ ); +} + +function Rules({ options }: { options?: YutOptions }) { + const o = options; + return ( +
+

+ 윷가락 네 개를 던져 나온 만큼 말을 움직여, 내 말을 모두 먼저 판 밖으로 내보내면(날) 이겨요. 말은 오른쪽 아래 참먹이에서 출발해 시계 반대 방향으로 돌아요. + {o?.mode === 'team' ? ' 팀전은 청팀·홍팀이 번갈아 던지고, 팀 안에서는 자리 순서대로 돌아가요.' : ''} +

+
+ + + + + + {o?.backdo !== false && } +
+

밝은 면이 배(평평한 면), 어두운 면이 등이에요. 배가 위인 가락 수로 결과가 정해져요. '빽' 표시가 있는 가락 하나만 배가 위면 빽도예요.

+

+ 윷·모가 나오면 한 번 더 던져요. 모아 둔 결과는 원하는 순서로, 원하는 말에 하나씩 써요. 결과 칩을 누르고 → 반짝이는 말을 누르면 갈 자리가 보이고 → 한 번 더 누르면 움직여요. +

+ +

+ 지름길: 모서리(모·뒷모)나 가운데 방에 멈췄다가 다음에 출발하면 안쪽 대각선으로 꺾어요. 지나가기만 하면 그대로 직진해요. 방에서 출발하면 언제나 참먹이 쪽으로 가요. +

+

+ 업기: 내 말이 있는 자리에 멈추면 함께 업혀서 같이 움직여요. 잡기: 상대 말이 있는 자리에 멈추면 그 말들을 처음으로 돌려보내고 한 번 더 던져요 + {o && !o.backdoCaptureBonus ? '(빽도로 잡을 때는 빼고)' : ''}. 지나가기만 해서는 잡지 않아요. +

+

+ 날: {o?.finishRule === 'reach' ? '한 바퀴 돈 말이 참먹이에 도착하면 바로 나요.' : '한 바퀴 돈 말이 참먹이를 지나가야 나요. 참먹이에 딱 멈추면 아직 판 위라서 잡힐 수 있어요.'} +

+ {o?.backdo !== false && ( +

+ 빽도: 말이 왔던 길로 한 칸 돌아가요. 판 위에 말이 없으면 빽도는 버려져요.{' '} + {o?.backdoFromDo === 'finish' ? '도 자리에 있는 말이 빽도를 받으면 바로 나요.' : '도 자리에 있는 말이 빽도를 받으면 참먹이로 가고, 다음 전진에서 바로 나요.'} +

+ )} + {!!o?.nakPercent &&

낙: {o.nakPercent}% 확률로 윷가락이 판 밖으로 떨어져 그 던지기는 무효가 돼요(그 전에 모아 둔 결과는 남아요).

} +

시간 안에 하지 않으면 자동으로 던지거나, 잡기 → 내기 → 업기 → 뒤처진 말 순서로 골라 움직여요. 세 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.

+
+ ); +} + +const ui: GameUI = { + Board: YutBoard, + Side: YutSide, + statusText, + isMyTurn: (v, me) => v.phase !== 'finished' && !!me && v.current === me, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 45edc0f..1ffd6bf 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -12,4 +12,4 @@ export const GAME_ICON: Record = { }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '마작', '고스톱', '뱅', '할리갈리']; +export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리']; diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 05c359a..1592f39 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -25,6 +25,8 @@ interface Plan { moves?: string[][]; /** Card games: custom driver over all pages (host first). */ drive?: (pages: Page[]) => Promise; + /** Host changes game options in the lobby before starting. */ + lobby?: (host: Page) => Promise; } /** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */ @@ -53,7 +55,73 @@ async function finishHand(pages: Page[]) { for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 }); } +/** + * 윷놀이: one UI step for whoever can act on `p` — press the throw button, or pick a result chip → + * a glowing piece (or the waiting-piece tray) → "이동하기" (the two-step move confirmation). + */ +async function yutStep(p: Page): Promise { + const throwBtn = p.getByRole('button', { name: /윷 던지기|한 번 더 던지기/ }); + if ((await throwBtn.count()) && (await throwBtn.isEnabled().catch(() => false))) { + return throwBtn.click({ timeout: 1500 }).then(() => true, () => false); + } + const chip = p.locator('.yut-chip:not([disabled])').first(); + if (!(await chip.count())) return false; + try { + await chip.click({ timeout: 1500 }); + const src = p.locator('g.yut-hit[tabindex="0"], .yut-tray-btn.can').first(); + await src.waitFor({ timeout: 2000 }); + // Keyboard: Enter on a piece shows where it lands, Enter again on the same piece moves it. + const h = await src.elementHandle({ timeout: 1000 }); + await h!.focus(); + await p.keyboard.press('Enter'); + await p.getByRole('button', { name: /^이동하기/ }).waitFor({ timeout: 1500 }); + await h!.focus(); + await p.keyboard.press('Enter'); + return true; + } catch { + return false; + } +} + const PLANS: Record = { + yut: { + // 말 2개로 줄여서 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 끝까지 둔다(중간 화면도 저장). + lobby: async (host) => { + await host.getByRole('button', { name: '2개', exact: true }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/말 2개/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + let actions = 0; + let shot = false; + const until = Date.now() + 300_000; + const over = () => host.getByText(/이겼어요|아쉽게 졌어요/).count(); + while (Date.now() < until && !(await over())) { + let acted = false; + for (const p of pages) { + if (await yutStep(p)) { + acted = true; + actions++; + break; + } + } + if (actions >= 16 && !shot) { + shot = true; + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'yut-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone.png'), fullPage: true }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') }); + await guest.emulateMedia({ colorScheme: 'light' }); + } + if (!acted) await host.waitForTimeout(120); + } + if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`); + await guest.getByText(/이겼어요|아쉽게 졌어요/).waitFor(); + console.log(` yut: finished after ${actions} UI actions`); + }, + }, seotda: { // 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown. players: 3, @@ -211,6 +279,7 @@ for (const id of games) { await g.getByRole('button', { name: '이 이름으로 입장하기' }).click(); await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor(); } + if (plan.lobby) await plan.lobby(host); await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) }); await host.getByRole('button', { name: '▶ 게임 시작' }).click(); await host.getByRole('button', { name: '시작하기' }).click(); diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 904230a..db74f2e 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -47,6 +47,8 @@ export interface GameDefinition { optionsSchema: ZodType; actionSchema: ZodType; playersFor?(options: O): { min: number; max: number }; + /** Extra player-count rule `playersFor` cannot express (e.g. even counts only); Korean reason or null. */ + playerCountError?(options: O, count: number): string | null; setup(ctx: SetupContext): S; validate(state: S, actor: PlayerId, action: A): Validation; apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] }; diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 88a29a7..d357d06 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -7,6 +7,7 @@ import { baduk } from './baduk'; import { seotda } from './seotda'; import { poker } from './poker'; import { yacht } from './yacht'; +import { yut } from './yut'; export const GAMES: Record = { [omok.id]: omok, @@ -17,4 +18,5 @@ export const GAMES: Record = { [seotda.id]: seotda, [poker.id]: poker, [yacht.id]: yacht, + [yut.id]: yut, }; diff --git a/packages/games/src/yut/board.ts b/packages/games/src/yut/board.ts new file mode 100644 index 0000000..225cd67 --- /dev/null +++ b/packages/games/src/yut/board.ts @@ -0,0 +1,152 @@ +/** + * 윷판 그래프와 윷가락 (docs/games/yut.md §5.1, §5.2, §6). + * Pure helpers shared by the rules module, its tests and the screen (node names, throw labels). + */ +import type { Rng } from '@bg/engine'; + +/** 0..28, see the table in §5.2. 0 = 참먹이(출발·도착), 22 = 방. */ +export type NodeId = number; +export type Throw = 'do' | 'gae' | 'geol' | 'yut' | 'mo' | 'backdo'; +export type Sticks = [boolean, boolean, boolean, boolean]; + +export const NODE_COUNT = 29; +export const GOAL = 'goal' as const; + +export const NODE_NAMES: readonly string[] = [ + '참먹이', '도', '개', '걸', '윷', '모', '뒷도', '뒷개', '뒷걸', '뒷윷', + '뒷모', '찌도', '찌개', '찌걸', '찌윷', '찌모', '날도', '날개', '날걸', '날윷', + '앞모도', '앞모개', '방', '속윷', '속모', '뒷모도', '뒷모개', '사려', '안찌', +]; + +export const THROW_STEPS: Record = { do: 1, gae: 2, geol: 3, yut: 4, mo: 5, backdo: -1 }; +export const THROW_KO: Record = { do: '도', gae: '개', geol: '걸', yut: '윷', mo: '모', backdo: '빽도' }; +/** Display order of the result chips. */ +export const THROW_ORDER: Throw[] = ['backdo', 'do', 'gae', 'geol', 'yut', 'mo']; + +/** + * One step forward (§5.2 table). `starting` = this step leaves the node the piece stood on, + * `prev` = the node passed just before `node` within the same move (only matters at 방). + * Returns GOAL when the piece leaves 참먹이 after a lap. + */ +export function step(node: NodeId | 'home', starting: boolean, prev: NodeId | null): NodeId | typeof GOAL { + if (node === 'home') return 1; + if (node === 0) return GOAL; + if (starting) { + if (node === 5) return 20; + if (node === 10) return 25; + if (node === 22) return 27; + } + switch (node) { + case 19: + return 0; + case 22: + return prev === 26 ? 27 : 23; + case 24: + return 15; + case 26: + return 22; + case 28: + return 0; + default: + return node + 1; // 1..18 outer ring, 20→21, 21→22, 23→24, 25→26, 27→28 + } +} + +export interface Advance { + /** Nodes entered, in order (the start node is not included; GOAL is not a node). */ + path: NodeId[]; + /** Final node, or GOAL when the piece finished. */ + to: NodeId | typeof GOAL; +} + +/** + * Moves `n` (≥ 1) steps forward from `from`. Reaching GOAL drops the remaining steps. + * With `reach`, arriving at 참먹이 after a lap (always the case when entering 0 by moving forward) finishes at once. + */ +export function advance(from: NodeId | 'home', n: number, finishRule: 'pass' | 'reach'): Advance { + let cur: NodeId | 'home' = from; + let prev: NodeId | null = null; + const path: NodeId[] = []; + for (let i = 0; i < n; i++) { + const nxt = step(cur, i === 0, prev); + if (nxt === GOAL) return { path, to: GOAL }; + path.push(nxt); + prev = cur === 'home' ? null : cur; + cur = nxt; + if (cur === 0 && finishRule === 'reach') return { path, to: GOAL }; + } + return { path, to: cur as NodeId }; +} + +/** Fallback predecessor when a piece has no usable trail (only after 참먹이/빽도 corner cases). */ +export function defaultPrev(node: NodeId): NodeId { + switch (node) { + case 0: + return 19; + case 20: + return 5; + case 25: + return 10; + case 22: + return 21; + case 23: + return 22; + case 27: + return 22; + default: + return node - 1; // 2..19, 21, 24, 26, 28 + } +} + +/** Minimum steps to finish under `pass` (§6), index = node; HOME = 12. `reach` subtracts 1. */ +const DIST_PASS: readonly number[] = [1, 11, 10, 9, 8, 7, 11, 10, 9, 8, 7, 10, 9, 8, 7, 6, 5, 4, 3, 2, 6, 5, 4, 8, 7, 6, 5, 3, 2]; +export const HOME_DIST_PASS = 12; + +export function remainingDistance(node: NodeId | 'home', finishRule: 'pass' | 'reach'): number { + const d = node === 'home' ? HOME_DIST_PASS : DIST_PASS[node]!; + return finishRule === 'reach' ? d - 1 : d; +} + +/** Maps the four sticks (true = 배(flat side) up; index 0 is the 빽도-marked stick) to a result (§5.1). */ +export function throwOf(sticks: Sticks, backdo: boolean): Throw { + const k = sticks.filter(Boolean).length; + switch (k) { + case 0: + return 'mo'; + case 1: + return backdo && sticks[0] ? 'backdo' : 'do'; + case 2: + return 'gae'; + case 3: + return 'geol'; + default: + return 'yut'; + } +} + +export const FLAT_P = { realistic: 0.6, fair: 0.5 } as const; + +/** One throw (§8.4): 낙 check first (if enabled), then each stick independently. */ +export function rollSticks( + rng: Rng, + o: { stickModel: keyof typeof FLAT_P; backdo: boolean; nakPercent: number }, +): { sticks: Sticks; result: Throw } | { sticks: null; result: 'nak' } { + if (o.nakPercent > 0 && rng.next() < o.nakPercent / 100) return { sticks: null, result: 'nak' }; + const p = FLAT_P[o.stickModel]; + const sticks = [rng.next() < p, rng.next() < p, rng.next() < p, rng.next() < p] as Sticks; + return { sticks, result: throwOf(sticks, o.backdo) }; +} + +/** Exact result probabilities (§5.1 table). */ +export function throwProbabilities(p: number, backdo = true): Record { + const q = 1 - p; + const one = 4 * p * q ** 3; + return { + backdo: backdo ? p * q ** 3 : 0, + do: backdo ? 3 * p * q ** 3 : one, + gae: 6 * p ** 2 * q ** 2, + geol: 4 * p ** 3 * q, + yut: p ** 4, + mo: q ** 4, + }; +} diff --git a/packages/games/src/yut/index.ts b/packages/games/src/yut/index.ts new file mode 100644 index 0000000..c8369ac --- /dev/null +++ b/packages/games/src/yut/index.ts @@ -0,0 +1,833 @@ +/** 윷놀이 (docs/games/yut.md). No hidden information; the RNG state is kept by the runner, never in the state. */ +import { z } from 'zod'; +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine'; +import { + GOAL, + THROW_KO, + THROW_STEPS, + advance, + defaultPrev, + remainingDistance, + rollSticks, + type NodeId, + type Sticks, + type Throw, +} from './board'; + +export * from './board'; + +// ---------------------------------------------------------------- options + +export const yutOptionsSchema = z.object({ + mode: z + .enum(['individual', 'team']) + .default('individual') + .meta({ title: '경기 방식', labels: { individual: '개인전(2~4명)', team: '팀전(4·6·8명, 2팀)' } }), + pieces: z + .union([z.literal(2), z.literal(3), z.literal(4), z.literal(5)]) + .default(4) + .meta({ title: '한 편의 말 수', labels: { 2: '2개', 3: '3개', 4: '4개', 5: '5개' } }), + stickModel: z + .enum(['realistic', 'fair']) + .default('realistic') + .meta({ title: '윷가락 확률', labels: { realistic: '실제 윷처럼(개·걸이 잘 나오고 모는 드묾)', fair: '반반(앞뒤가 나올 확률이 같음)' } }), + backdo: z.boolean().default(true).meta({ title: '빽도 사용' }), + backdoFromDo: z + .enum(['chammeogi', 'finish']) + .default('chammeogi') + .meta({ title: '도 자리 말이 빽도를 받으면', labels: { chammeogi: '참먹이로 감(다음 전진에서 남)', finish: '바로 남' } }), + finishRule: z + .enum(['pass', 'reach']) + .default('pass') + .meta({ title: '날(완주) 판정', labels: { pass: '참먹이를 지나야 남', reach: '참먹이에 도착하면 남' } }), + nakPercent: z + .union([z.literal(0), z.literal(3), z.literal(5), z.literal(10)]) + .default(0) + .meta({ title: '낙(윷가락이 판 밖으로 떨어짐) 확률', labels: { 0: '낙 없음', 3: '3%', 5: '5%', 10: '10%' } }), + backdoCaptureBonus: z.boolean().default(true).meta({ title: '빽도로 잡아도 한 번 더 던지기' }), + ranking: z + .enum(['first', 'all']) + .default('first') + .meta({ title: '3~4명 개인전에서 1등이 나오면', labels: { first: '바로 끝(나머지는 진행도로 순위)', all: '꼴찌가 정해질 때까지 계속' } }), + turnSeconds: z + .union([z.literal(15), z.literal(30), z.literal(60), z.literal(null)]) + .default(30) + .meta({ title: '행동 제한 시간', labels: { 15: '15초', 30: '30초', 60: '60초', null: '무제한' } }), +}); +export type YutOptions = z.infer; + +export const yutActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('THROW') }), + z.object({ + type: z.literal('MOVE'), + throwIndex: z.number().int().min(0).max(999), + from: z.union([z.literal('home'), z.number().int().min(0).max(28)]), + }), + z.object({ type: z.literal('CONCEDE') }), +]); +/** Client actions plus the system-only timer action (not in the schema). */ +export type YutAction = z.infer | { type: 'TIMEOUT' }; +export type YutMoveAction = Extract; + +// ---------------------------------------------------------------- state + +/** 개인전: seat index. 팀전: 0 = 청팀, 1 = 홍팀. */ +export type SideId = number; + +export interface Piece { + id: number; + status: 'home' | 'board' | 'done'; + node: NodeId | null; + /** True while standing on 참먹이 after a lap (every board piece on 0 is lapped). */ + lapped: boolean; + /** Nodes visited since entering the board, current node last (for 빽도). Shared by a stack. */ + trail: NodeId[]; +} + +export interface SideState { + id: SideId; + members: PlayerId[]; + pieces: Piece[]; + finishedRank: number | null; + /** Members who conceded; the side is out when all of them did. */ + conceded: PlayerId[]; + out: boolean; + outSeq: number | null; + /** Index into `members` of whoever throws next time this side has the turn. */ + cursor: number; +} + +export interface YutMoveInfo { + no: number; + side: SideId; + player: PlayerId; + throw: Throw; + from: NodeId | 'home'; + to: NodeId | typeof GOAL; + path: NodeId[]; + count: number; + captured: { side: SideId; count: number }[]; + stacked: boolean; +} + +export interface YutStats { + throws: Record; + /** Keyed by side id. */ + captures: Record; +} + +export interface YutRankEntry { + rank: number; + players: PlayerId[]; + side: SideId; + finishedPieces: number; + remainingDistance: number; +} + +export interface YutResult { + ranking: YutRankEntry[]; + winners: PlayerId[]; + summary: string; + reason: 'normal' | 'resign'; + stats: YutStats; +} + +export interface YutState { + options: YutOptions; + players: PlayerId[]; + sideOf: Record; + sides: SideState[]; + turnSide: SideId; + current: PlayerId; + phase: 'throw' | 'move' | 'finished'; + /** Throws still owed this turn (윷/모/잡기 bonus). 1 when the throw phase starts. */ + throwsOwed: number; + /** Results collected this turn, in throw order. */ + pending: Throw[]; + lastThrow: { no: number; player: PlayerId; sticks: Sticks | null; result: Throw | 'nak' } | null; + lastMove: YutMoveInfo | null; + throwNo: number; + moveNo: number; + /** +1 per applied action (timer key / duplicate guard). */ + turnSeq: number; + deadlineAt: number | null; + timeouts: Record; + log: GameEvent[]; + stats: YutStats; + nextRank: number; + outCount: number; + final: YutResult | null; +} + +// ---------------------------------------------------------------- views + +export interface YutLegalMove { + throwIndex: number; + throw: Throw; + from: NodeId | 'home'; + to: NodeId | typeof GOAL; + /** Nodes entered (for the dotted preview). */ + path: NodeId[]; + /** Pieces that move together. */ + count: number; + /** Opponent pieces caught. */ + captures: number; + stacks: boolean; + finishes: boolean; +} + +export interface YutSideView { + id: SideId; + name: string; + members: PlayerId[]; + conceded: PlayerId[]; + out: boolean; + finishedRank: number | null; + home: number; + onBoard: number; + done: number; + remaining: number; +} + +export interface YutStackView { + node: NodeId; + side: SideId; + count: number; +} + +export interface YutView { + options: YutOptions; + team: boolean; + me: PlayerId | null; + mySide: SideId | null; + phase: YutState['phase']; + sides: YutSideView[]; + stacks: YutStackView[]; + turnSide: SideId; + current: PlayerId; + throwsOwed: number; + pending: Throw[]; + lastThrow: YutState['lastThrow']; + lastMove: YutMoveInfo | null; + turnSeq: number; + deadlineAt: number | null; + away: PlayerId[]; + /** Only for the player to move (null otherwise). */ + legalMoves: YutLegalMove[] | null; + /** The move a timeout would play (§8.5), for the player to move. */ + suggested: { throwIndex: number; from: NodeId | 'home' } | null; + log: GameEvent[]; + stats: YutStats; + result: YutResult | null; +} + +// ---------------------------------------------------------------- helpers + +const TEAM_NAMES = ['청팀', '홍팀']; +const SOLO_NAMES = ['빨강', '파랑', '초록', '노랑']; +const LOG_MAX = 40; +const AWAY_AFTER = 3; +const AWAY_MS = 5000; + +export function sideName(o: Pick, side: SideId): string { + return o.mode === 'team' ? TEAM_NAMES[side]! : SOLO_NAMES[side]!; +} + +export function playerRange(o: Pick): { min: number; max: number } { + return o.mode === 'team' ? { min: 4, max: 8 } : { min: 2, max: 4 }; +} + +/** Player-count check that `playersFor` cannot express (팀전은 짝수만). */ +export function playerCountError(o: Pick, n: number): string | null { + if (o.mode === 'team') return n === 4 || n === 6 || n === 8 ? null : '팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.'; + return n >= 2 && n <= 4 ? null : '개인전은 2~4명이 할 수 있어요.'; +} + +const sideActive = (x: SideState) => x.finishedRank === null && !x.out; +const memberActive = (x: SideState, i: number) => !x.conceded.includes(x.members[i]!); + +/** First active member index at or after `from` (cyclic). */ +function memberFrom(x: SideState, from: number): number { + for (let k = 0; k < x.members.length; k++) { + const i = (from + k) % x.members.length; + if (memberActive(x, i)) return i; + } + return from % x.members.length; +} + +function pieceDistance(p: Piece, rule: YutOptions['finishRule']): number { + if (p.status === 'done') return 0; + return remainingDistance(p.status === 'home' ? 'home' : p.node!, rule); +} + +function sideDistance(x: SideState, rule: YutOptions['finishRule']): number { + return x.pieces.reduce((a, p) => a + pieceDistance(p, rule), 0); +} + +const doneCount = (x: SideState) => x.pieces.filter((p) => p.status === 'done').length; + +function pushLog(s: YutState, events: GameEvent[], e: GameEvent): void { + events.push(e); + s.log.push(e); + if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX); +} + +interface Plan { + throwIndex: number; + throw: Throw; + from: NodeId | 'home'; + to: NodeId | typeof GOAL; + path: NodeId[]; + movers: number[]; + trailAfter: NodeId[]; + victims: { side: SideId; ids: number[] }[]; + stackWith: number[]; +} + +/** Where `from` would go with `pending[throwIndex]`; null if that is not a legal move. */ +function planMove(s: YutState, sideId: SideId, throwIndex: number, from: NodeId | 'home'): Plan | null { + const t = s.pending[throwIndex]; + const side = s.sides[sideId]; + if (!t || !side) return null; + const o = s.options; + let movers: Piece[]; + let to: NodeId | typeof GOAL; + let path: NodeId[]; + let trailAfter: NodeId[]; + if (from === 'home') { + if (t === 'backdo') return null; + const p = side.pieces.find((x) => x.status === 'home'); + if (!p) return null; + movers = [p]; + const adv = advance('home', THROW_STEPS[t], o.finishRule); + ({ to, path } = adv); + trailAfter = to === GOAL ? [] : [...path]; + } else { + movers = side.pieces.filter((x) => x.status === 'board' && x.node === from); + if (movers.length === 0) return null; + const trail = movers[0]!.trail; + if (t === 'backdo') { + if (trail.length >= 2) { + to = trail[trail.length - 2]!; + trailAfter = trail.slice(0, -1); + } else if (from === 1) { + to = o.backdoFromDo === 'finish' ? GOAL : 0; + trailAfter = to === GOAL ? [] : [0]; + } else { + to = defaultPrev(from); + trailAfter = [to]; + } + path = to === GOAL ? [] : [to]; + } else { + const adv = advance(from, THROW_STEPS[t], o.finishRule); + ({ to, path } = adv); + trailAfter = to === GOAL ? [] : [...trail, ...path]; + } + } + const victims: Plan['victims'] = []; + const stackWith: number[] = []; + if (to !== GOAL) { + for (const x of s.sides) { + const here = x.pieces.filter((p) => p.status === 'board' && p.node === to && !(x.id === sideId && movers.includes(p))).map((p) => p.id); + if (here.length === 0) continue; + if (x.id === sideId) stackWith.push(...here); + else victims.push({ side: x.id, ids: here }); + } + } + return { throwIndex, throw: t, from, to, path, movers: movers.map((p) => p.id), trailAfter, victims, stackWith }; +} + +/** Legal moves of the side to move. Equal results are listed once (first index). Order: index, node, 'home' last. */ +function legalPlans(s: YutState): Plan[] { + if (s.phase !== 'move') return []; + const side = s.sides[s.turnSide]!; + const sources: (NodeId | 'home')[] = [...new Set(side.pieces.filter((p) => p.status === 'board').map((p) => p.node!))].sort((a, b) => a - b); + if (side.pieces.some((p) => p.status === 'home')) sources.push('home'); + const out: Plan[] = []; + const seen = new Set(); + s.pending.forEach((t, i) => { + if (seen.has(t)) return; + seen.add(t); + for (const from of sources) { + const p = planMove(s, s.turnSide, i, from); + if (p) out.push(p); + } + }); + return out; +} + +const toLegal = (p: Plan): YutLegalMove => ({ + throwIndex: p.throwIndex, + throw: p.throw, + from: p.from, + to: p.to, + path: p.path, + count: p.movers.length, + captures: p.victims.reduce((a, v) => a + v.ids.length, 0), + stacks: p.stackWith.length > 0, + finishes: p.to === GOAL, +}); + +/** Deterministic auto move (§8.5): capture > finish > stack > most-behind piece with the biggest result > enter > rest. */ +function chooseAuto(s: YutState, plans: Plan[]): Plan { + const rule = s.options.finishRule; + const side = s.sides[s.turnSide]!; + const byTie = (a: Plan, b: Plan) => + a.throwIndex - b.throwIndex || (a.from === 'home' ? 1 : 0) - (b.from === 'home' ? 1 : 0) || (a.from === 'home' ? 0 : a.from) - (b.from === 'home' ? 0 : (b.from as number)); + const pick = (list: Plan[], cmp: (a: Plan, b: Plan) => number) => [...list].sort((a, b) => cmp(a, b) || byTie(a, b))[0]; + const caps = plans.filter((p) => p.victims.length > 0); + if (caps.length) return pick(caps, (a, b) => toLegal(b).captures - toLegal(a).captures)!; + const fin = plans.filter((p) => p.to === GOAL); + if (fin.length) return pick(fin, (a, b) => b.movers.length - a.movers.length)!; + const stack = plans.filter((p) => p.stackWith.length > 0); + if (stack.length) return pick(stack, () => 0)!; + const distOf = (p: Plan) => pieceDistance(side.pieces.find((x) => x.id === p.movers[0])!, rule); + const fwd = plans.filter((p) => p.from !== 'home' && p.throw !== 'backdo'); + if (fwd.length) return pick(fwd, (a, b) => distOf(b) - distOf(a) || THROW_STEPS[b.throw] - THROW_STEPS[a.throw])!; + const enter = plans.filter((p) => p.from === 'home'); + if (enter.length) return pick(enter, () => 0)!; + return pick(plans, () => 0)!; +} + +/** The action a timeout plays now (THROW in the throw phase, §8.5 move otherwise). */ +export function autoAction(s: YutState): Exclude { + if (s.phase !== 'move') return { type: 'THROW' }; + const plans = legalPlans(s); + if (plans.length === 0) return { type: 'THROW' }; // unreachable: stuck results are discarded right away + const p = chooseAuto(s, plans); + return { type: 'MOVE', throwIndex: p.throwIndex, from: p.from }; +} + +// ---------------------------------------------------------------- transitions + +function startTurn(s: YutState, sideId: SideId, events: GameEvent[]): void { + const side = s.sides[sideId]!; + side.cursor = memberFrom(side, side.cursor); + s.turnSide = sideId; + s.current = side.members[side.cursor]!; + s.phase = 'throw'; + s.throwsOwed = 1; + s.pending = []; + pushLog(s, events, { type: 'turnChanged', side: sideId, player: s.current }); +} + +function endTurn(s: YutState, events: GameEvent[]): void { + const side = s.sides[s.turnSide]!; + side.cursor = memberFrom(side, side.cursor + 1); + const n = s.sides.length; + for (let k = 1; k <= n; k++) { + const next = s.sides[(s.turnSide + k) % n]!; + if (sideActive(next)) { + startTurn(s, next.id, events); + return; + } + } + finishGame(s, events); +} + +function gameShouldEnd(s: YutState): boolean { + const active = s.sides.filter(sideActive).length; + const anyFinished = s.sides.some((x) => x.finishedRank !== null); + return active <= 1 || (anyFinished && (s.options.ranking === 'first' || s.options.mode === 'team')); +} + +function finishGame(s: YutState, events: GameEvent[]): void { + const rule = s.options.finishRule; + const entry = (x: SideState): Omit => ({ + players: [...x.members], + side: x.id, + finishedPieces: doneCount(x), + remainingDistance: sideDistance(x, rule), + }); + const ranked: YutRankEntry[] = []; + const push = (group: SideState[]) => { + const rank = ranked.length + 1; + for (const x of group) ranked.push({ rank, ...entry(x) }); + }; + for (const x of s.sides.filter((x) => x.finishedRank !== null).sort((a, b) => a.finishedRank! - b.finishedRank!)) push([x]); + const active = s.sides + .filter(sideActive) + .sort((a, b) => doneCount(b) - doneCount(a) || sideDistance(a, rule) - sideDistance(b, rule)); + for (let i = 0; i < active.length; ) { + const a = active[i]!; + const group = active.filter((b, j) => j >= i && doneCount(b) === doneCount(a) && sideDistance(b, rule) === sideDistance(a, rule)); + push(group); + i += group.length; + } + for (const x of s.sides.filter((x) => x.out).sort((a, b) => b.outSeq! - a.outSeq!)) push([x]); + const first = ranked.filter((r) => r.rank === 1); + const winners = first.flatMap((r) => r.players); + const anyFinished = s.sides.some((x) => x.finishedRank !== null); + const o = s.options; + const top = first[0]!; + let summary: string; + if (anyFinished) { + summary = + o.mode === 'team' + ? `${sideName(o, top.side)} 승리! (말 ${o.pieces}개 모두 남)` + : `${sideName(o, top.side)} 1등! (말 ${o.pieces}개 모두 남)${o.ranking === 'all' && s.sides.length > 2 ? ' · 꼴찌까지 순위가 정해졌어요' : ''}`; + } else if (first.length > 1) { + summary = '모두 기권해서 게임이 끝났어요'; + } else { + summary = + o.mode === 'team' + ? `${sideName(o, 1 - top.side)} 기권으로 ${sideName(o, top.side)} 승리!` + : `다른 사람이 모두 기권해서 ${sideName(o, top.side)} 1등!`; + } + s.phase = 'finished'; + s.pending = []; + s.throwsOwed = 0; + s.deadlineAt = null; + s.final = { ranking: ranked, winners, summary, reason: anyFinished ? 'normal' : 'resign', stats: structuredClone(s.stats) }; + pushLog(s, events, { type: 'gameOver', winners, summary }); +} + +/** In the move phase: if no pending result can be used, discard them all and end the turn (§5.3-2). */ +function settleMovePhase(s: YutState, events: GameEvent[]): void { + if (s.phase !== 'move') return; + if (s.pending.length === 0) { + endTurn(s, events); + return; + } + if (legalPlans(s).length > 0) return; + pushLog(s, events, { type: 'discarded', side: s.turnSide, player: s.current, throws: [...s.pending] }); + s.pending = []; + endTurn(s, events); +} + +function doThrow(s: YutState, rng: Rng, events: GameEvent[]): void { + const r = rollSticks(rng, s.options); + s.throwNo += 1; + s.stats.throws[r.result] += 1; + s.lastThrow = { no: s.throwNo, player: s.current, sticks: r.sticks, result: r.result }; + s.throwsOwed -= 1; + if (r.result === 'nak') { + pushLog(s, events, { type: 'nak', side: s.turnSide, player: s.current }); + } else { + s.pending.push(r.result); + if (r.result === 'yut' || r.result === 'mo') s.throwsOwed += 1; + pushLog(s, events, { type: 'thrown', side: s.turnSide, player: s.current, sticks: r.sticks, result: r.result }); + } + if (s.throwsOwed > 0) return; + s.phase = 'move'; + settleMovePhase(s, events); +} + +function doMove(s: YutState, throwIndex: number, from: NodeId | 'home', events: GameEvent[]): void { + const plan = planMove(s, s.turnSide, throwIndex, from); + if (!plan) throw new Error(`yut: illegal move ${throwIndex} ${from}`); + const side = s.sides[s.turnSide]!; + s.pending.splice(throwIndex, 1); + const movers = side.pieces.filter((p) => plan.movers.includes(p.id)); + for (const p of movers) { + if (plan.to === GOAL) Object.assign(p, { status: 'done', node: null, lapped: false, trail: [] }); + else Object.assign(p, { status: 'board', node: plan.to, lapped: plan.to === 0, trail: [...plan.trailAfter] }); + } + for (const p of side.pieces) if (plan.stackWith.includes(p.id)) Object.assign(p, { lapped: plan.to === 0, trail: [...plan.trailAfter] }); + let caught = 0; + for (const v of plan.victims) { + for (const p of s.sides[v.side]!.pieces) if (v.ids.includes(p.id)) Object.assign(p, { status: 'home', node: null, lapped: false, trail: [] }); + caught += v.ids.length; + } + s.moveNo += 1; + s.lastMove = { + no: s.moveNo, + side: side.id, + player: s.current, + throw: plan.throw, + from: plan.from, + to: plan.to, + path: plan.path, + count: movers.length, + captured: plan.victims.map((v) => ({ side: v.side, count: v.ids.length })), + stacked: plan.stackWith.length > 0, + }; + pushLog(s, events, { type: 'moved', side: side.id, player: s.current, throw: plan.throw, from: plan.from, to: plan.to, path: plan.path, count: movers.length }); + if (plan.stackWith.length) pushLog(s, events, { type: 'stacked', side: side.id, node: plan.to, count: movers.length + plan.stackWith.length }); + for (const v of plan.victims) pushLog(s, events, { type: 'captured', side: side.id, victimSide: v.side, count: v.ids.length, node: plan.to }); + if (caught) s.stats.captures[String(side.id)] = (s.stats.captures[String(side.id)] ?? 0) + 1; + if (plan.to === GOAL) { + pushLog(s, events, { type: 'finishedPieces', side: side.id, count: movers.length }); + if (side.pieces.every((p) => p.status === 'done')) { + side.finishedRank = s.nextRank++; + pushLog(s, events, { type: 'sideFinished', side: side.id, rank: side.finishedRank }); + if (gameShouldEnd(s)) { + finishGame(s, events); + return; + } + // ranking = all: the finished side drops out; leftover results are lost. + if (s.pending.length) pushLog(s, events, { type: 'discarded', side: side.id, player: s.current, throws: [...s.pending] }); + s.pending = []; + endTurn(s, events); + return; + } + } + if (caught && (plan.throw !== 'backdo' || s.options.backdoCaptureBonus)) { + s.phase = 'throw'; + s.throwsOwed = 1; + pushLog(s, events, { type: 'bonusThrow', side: side.id, player: s.current }); + return; + } + settleMovePhase(s, events); +} + +function doConcede(s: YutState, actor: PlayerId, events: GameEvent[]): void { + const side = s.sides[s.sideOf[actor]!]!; + side.conceded.push(actor); + const whole = side.members.every((m) => side.conceded.includes(m)); + pushLog(s, events, { type: 'conceded', side: side.id, player: actor, sideOut: whole }); + if (!whole) { + // 팀전: a teammate takes over (the turn in progress continues with them). + if (s.current === actor) { + side.cursor = memberFrom(side, side.cursor + 1); + s.current = side.members[side.cursor]!; + } + return; + } + side.out = true; + side.outSeq = ++s.outCount; + for (const p of side.pieces) if (p.status === 'board') Object.assign(p, { status: 'home', node: null, lapped: false, trail: [] }); + if (gameShouldEnd(s)) { + finishGame(s, events); + return; + } + if (s.turnSide === side.id) { + s.pending = []; + endTurn(s, events); + } +} + +function refreshDeadline(s: YutState, now: number): void { + if (s.phase === 'finished' || s.options.turnSeconds === null) { + s.deadlineAt = null; + return; + } + const away = (s.timeouts[s.current] ?? 0) >= AWAY_AFTER; + s.deadlineAt = now + (away ? AWAY_MS : s.options.turnSeconds * 1000); +} + +function emptyStats(n: number): YutStats { + const captures: Record = {}; + for (let i = 0; i < n; i++) captures[String(i)] = 0; + return { throws: { do: 0, gae: 0, geol: 0, yut: 0, mo: 0, backdo: 0, nak: 0 }, captures }; +} + +// ---------------------------------------------------------------- definition + +export const yut: GameDefinition = { + id: 'yut', + nameKo: '윷놀이', + minPlayers: 2, + maxPlayers: 8, + stateVersion: 1, + defaultOptions: yutOptionsSchema.parse({}), + optionsSchema: yutOptionsSchema as unknown as z.ZodType, + actionSchema: yutActionSchema as unknown as z.ZodType, + playersFor: playerRange, + playerCountError: (o, n) => playerCountError(o, n), + + setup({ players, options, rng, now }) { + const err = playerCountError(options, players.length); + if (err) throw new Error(`yut: ${err}`); + const team = options.mode === 'team'; + const nSides = team ? 2 : players.length; + const sides: SideState[] = Array.from({ length: nSides }, (_, id) => ({ + id, + members: team ? players.filter((_, i) => i % 2 === id) : [players[id]!], + pieces: Array.from({ length: options.pieces }, (_, pid) => ({ id: pid, status: 'home' as const, node: null, lapped: false, trail: [] })), + finishedRank: null, + conceded: [], + out: false, + outSeq: null, + cursor: 0, + })); + const sideOf: Record = {}; + players.forEach((p, i) => (sideOf[p] = team ? i % 2 : i)); + const timeouts: Record = {}; + for (const p of players) timeouts[p] = 0; + const s: YutState = { + options, + players: [...players], + sideOf, + sides, + turnSide: 0, + current: players[0]!, + phase: 'throw', + throwsOwed: 1, + pending: [], + lastThrow: null, + lastMove: null, + throwNo: 0, + moveNo: 0, + turnSeq: 0, + deadlineAt: null, + timeouts, + log: [], + stats: emptyStats(nSides), + nextRank: 1, + outCount: 0, + final: null, + }; + startTurn(s, rng.int(nSides), []); + refreshDeadline(s, now); + return s; + }, + + validate(s, actor, a) { + const sideId = s.sideOf[actor]; + if (sideId === undefined) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); + const side = s.sides[sideId]!; + if (a.type === 'CONCEDE') { + if (side.conceded.includes(actor)) return fail('이미 기권했어요.'); + if (side.finishedRank !== null) return fail('이미 말을 모두 냈어요.'); + return ok; + } + if (actor !== s.current) return fail('지금은 다른 사람 차례예요.'); + switch (a.type) { + case 'TIMEOUT': + return ok; + case 'THROW': + if (s.phase !== 'throw' || s.throwsOwed <= 0) return fail('지금은 윷을 던질 차례가 아니에요.'); + return ok; + case 'MOVE': { + if (s.phase !== 'move') return fail('지금은 말을 움직일 차례가 아니에요. 먼저 윷을 던져 주세요.'); + const t = s.pending[a.throwIndex]; + if (!t) return fail('그 윷 결과는 없어요.'); + if (a.from === 'home') { + if (t === 'backdo') return fail('빽도로는 새 말을 올릴 수 없어요.'); + if (!side.pieces.some((p) => p.status === 'home')) return fail('기다리는 말이 없어요.'); + } else if (!side.pieces.some((p) => p.status === 'board' && p.node === a.from)) { + return fail('그 말은 움직일 수 없어요.'); + } + return planMove(s, sideId, a.throwIndex, a.from) ? ok : fail('그 말은 움직일 수 없어요.'); + } + } + return fail('알 수 없는 행동이에요.'); + }, + + apply(prev, actor, a, { rng, now }) { + const s = structuredClone(prev); + const events: GameEvent[] = []; + s.turnSeq += 1; + const act: Exclude = a.type === 'TIMEOUT' ? autoAction(s) : a; + if (a.type === 'TIMEOUT') { + s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1; + pushLog(s, events, { type: 'timeout', player: actor }); + if (s.timeouts[actor] === AWAY_AFTER) pushLog(s, events, { type: 'away', player: actor }); + } else if (a.type !== 'CONCEDE') s.timeouts[actor] = 0; + switch (act.type) { + case 'THROW': + doThrow(s, rng, events); + break; + case 'MOVE': + doMove(s, act.throwIndex, act.from, events); + break; + case 'CONCEDE': + doConcede(s, actor, events); + break; + } + refreshDeadline(s, now); + return { state: s, events }; + }, + + view(s, viewer) { + const o = s.options; + const mySide = viewer !== null && s.sideOf[viewer] !== undefined ? s.sideOf[viewer]! : null; + const stacks: YutStackView[] = []; + for (const x of s.sides) { + const byNode = new Map(); + for (const p of x.pieces) if (p.status === 'board') byNode.set(p.node!, (byNode.get(p.node!) ?? 0) + 1); + for (const [node, count] of byNode) stacks.push({ node, side: x.id, count }); + } + stacks.sort((a, b) => a.node - b.node); + const mine = s.phase === 'move' && viewer !== null && viewer === s.current; + const plans = mine ? legalPlans(s) : []; + const auto = mine && plans.length ? chooseAuto(s, plans) : null; + return { + options: o, + team: o.mode === 'team', + me: mySide === null ? null : viewer, + mySide, + phase: s.phase, + sides: s.sides.map((x) => ({ + id: x.id, + name: sideName(o, x.id), + members: x.members, + conceded: x.conceded, + out: x.out, + finishedRank: x.finishedRank, + home: x.pieces.filter((p) => p.status === 'home').length, + onBoard: x.pieces.filter((p) => p.status === 'board').length, + done: doneCount(x), + remaining: sideDistance(x, o.finishRule), + })), + stacks, + turnSide: s.turnSide, + current: s.current, + throwsOwed: s.throwsOwed, + pending: s.pending, + lastThrow: s.lastThrow, + lastMove: s.lastMove, + turnSeq: s.turnSeq, + deadlineAt: s.deadlineAt, + away: s.players.filter((p) => (s.timeouts[p] ?? 0) >= AWAY_AFTER), + legalMoves: mine ? plans.map(toLegal) : null, + suggested: auto ? { throwIndex: auto.throwIndex, from: auto.from } : null, + log: s.log, + stats: s.stats, + result: s.final, + }; + }, + + activePlayers(s) { + return s.phase === 'finished' ? [] : [s.current]; + }, + + deadline(s) { + return s.phase === 'finished' ? null : s.deadlineAt; + }, + + timeoutPlayers(s) { + return s.phase === 'finished' ? [] : [s.current]; + }, + + onTimeout() { + return { type: 'TIMEOUT' }; + }, + + onLeave(s, player) { + const sideId = s.sideOf[player]; + if (sideId === undefined || s.phase === 'finished') return null; + const side = s.sides[sideId]!; + if (side.conceded.includes(player) || side.finishedRank !== null) return null; + return { type: 'CONCEDE' }; + }, + + result(s): GameResult | null { + const f = s.final; + if (!f) return null; + const groups = new Map(); + for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), ...r.players]); + const scores: Record = {}; + for (const r of f.ranking) for (const p of r.players) scores[p] = r.finishedPieces; + return { + ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps), + scores, + summary: f.summary, + reason: f.reason, + }; + }, + + /** Test helper: THROW in the throw phase, every distinct legal MOVE in the move phase (CONCEDE is left out). */ + legalActions(s, p) { + if (s.phase === 'finished' || p !== s.current) return []; + if (s.phase === 'throw') return [{ type: 'THROW' }]; + return legalPlans(s).map((x) => ({ type: 'MOVE' as const, throwIndex: x.throwIndex, from: x.from })); + }, +}; + +/** Throw label with its step count, e.g. "걸(3칸)", "빽도(뒤로 1칸)". */ +export function throwLabel(t: Throw): string { + return t === 'backdo' ? `${THROW_KO[t]}(뒤로 1칸)` : `${THROW_KO[t]}(${THROW_STEPS[t]}칸)`; +} diff --git a/packages/games/src/yut/yut.test.ts b/packages/games/src/yut/yut.test.ts new file mode 100644 index 0000000..1f630c4 --- /dev/null +++ b/packages/games/src/yut/yut.test.ts @@ -0,0 +1,731 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine'; +import { + FLAT_P, + advance, + autoAction, + playerCountError, + rollSticks, + throwProbabilities, + yut, + type NodeId, + type Throw, + type YutAction, + type YutOptions, + type YutState, +} from './index'; + +// ---------------------------------------------------------------- helpers + +/** Scripted RNG: `next()` returns the queued values in order. */ +class Script implements Rng { + private vals: number[]; + constructor(vals: number[]) { + this.vals = [...vals]; + } + next(): number { + const v = this.vals.shift(); + if (v === undefined) throw new Error('script exhausted'); + return v; + } + int(n: number): number { + return Math.floor(this.next() * n); + } + shuffle(a: readonly T[]): T[] { + return [...a]; + } + get left(): number { + return this.vals.length; + } +} +const F = 0; // 배(flat) up +const R = 0.99; // 등(round) up +const STICKS: Record = { do: [R, F, R, R], backdo: [F, R, R, R], gae: [F, F, R, R], geol: [F, F, F, R], yut: [F, F, F, F], mo: [R, R, R, R] }; +/** RNG producing the given results; with `nak` on, every throw first consumes the 낙 roll. */ +const script = (results: (Throw | 'nak')[], nak = false) => + new Script(results.flatMap((t) => (t === 'nak' ? [0] : [...(nak ? [R] : []), ...STICKS[t]]))); + +const PLAYERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + +/** Fresh game with side 0 to move. */ +function make(opts: Partial = {}, n = 2): YutState { + const options = yut.optionsSchema.parse(opts); + const s = yut.setup({ players: PLAYERS.slice(0, n), options, rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + s.turnSide = 0; + s.current = s.sides[0]!.members[0]!; + return s; +} + +function put(s: YutState, side: number, piece: number, node: NodeId, trail: NodeId[] = [node]): void { + Object.assign(s.sides[side]!.pieces[piece]!, { status: 'board', node, trail, lapped: node === 0 }); +} + +/** Puts the side to move into the move phase with these results. */ +function moving(s: YutState, pending: Throw[]): YutState { + s.phase = 'move'; + s.throwsOwed = 0; + s.pending = [...pending]; + return s; +} + +let clock = 1000; +function actE(s: YutState, p: string, a: YutAction, rng: Rng = new Script([])): { state: YutState; events: GameEvent[] } { + const v = yut.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return yut.apply(s, p, a, { rng, now: (clock += 1000) }); +} +const act = (s: YutState, p: string, a: YutAction, rng?: Rng) => actE(s, p, a, rng).state; +const reject = (s: YutState, p: string, a: YutAction) => { + const v = yut.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const mv = (throwIndex: number, from: NodeId | 'home'): YutAction => ({ type: 'MOVE', throwIndex, from }); +/** Uses the first pending result equal to `t`. */ +const use = (s: YutState, t: Throw, from: NodeId | 'home') => act(s, s.current, mv(s.pending.indexOf(t), from)); +const piece = (s: YutState, side: number, id: number) => s.sides[side]!.pieces[id]!; +const throwN = (s: YutState, rng: Rng, n = 1) => { + for (let i = 0; i < n; i++) s = act(s, s.current, { type: 'THROW' }, rng); + return s; +}; + +// ---------------------------------------------------------------- sticks + +describe('윷가락 확률', () => { + const TABLE: Record<'realistic' | 'fair', Record> = { + realistic: { do: 0.1152, backdo: 0.0384, gae: 0.3456, geol: 0.3456, yut: 0.1296, mo: 0.0256 }, + fair: { do: 0.1875, backdo: 0.0625, gae: 0.375, geol: 0.25, yut: 0.0625, mo: 0.0625 }, + }; + + test('formula matches the §5.1 table', () => { + for (const m of ['realistic', 'fair'] as const) { + const p = throwProbabilities(FLAT_P[m]); + for (const t of Object.keys(TABLE[m]) as Throw[]) expect(p[t]).toBeCloseTo(TABLE[m][t], 4); + } + }); + + test.each(['realistic', 'fair'] as const)('%s: 100,000 seeded throws within ±0.5%%p of the table', (stickModel) => { + const rng = SeededRng.fromSeed(seedN(stickModel === 'fair' ? 2 : 1)); + const counts: Record = {}; + const N = 100_000; + for (let i = 0; i < N; i++) { + const r = rollSticks(rng, { stickModel, backdo: true, nakPercent: 0 }); + counts[r.result] = (counts[r.result] ?? 0) + 1; + } + for (const t of Object.keys(TABLE[stickModel]) as Throw[]) expect(Math.abs((counts[t] ?? 0) / N - TABLE[stickModel][t])).toBeLessThan(0.005); + }, 30_000); + + test('without 빽도 the marked stick is a plain 도; 낙 is drawn first', () => { + expect(rollSticks(new Script(STICKS.backdo), { stickModel: 'realistic', backdo: false, nakPercent: 0 }).result).toBe('do'); + expect(rollSticks(new Script(STICKS.backdo), { stickModel: 'realistic', backdo: true, nakPercent: 0 })).toEqual({ sticks: [true, false, false, false], result: 'backdo' }); + expect(rollSticks(new Script([0.05]), { stickModel: 'fair', backdo: true, nakPercent: 10 })).toEqual({ sticks: null, result: 'nak' }); + expect(rollSticks(new Script([0.1, ...STICKS.mo]), { stickModel: 'fair', backdo: true, nakPercent: 10 }).result).toBe('mo'); + }); +}); + +// ---------------------------------------------------------------- throwing & moving + +describe('던지기와 이동', () => { + test('윷 → 모 → 걸: throwing stops at 걸, results are used in any order, one piece finishes in one turn (예시 1)', () => { + let s = make(); + const rng = script(['yut', 'mo', 'geol']); + s = throwN(s, rng); + expect([s.phase, s.throwsOwed, s.pending]).toEqual(['throw', 1, ['yut']]); + s = throwN(s, rng); + expect([s.phase, s.pending]).toEqual(['throw', ['yut', 'mo']]); + s = throwN(s, rng); + expect([s.phase, s.throwsOwed, s.pending]).toEqual(['move', 0, ['yut', 'mo', 'geol']]); + expect(reject(s, 'A', { type: 'THROW' })).toBe('지금은 윷을 던질 차례가 아니에요.'); + s = use(s, 'mo', 'home'); + expect(piece(s, 0, 0)).toMatchObject({ status: 'board', node: 5, trail: [1, 2, 3, 4, 5] }); + s = use(s, 'geol', 5); + expect(piece(s, 0, 0)).toMatchObject({ node: 22, trail: [1, 2, 3, 4, 5, 20, 21, 22] }); + const r = actE(s, 'A', mv(0, 22)); + s = r.state; + expect(piece(s, 0, 0)).toMatchObject({ status: 'done', node: null }); + expect(r.events.find((e) => e.type === 'moved')).toMatchObject({ from: 22, to: 'goal', path: [27, 28, 0] }); + expect(r.events.map((e) => e.type)).toContain('finishedPieces'); + expect([s.current, s.phase, s.pending]).toEqual(['B', 'throw', []]); + }); + + test('passing 모(5) goes straight; stopping on 5 turns into the shortcut', () => { + expect(advance(4, 3, 'pass')).toEqual({ path: [5, 6, 7], to: 7 }); + expect(advance(5, 1, 'pass')).toEqual({ path: [20], to: 20 }); + expect(advance(10, 2, 'pass')).toEqual({ path: [25, 26], to: 26 }); + expect(advance(9, 2, 'pass')).toEqual({ path: [10, 11], to: 11 }); + let s = make(); + put(s, 0, 0, 4, [1, 2, 3, 4]); + put(s, 0, 1, 5, [1, 2, 3, 4, 5]); + s = moving(s, ['geol', 'do']); + s = use(s, 'geol', 4); + expect(piece(s, 0, 0).node).toBe(7); + s = use(s, 'do', 5); + expect(piece(s, 0, 1).node).toBe(20); + }); + + test('방(22): through from 21 → 23, through from 26 → 27, starting on 22 → 27 (예시 2)', () => { + expect(advance(21, 2, 'pass').path).toEqual([22, 23]); + expect(advance(26, 2, 'pass').path).toEqual([22, 27]); + expect(advance(22, 1, 'pass').path).toEqual([27]); + expect(advance(20, 3, 'pass')).toEqual({ path: [21, 22, 23], to: 23 }); + expect(advance(24, 1, 'pass').to).toBe(15); + }); + + test('찌모(15) has no shortcut even after stopping there', () => { + expect(advance(15, 1, 'pass').to).toBe(16); + expect(advance(14, 2, 'pass').path).toEqual([15, 16]); + }); + + test('pass: 28 + 도 stops on 참먹이 (not finished, can be caught); next 도 finishes. reach: finishes at once (예시 4)', () => { + let s = make(); + put(s, 0, 0, 28, [27, 28]); + put(s, 1, 0, 19, [19]); + s = use(moving(s, ['do']), 'do', 28); + expect(piece(s, 0, 0)).toMatchObject({ status: 'board', node: 0, lapped: true }); + expect(s.current).toBe('B'); + // B lands on 0 and catches A's piece, then gets a bonus throw. + s = use(moving(s, ['do']), 'do', 19); + expect(piece(s, 0, 0)).toMatchObject({ status: 'home', node: null, trail: [] }); + expect(piece(s, 1, 0)).toMatchObject({ status: 'board', node: 0 }); + expect([s.current, s.phase, s.throwsOwed]).toEqual(['B', 'throw', 1]); + // From 0 any forward result finishes. + s = use(moving(s, ['do']), 'do', 0); + expect(piece(s, 1, 0).status).toBe('done'); + + let r = make({ finishRule: 'reach' }); + put(r, 0, 0, 28, [27, 28]); + r = use(moving(r, ['do']), 'do', 28); + expect(piece(r, 0, 0).status).toBe('done'); + expect(advance(19, 3, 'reach').to).toBe('goal'); + expect(advance(19, 3, 'pass')).toEqual({ path: [0], to: 'goal' }); + }); + + test('빽도 goes back the way it came; after 빽도 to 21 a 개 passes 방 straight to 23', () => { + let s = make(); + put(s, 0, 0, 21, [1, 2, 3, 4, 5, 20, 21]); + put(s, 0, 1, 26, [10, 25, 26]); + s = moving(s, ['do', 'do', 'backdo', 'backdo', 'gae']); + s = act(s, 'A', mv(0, 21)); + expect(piece(s, 0, 0).node).toBe(22); + s = use(s, 'backdo', 22); + expect(piece(s, 0, 0)).toMatchObject({ node: 21, trail: [1, 2, 3, 4, 5, 20, 21] }); + s = use(s, 'do', 26); + expect(piece(s, 0, 1).node).toBe(22); + s = use(s, 'backdo', 22); + expect(piece(s, 0, 1).node).toBe(26); + s = use(s, 'gae', 21); + expect(piece(s, 0, 0).node).toBe(23); + }); + + test('도 자리 빽도: chammeogi → 참먹이, next 개 finishes; finish → done at once', () => { + let s = make(); + s = use(moving(s, ['do', 'backdo']), 'do', 'home'); + expect(piece(s, 0, 0)).toMatchObject({ node: 1, trail: [1] }); + s = use(s, 'backdo', 1); + expect(piece(s, 0, 0)).toMatchObject({ status: 'board', node: 0, lapped: true, trail: [0] }); + s.current = 'A'; + s.turnSide = 0; + s = use(moving(s, ['gae']), 'gae', 0); + expect(piece(s, 0, 0).status).toBe('done'); + // 빽도 from a 참먹이 piece without a record goes to 19. + let z = make(); + put(z, 0, 0, 0, [0]); + z = use(moving(z, ['backdo']), 'backdo', 0); + expect(piece(z, 0, 0)).toMatchObject({ node: 19, lapped: false }); + + let f = make({ backdoFromDo: 'finish' }); + put(f, 0, 0, 1, [1]); + const r = actE(moving(f, ['backdo']), 'A', mv(0, 1)); + f = r.state; + expect(piece(f, 0, 0).status).toBe('done'); + expect(r.events.map((e) => e.type)).toContain('finishedPieces'); + }); + + test('stuck results: [빽도] with nothing on the board is discarded and the turn passes; [빽도, 개] can use both', () => { + let s = make(); + const r = actE(s, 'A', { type: 'THROW' }, script(['backdo'])); + s = r.state; + expect(r.events.find((e) => e.type === 'discarded')).toMatchObject({ throws: ['backdo'] }); + expect([s.current, s.phase, s.pending]).toEqual(['B', 'throw', []]); + + let t = moving(make(), ['backdo', 'gae']); + expect(yut.view(t, 'A').legalMoves!.map((m) => [m.throw, m.from])).toEqual([['gae', 'home']]); + expect(reject(t, 'A', mv(0, 'home'))).toBe('빽도로는 새 말을 올릴 수 없어요.'); + t = act(t, 'A', mv(1, 'home')); + expect(piece(t, 0, 0).node).toBe(2); + expect(t.pending).toEqual(['backdo']); + t = act(t, 'A', mv(0, 2)); + expect(piece(t, 0, 0).node).toBe(1); + expect(t.current).toBe('B'); + }); + + test('without 빽도 the backdo stick result counts as 도', () => { + let s = make({ backdo: false }); + s = act(s, 'A', { type: 'THROW' }, script(['backdo'])); + expect(s.pending).toEqual(['do']); + }); + + test('업기: stacked pieces move, finish and get caught together; one bonus for catching a stack', () => { + let s = make(); + put(s, 0, 0, 3, [1, 2, 3]); + s = use(moving(s, ['geol', 'gae', 'yut', 'geol']), 'geol', 'home'); + expect(piece(s, 0, 1)).toMatchObject({ node: 3, trail: [1, 2, 3] }); + expect(yut.view(s, 'A').stacks).toEqual([{ node: 3, side: 0, count: 2 }]); + s = use(s, 'gae', 3); + expect([piece(s, 0, 0).node, piece(s, 0, 1).node]).toEqual([5, 5]); + s = use(s, 'geol', 5); // 20 21 22 + expect([piece(s, 0, 0).node, piece(s, 0, 1).node]).toEqual([22, 22]); + const r = actE(s, 'A', mv(0, 22)); // 윷: 27 28 0 → 남 + expect([piece(r.state, 0, 0).status, piece(r.state, 0, 1).status]).toEqual(['done', 'done']); + expect(r.events.find((e) => e.type === 'moved')).toMatchObject({ count: 2 }); + + let c = make(); + put(c, 1, 0, 4, [1, 2, 3, 4]); + put(c, 1, 1, 4, [1, 2, 3, 4]); + put(c, 0, 0, 2, [1, 2]); + const cr = actE(moving(c, ['gae', 'do']), 'A', mv(0, 2)); + c = cr.state; + expect([piece(c, 1, 0).status, piece(c, 1, 1).status]).toEqual(['home', 'home']); + expect(cr.events.find((e) => e.type === 'captured')).toMatchObject({ victimSide: 1, count: 2 }); + expect([c.phase, c.throwsOwed, c.pending]).toEqual(['throw', 1, ['do']]); + expect(c.stats.captures['0']).toBe(1); + }); + + test('capture bonus keeps the remaining results; a 윷 on the bonus throw throws again', () => { + let s = make(); + put(s, 0, 0, 2, [1, 2]); + put(s, 1, 0, 4, [1, 2, 3, 4]); + s = use(moving(s, ['gae', 'geol']), 'gae', 2); + expect([s.phase, s.pending]).toEqual(['throw', ['geol']]); + const rng = script(['yut', 'do']); + s = throwN(s, rng); + expect([s.phase, s.pending, s.throwsOwed]).toEqual(['throw', ['geol', 'yut'], 1]); + s = throwN(s, rng); + expect([s.phase, s.pending]).toEqual(['move', ['geol', 'yut', 'do']]); + }); + + test('빽도 capture: bonus only when backdoCaptureBonus is on; the catcher stands on 모 and takes the shortcut (예시 3)', () => { + for (const bonus of [true, false]) { + let s = make({ backdoCaptureBonus: bonus }); + put(s, 0, 0, 6, [1, 2, 3, 4, 5, 6]); + put(s, 1, 0, 5, [1, 2, 3, 4, 5]); + s = use(moving(s, ['backdo', 'do']), 'backdo', 6); + expect(piece(s, 1, 0).status).toBe('home'); + expect(piece(s, 0, 0).node).toBe(5); + expect(s.phase).toBe(bonus ? 'throw' : 'move'); + if (!bonus) { + s = use(s, 'do', 5); + expect(piece(s, 0, 0).node).toBe(20); + } + } + }); + + test('passing over an opponent does not catch it', () => { + let s = make(); + put(s, 0, 0, 1, [1]); + put(s, 1, 0, 2, [1, 2]); + s = use(moving(s, ['geol']), 'geol', 1); + expect(piece(s, 1, 0)).toMatchObject({ status: 'board', node: 2 }); + expect(s.current).toBe('B'); + }); + + test('낙 10%: a 낙 gives nothing, the earlier 윷 is kept and the turn goes to moving', () => { + let s = make({ nakPercent: 10 }); + const rng = script(['yut', 'nak'], true); + s = throwN(s, rng); + const r = actE(s, 'A', { type: 'THROW' }, rng); + s = r.state; + expect(r.events.map((e) => e.type)).toContain('nak'); + expect([s.phase, s.pending, s.lastThrow?.result]).toEqual(['move', ['yut'], 'nak']); + expect(s.stats.throws.nak).toBe(1); + // A 낙 with nothing collected ends the turn. + let t = make({ nakPercent: 10 }); + t = act(t, 'A', { type: 'THROW' }, script(['nak'], true)); + expect(t.current).toBe('B'); + }); + + test('validation messages', () => { + const s = make(); + expect(reject(s, 'B', { type: 'THROW' })).toBe('지금은 다른 사람 차례예요.'); + expect(reject(s, 'Z', { type: 'THROW' })).toBe('이 게임의 플레이어가 아니에요.'); + expect(reject(s, 'A', mv(0, 'home'))).toBe('지금은 말을 움직일 차례가 아니에요. 먼저 윷을 던져 주세요.'); + const m = moving(make(), ['do']); + expect(reject(m, 'A', mv(3, 'home'))).toBe('그 윷 결과는 없어요.'); + expect(reject(m, 'A', mv(0, 7))).toBe('그 말은 움직일 수 없어요.'); + expect(yut.actionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false); + expect(yut.actionSchema.safeParse({ type: 'MOVE', throwIndex: 0, from: 29 }).success).toBe(false); + expect(yut.actionSchema.safeParse({ type: 'MOVE', throwIndex: 0, from: 'home' }).success).toBe(true); + }); +}); + +// ---------------------------------------------------------------- turns, teams, ranking + +describe('차례·팀전·순위', () => { + test('팀전 4명: 청1 → 홍1 → 청2 → 홍2, and only the player to move may act', () => { + let s = yut.setup({ players: ['A', 'B', 'C', 'D'], options: yut.optionsSchema.parse({ mode: 'team' }), rng: new Script([0]), now: 0 }); + expect(s.sides.map((x) => x.members)).toEqual([ + ['A', 'C'], + ['B', 'D'], + ]); + const order: string[] = []; + // A: 개 → 2, B: 걸 → 3, C: 도 → 1, D: 걸 → 3 (업기 with teammate B's piece). + for (const t of ['gae', 'geol', 'do', 'geol'] as Throw[]) { + order.push(s.current); + s = act(s, s.current, { type: 'THROW' }, script([t])); + if (s.current === 'B') { + // 홍1(B) is moving: 청2(C) cannot act. + expect(reject(s, 'C', mv(0, 'home'))).toBe('지금은 다른 사람 차례예요.'); + } + s = act(s, s.current, mv(0, 'home')); + } + order.push(s.current); + expect(order).toEqual(['A', 'B', 'C', 'D', 'A']); + expect(yut.view(s, null).stacks).toEqual([ + { node: 1, side: 0, count: 1 }, + { node: 2, side: 0, count: 1 }, + { node: 3, side: 1, count: 2 }, + ]); + }); + + test('팀전 6명 seats alternate teams; a team that finishes wins for all its members', () => { + let s = yut.setup({ players: PLAYERS.slice(0, 6), options: yut.optionsSchema.parse({ mode: 'team', pieces: 2 }), rng: new Script([0.6]), now: 0 }); + expect(s.sides.map((x) => x.members)).toEqual([ + ['A', 'C', 'E'], + ['B', 'D', 'F'], + ]); + expect(s.current).toBe('B'); + s.sides[1]!.pieces[0]!.status = 'done'; + put(s, 1, 1, 0, [28, 0]); + s = act(moving(s, ['do']), 'B', mv(0, 0)); + expect(yut.result(s)).toEqual({ + ranking: [ + ['B', 'D', 'F'], + ['A', 'C', 'E'], + ], + scores: { B: 2, D: 2, F: 2, A: 0, C: 0, E: 0 }, + summary: '홍팀 승리! (말 2개 모두 남)', + reason: 'normal', + }); + expect(s.final!.winners).toEqual(['B', 'D', 'F']); + }); + + test('개인전 3명 ranking=all: the winner drops out of the order and play goes on to the last place', () => { + let s = make({ ranking: 'all', pieces: 2 }, 3); + s.sides[0]!.pieces[0]!.status = 'done'; + const done = (side: number, id: number) => Object.assign(piece(s, side, id), { status: 'done', node: null, trail: [] }); + put(s, 0, 1, 19, [19]); + s = act(moving(s, ['gae', 'do']), 'A', mv(0, 19)); + expect(s.sides[0]!.finishedRank).toBe(1); + expect(s.phase).toBe('throw'); + expect(s.current).toBe('B'); + s = act(s, 'B', { type: 'THROW' }, script(['gae'])); + s = act(s, 'B', mv(0, 'home')); + expect(s.current).toBe('C'); + s = act(s, 'C', { type: 'THROW' }, script(['geol'])); + s = act(s, 'C', mv(0, 'home')); + expect(s.current).toBe('B'); // A is skipped + // B finishes second → C is last and the game ends. + done(1, 0); + put(s, 1, 1, 28, [28]); + s = act(moving(s, ['geol']), 'B', mv(0, 28)); + expect(s.phase).toBe('finished'); + expect(yut.result(s)!.ranking).toEqual([['A'], ['B'], ['C']]); + expect(s.final!.summary).toBe('빨강 1등! (말 2개 모두 남) · 꼴찌까지 순위가 정해졌어요'); + }); + + test('ranking=first: the game ends at the first finisher, others ranked by finished pieces then distance (ties shared)', () => { + let s = make({ pieces: 2 }, 4); + s.sides[0]!.pieces[0]!.status = 'done'; + put(s, 0, 1, 19, [19]); + s.sides[1]!.pieces[0]!.status = 'done'; // B: 1 done + 1 home (12) + put(s, 2, 0, 15, [15]); // C: 6 + 12 = 18 + put(s, 3, 0, 15, [15]); // D: same as C + s = act(moving(s, ['gae']), 'A', mv(0, 19)); + const r = s.final!; + expect(r.ranking.map((e) => [e.rank, e.players[0], e.finishedPieces, e.remainingDistance])).toEqual([ + [1, 'A', 2, 0], + [2, 'B', 1, 12], + [3, 'C', 0, 18], + [3, 'D', 0, 18], + ]); + expect(yut.result(s)!.ranking).toEqual([['A'], ['B'], ['C', 'D']]); + }); + + test('기권: individual side drops out (pieces leave the board); last one standing wins; team needs every member', () => { + let s = make({}, 3); + put(s, 1, 0, 7, [7]); + s = act(s, 'B', { type: 'CONCEDE' }); + expect(s.sides[1]!.out).toBe(true); + expect(piece(s, 1, 0).status).toBe('home'); + expect(s.phase).toBe('throw'); + expect(s.current).toBe('A'); + expect(yut.onLeave!(s, 'B')).toBeNull(); + expect(yut.onLeave!(s, 'C')).toEqual({ type: 'CONCEDE' }); + s = act(s, 'A', { type: 'CONCEDE' }); // the player to move concedes: turn passes on, then C is alone + expect(s.phase).toBe('finished'); + expect(yut.result(s)).toMatchObject({ ranking: [['C'], ['A'], ['B']], reason: 'resign', summary: '다른 사람이 모두 기권해서 초록 1등!' }); + + let t = yut.setup({ players: ['A', 'B', 'C', 'D'], options: yut.optionsSchema.parse({ mode: 'team' }), rng: new Script([0]), now: 0 }); + t = act(t, 'A', { type: 'CONCEDE' }); + expect(t.phase).toBe('throw'); + expect(t.current).toBe('C'); // the teammate takes over the turn + t = act(t, 'C', { type: 'THROW' }, script(['gae'])); + t = act(t, 'C', mv(0, 'home')); + expect(t.current).toBe('B'); + t = act(t, 'B', { type: 'THROW' }, script(['geol'])); + t = act(t, 'B', mv(0, 'home')); + expect(t.current).toBe('C'); // 청팀 has only C left + t = act(t, 'C', { type: 'CONCEDE' }); + expect(yut.result(t)).toMatchObject({ ranking: [['B', 'D'], ['A', 'C']], reason: 'resign', summary: '청팀 기권으로 홍팀 승리!' }); + }); + + test('player counts: 개인전 2~4, 팀전 4·6·8 (개인전 5명·팀전 5명 거부)', () => { + expect(yut.playersFor!(yut.defaultOptions)).toEqual({ min: 2, max: 4 }); + expect(yut.playersFor!({ ...yut.defaultOptions, mode: 'team' })).toEqual({ min: 4, max: 8 }); + expect(playerCountError(yut.defaultOptions, 5)).toBe('개인전은 2~4명이 할 수 있어요.'); + expect(playerCountError({ mode: 'team' }, 5)).toBe('팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.'); + expect(playerCountError({ mode: 'team' }, 2)).not.toBeNull(); + for (const n of [4, 6, 8]) expect(yut.playerCountError!({ ...yut.defaultOptions, mode: 'team' }, n)).toBeNull(); + const mk = (mode: 'individual' | 'team', n: number) => () => + yut.setup({ players: PLAYERS.slice(0, n), options: yut.optionsSchema.parse({ mode }), rng: new Script([0]), now: 0 }); + expect(mk('individual', 5)).toThrow(); + expect(mk('team', 5)).toThrow(); + expect(yut.optionsSchema.safeParse({ pieces: 6 }).success).toBe(false); + expect(yut.optionsSchema.parse({ turnSeconds: null }).turnSeconds).toBeNull(); + }); +}); + +// ---------------------------------------------------------------- time + +describe('시간 초과', () => { + test('throw phase → automatic throw; move phase prefers a capture (most pieces first)', () => { + let s = make(); + s = act(s, 'A', yut.onTimeout(s, 'A'), script(['gae'])); + expect([s.phase, s.pending]).toEqual(['move', ['gae']]); + s = act(s, 'A', yut.onTimeout(s, 'A')); + expect(piece(s, 0, 0).node).toBe(2); // only legal move: enter + expect(s.timeouts.A).toBe(2); + + let c = make(); + put(c, 0, 0, 3, [1, 2, 3]); + put(c, 0, 1, 10, [10]); + put(c, 1, 0, 5, [5]); + put(c, 1, 1, 12, [12]); + put(c, 1, 2, 12, [12]); + c = moving(c, ['gae', 'geol']); + // Only 3 + 개 → 5 catches (10 turns into the shortcut, 3 + 걸 passes 5). + expect(autoAction(c)).toEqual({ type: 'MOVE', throwIndex: 0, from: 3 }); + put(c, 0, 1, 9, [9]); // 9 + 걸 → 10, 11, 12: catches two + expect(autoAction(c)).toEqual({ type: 'MOVE', throwIndex: 1, from: 9 }); + const after = act(c, 'A', { type: 'TIMEOUT' }); + expect([piece(after, 1, 1).status, piece(after, 1, 2).status]).toEqual(['home', 'home']); + }); + + test('priority after captures: finish > stack > most-behind piece with the biggest result > enter', () => { + const base = () => { + const s = make(); + put(s, 0, 0, 19, [19]); + put(s, 0, 1, 2, [1, 2]); + return s; + }; + expect(autoAction(moving(base(), ['gae', 'do']))).toEqual({ type: 'MOVE', throwIndex: 0, from: 19 }); // finish + const st = base(); + put(st, 0, 0, 17, [17]); + put(st, 0, 2, 4, [4]); + expect(autoAction(moving(st, ['gae', 'do']))).toEqual({ type: 'MOVE', throwIndex: 0, from: 2 }); // 2+개 → 4 stacks + const far = base(); + put(far, 0, 0, 17, [17]); + expect(autoAction(moving(far, ['do', 'geol']))).toEqual({ type: 'MOVE', throwIndex: 1, from: 2 }); // behind piece, biggest result + expect(autoAction(moving(make(), ['geol', 'do']))).toEqual({ type: 'MOVE', throwIndex: 0, from: 'home' }); + expect(autoAction(make())).toEqual({ type: 'THROW' }); + }); + + test('deadline follows turnSeconds; three timeouts in a row mark the player away (5 s); a real action clears it', () => { + let s = yut.setup({ players: ['A', 'B'], options: yut.optionsSchema.parse({ turnSeconds: 15 }), rng: new Script([0]), now: 100 }); + expect(yut.deadline!(s)).toBe(100 + 15_000); + expect(yut.timeoutPlayers!(s)).toEqual(['A']); + const r1 = yut.apply(s, 'A', { type: 'TIMEOUT' }, { rng: script(['gae']), now: 1000 }); + expect(r1.state.deadlineAt).toBe(1000 + 15_000); + s = r1.state; + s = yut.apply(s, 'A', { type: 'TIMEOUT' }, { rng: new Script([]), now: 2000 }).state; + expect(s.current).toBe('B'); + s = yut.apply(s, 'B', { type: 'THROW' }, { rng: script(['geol']), now: 3000 }).state; + s = yut.apply(s, 'B', mv(0, 'home'), { rng: new Script([]), now: 4000 }).state; + const r = yut.apply(s, 'A', { type: 'TIMEOUT' }, { rng: script(['gae']), now: 5000 }); + expect(r.events.map((e) => e.type)).toContain('away'); + expect(yut.view(r.state, null).away).toEqual(['A']); + expect(r.state.deadlineAt).toBe(5000 + 5000); + s = yut.apply(r.state, 'A', mv(0, 'home'), { rng: new Script([]), now: 6000 }).state; + expect(s.timeouts.A).toBe(0); + const unlimited = make({ turnSeconds: null }); + expect(yut.deadline!(unlimited)).toBeNull(); + }); +}); + +// ---------------------------------------------------------------- view + +describe('view', () => { + test('same public view for everyone; no RNG or seed anywhere; legal moves only for the player to move', () => { + let s = yut.setup({ players: ['A', 'B'], options: yut.defaultOptions, rng: SeededRng.fromSeed(seedN(5)), now: 0 }); + s = moving(s, ['gae', 'backdo']); + put(s, s.turnSide, 0, 3, [1, 2, 3]); + const cur = s.current; + const other = cur === 'A' ? 'B' : 'A'; + const strip = (v: ReturnType) => ({ ...v, me: null, mySide: null, legalMoves: null, suggested: null }); + expect(strip(yut.view(s, 'A'))).toEqual(strip(yut.view(s, null))); + expect(strip(yut.view(s, 'B'))).toEqual(strip(yut.view(s, null))); + for (const v of [yut.view(s, 'A'), yut.view(s, 'B'), yut.view(s, null)]) expect(JSON.stringify(v)).not.toMatch(/rng|seed/i); + expect(JSON.stringify(s)).not.toMatch(/rng|seed/i); + expect(yut.view(s, other).legalMoves).toBeNull(); + expect(yut.view(s, null).legalMoves).toBeNull(); + const mine = yut.view(s, cur); + expect(mine.legalMoves!.map((m) => [m.throw, m.from, m.to])).toEqual([ + ['gae', 3, 5], + ['gae', 'home', 2], + ['backdo', 3, 2], + ]); + expect(mine.suggested).toEqual({ throwIndex: 0, from: 3 }); + expect(mine.mySide).toBe(s.sideOf[cur]!); + }); + + test('legal move badges: 잡기 / 업기 / 날', () => { + const s = make(); + put(s, 0, 0, 19, [19]); + put(s, 0, 1, 1, [1]); + put(s, 1, 0, 3, [3]); + const lm = yut.view(moving(s, ['gae']), 'A').legalMoves!; + expect(lm.find((m) => m.from === 19)).toMatchObject({ finishes: true, to: 'goal' }); + expect(lm.find((m) => m.from === 1)).toMatchObject({ captures: 1, to: 3 }); + expect(lm.find((m) => m.from === 'home')).toMatchObject({ stacks: false, to: 2 }); + const st = make(); + put(st, 0, 0, 2, [1, 2]); + expect(yut.view(moving(st, ['gae']), 'A').legalMoves!.find((m) => m.from === 'home')).toMatchObject({ stacks: true }); + }); +}); + +// ---------------------------------------------------------------- properties + +interface Config { + opts: Partial; + n: number; +} +const CONFIGS: Config[] = [ + { opts: {}, n: 2 }, + { opts: { stickModel: 'fair', backdoFromDo: 'finish' }, n: 3 }, + { opts: { ranking: 'all', nakPercent: 10 }, n: 4 }, + { opts: { mode: 'team', pieces: 3 }, n: 4 }, + { opts: { mode: 'team', pieces: 2, finishRule: 'reach', backdoCaptureBonus: false }, n: 6 }, + { opts: { mode: 'team', pieces: 5, backdo: false, nakPercent: 3 }, n: 8 }, + { opts: { pieces: 5, ranking: 'all', finishRule: 'reach', stickModel: 'fair' }, n: 3 }, + { opts: { pieces: 2, nakPercent: 5, turnSeconds: null }, n: 2 }, +]; + +/** Per-step invariants: piece count, one side per node, trail ends on the node, phase consistency, onTimeout legal. */ +function invariant(st: unknown): void { + const s = st as YutState; + const occupied = new Map(); + for (const x of s.sides) { + if (x.pieces.length !== s.options.pieces) throw new Error('piece count changed'); + for (const p of x.pieces) { + if (p.status === 'board') { + if (p.node === null || p.node < 0 || p.node > 28) throw new Error('board piece without node'); + if (p.trail.at(-1) !== p.node) throw new Error(`trail ${p.trail} does not end at ${p.node}`); + if (p.node === 0 && !p.lapped) throw new Error('piece on 참먹이 not lapped'); + const o = occupied.get(p.node); + if (o !== undefined && o !== x.id) throw new Error(`two sides on node ${p.node}`); + occupied.set(p.node, x.id); + } else if (p.node !== null || p.trail.length) throw new Error('off-board piece keeps a position'); + } + const stacked = new Map(); + for (const p of x.pieces) { + if (p.status !== 'board') continue; + const key = JSON.stringify(p.trail); + if (stacked.has(p.node!) && stacked.get(p.node!) !== key) throw new Error('stack with different trails'); + stacked.set(p.node!, key); + } + if (x.finishedRank !== null && x.pieces.some((p) => p.status !== 'done')) throw new Error('finished side with pieces left'); + } + if (s.phase === 'finished') { + if (!s.final || s.deadlineAt !== null) throw new Error('finished without result'); + return; + } + if (s.phase === 'throw' && s.throwsOwed < 1) throw new Error('throw phase without owed throws'); + if (s.phase === 'move' && (s.throwsOwed !== 0 || s.pending.length === 0)) throw new Error('bad move phase'); + if (s.phase === 'move' && yut.legalActions!(s, s.current).length === 0) throw new Error('move phase without legal moves'); + const side = s.sides[s.turnSide]!; + if (side.finishedRank !== null || side.out || !side.members.includes(s.current) || side.conceded.includes(s.current)) throw new Error('turn on an inactive side'); + for (const p of yut.timeoutPlayers!(s)) { + const v = yut.validate(s, p, yut.onTimeout(s, p)); + const auto = yut.validate(s, p, autoAction(s)); + if (!v.ok || !auto.ok) throw new Error('onTimeout illegal'); + } +} + +describe('random playouts', () => { + test('1,000 seeds: finish within the step limit, invariants hold, onTimeout always legal, deterministic, replayable', () => { + let steps = 0; + for (let i = 1; i <= 1000; i++) { + const cfg = CONFIGS[i % CONFIGS.length]!; + const options = yut.optionsSchema.parse(cfg.opts); + const players = PLAYERS.slice(0, cfg.n); + const a = randomPlayout(yut, { players, options, seed: seedN(i), maxSteps: 3000, invariant, maxApplyMs: 60 }); + if (!a.finished) throw new Error(`seed ${i} did not finish`); + steps += a.steps; + const res = yut.result(a.finalState as YutState)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + if (i % 25 === 0) { + const b = randomPlayout(yut, { players, options, seed: seedN(i), maxSteps: 3000, maxApplyMs: 60 }); + expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace)); + expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState)); + // Replay the recorded actions from the same seed → identical final state. + const r = SeededRng.fromSeed(seedN(i)); + let now = 1_700_000_000_000; + let s = yut.setup({ players, options, rng: r, now }); + for (const { actor, action } of a.trace) { + now += 1000; + s = yut.apply(s, actor, action as YutAction, { rng: r, now }).state; + } + expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState)); + } + } + expect(steps).toBeGreaterThan(1000 * 20); + }, 120_000); + + test('apply is fast: p99 under 5 ms (the per-step cap above only guards against GC/scheduler spikes)', () => { + const times: number[] = []; + for (let i = 1; i <= 100; i++) { + const rng = SeededRng.fromSeed(seedN(i)); + let s = yut.setup({ players: ['A', 'B', 'C', 'D'], options: yut.defaultOptions, rng, now: 0 }); + while (s.phase !== 'finished') { + const a = autoAction(s); + const t0 = performance.now(); + s = yut.apply(s, s.current, a, { rng, now: 0 }).state; + times.push(performance.now() - t0); + } + } + times.sort((a, b) => a - b); + expect(times[Math.floor(times.length * 0.99)]!).toBeLessThan(5); + }, 30_000); + + test('timeout-only games finish too (auto play from every state), and random concessions keep the game consistent', () => { + for (let i = 1; i <= 60; i++) { + const cfg = CONFIGS[i % CONFIGS.length]!; + const options = yut.optionsSchema.parse(cfg.opts); + const players = PLAYERS.slice(0, cfg.n); + const rng = SeededRng.fromSeed(seedN(i + 5000)); + let s = yut.setup({ players, options, rng, now: 0 }); + let n = 0; + for (; s.phase !== 'finished' && n < 3000; n++) { + const p = yut.timeoutPlayers!(s)[0]!; + // Now and then somebody concedes (any player, any time). + const quitter = players[rng.int(players.length)]!; + const a: YutAction = rng.next() < 0.01 && yut.validate(s, quitter, { type: 'CONCEDE' }).ok ? { type: 'CONCEDE' } : yut.onTimeout(s, p); + const actor = a.type === 'CONCEDE' ? quitter : p; + expect(yut.validate(s, actor, a).ok).toBe(true); + s = yut.apply(s, actor, a, { rng, now: n * 1000 }).state; + invariant(s); + } + expect(s.phase).toBe('finished'); + expect(yut.result(s)!.ranking.flat().sort()).toEqual([...players].sort()); + } + }, 60_000); +}); diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index 51a5494..e4dca39 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -19,6 +19,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'seotda', nameKo: '섯다', minPlayers: 2, maxPlayers: 10, minutes: '20~40', level: '보통', blurb: '화투 두 장으로 겨루는 족보 승부 · 놀이용 칩', available: true }, { id: 'poker', nameKo: '포커', minPlayers: 2, maxPlayers: 10, minutes: '20~60', level: '보통', blurb: '홀덤·7포커·인디언 등 7가지 모드 · 놀이용 칩', available: true }, { id: 'yacht', nameKo: '요트 다이스', minPlayers: 1, maxPlayers: 8, minutes: '10~30', level: '쉬움', blurb: '주사위 다섯 개로 족보 만들기 · 혼자서도 OK', available: true }, + { id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);