From ad7af30daa75c831b0d23ee7a5bf81d7b493987b Mon Sep 17 00:00:00 2001 From: EJClaw Date: Mon, 5 Oct 2026 13:31:22 +0900 Subject: [PATCH] =?UTF-8?q?M6:=20=EC=A3=BC=EC=82=AC=EC=9C=84=20=EB=95=85?= =?UTF-8?q?=EB=94=B0=EB=A8=B9=EA=B8=B0=20=EA=B7=9C=EC=B9=99=C2=B7=ED=99=94?= =?UTF-8?q?=EB=A9=B4=C2=B7=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/games/src/marble: 판 데이터(board.ts, 40칸·가격표·행운카드 20장), 상태 기계(rules.ts), 게임 정의(index.ts). 더블·3더블 무인도, 무인도/탈출권/탈출비, 우주여행·탑승료, 사회복지기금, 통행료(관광지 2곳 이상 2배·면제권), 빚 갚기·파산(채권자에게 매각 대금), 인수·랜드마크·독점 승리·교환 옵션, 라운드 제한과 총자산 순위(동점이면 포인트 비교). 카드 더미 순서는 비공개(남은 장 수만), 시스템 전용 TIMEOUT(굴리기/안 사기/싼 것부터 매각/거절), onLeave = 기권. - 테스트 80개: 문서 §10 체크리스트 전 항목, 1,000판 시드 무작위 대국(포인트 장부·소유 일관성· onTimeout 합법), 엔진 randomPlayout·리플레이 결정성, 시간 초과만으로도 끝나는지. - 화면: SVG 정사각형 판(도시 이름·소유 색·건물 표시·말), 가운데 주사위·결정 프롬프트, 행운카드 공개, 우주여행 칸 고르기와 미리 보기, 빚 갚기 막대·매각 목록, 교환 시트, 진행 기록, PC·휴대폰·다크 모드. 화폐 대신 놀이용 포인트(P) 표기, no-money-wording 검사 대상에 추가. - 등록: GAMES, CATALOG(주사위 땅따먹기), 웹 registry, GAME_ICON, @bg/games/marble/board export. - E2E marble 계획: PC 방장 + 휴대폰 손님, 15라운드·200P로 결과 화면까지 실제 진행. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/games/marble/index.tsx | 1393 ++++++++++++++++++++++ apps/web/src/games/registry.ts | 1 + apps/web/src/lib/catalog-ui.ts | 1 + apps/web/src/no-money-wording.test.ts | 2 +- e2e/games.e2e.ts | 98 ++ packages/games/package.json | 1 + packages/games/src/index.ts | 2 + packages/games/src/marble/board.ts | 182 +++ packages/games/src/marble/index.ts | 559 +++++++++ packages/games/src/marble/marble.test.ts | 1131 ++++++++++++++++++ packages/games/src/marble/rules.ts | 945 +++++++++++++++ packages/shared/src/catalog.ts | 1 + 12 files changed, 4315 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/games/marble/index.tsx create mode 100644 packages/games/src/marble/board.ts create mode 100644 packages/games/src/marble/index.ts create mode 100644 packages/games/src/marble/marble.test.ts create mode 100644 packages/games/src/marble/rules.ts diff --git a/apps/web/src/games/marble/index.tsx b/apps/web/src/games/marble/index.tsx new file mode 100644 index 0000000..67e304b --- /dev/null +++ b/apps/web/src/games/marble/index.tsx @@ -0,0 +1,1393 @@ +/** 주사위 땅따먹기 화면 (docs/games/marble.md §9). Board, dice, tokens and buildings are drawn here (SVG/CSS only). */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { MarbleOptions, MarbleOwnedView, MarblePlayerView, MarbleView } from '@bg/games/marble'; +import { CARDS, SPACES, type CardId, type Level } from '@bg/games/marble/board'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +type GameEvent = MarbleView['log'][number]; + +// ---------------------------------------------------------------- look & words + +const SEAT_COLORS = ['#dc2626', '#2563eb', '#16a34a', '#d97706']; +const SEAT_NAMES = ['빨강', '파랑', '초록', '주황']; +const SHAPES = ['circle', 'triangle', 'square', 'star'] as const; +const GROUP_COLORS: Record = { + A: '#8d5a2b', + B: '#38bdf8', + C: '#db2777', + D: '#f97316', + E: '#e11d48', + F: '#eab308', + G: '#16a34a', + H: '#1e40af', +}; +const LEVEL_KO = ['땅', '별장', '빌딩', '호텔', '랜드마크']; +const P = (n: number) => `${n}P`; +const SALARY_WORD = '월급'; + +/** Short names for the phone board (2 lines of 2 letters at most). */ +const SHORT: Record = { + 0: '출발', + 10: '무인도', + 20: '기금받기', + 21: '부에노스', + 25: '우주센터', + 30: '우주여행', + 34: '샌프란', + 38: '기금내기', +}; +const FULL_LINES: Record = { + 20: ['사회복지기금', '받는 곳'], + 21: ['부에노스', '아이레스'], + 34: ['샌프란', '시스코'], + 38: ['기금', '내는 곳'], +}; +const CORNER_ICON: Record = { 0: '🏁', 10: '🏝️', 20: '💝', 30: '🚀' }; + +const hasBatchim = (w: string) => { + const c = w.charCodeAt(w.length - 1); + return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; +}; +const eul = (w: string) => `${w}${hasBatchim(w) ? '을' : '를'}`; +const ro = (w: string) => { + const c = w.charCodeAt(w.length - 1); + const jong = c >= 0xac00 && c <= 0xd7a3 ? (c - 0xac00) % 28 : 0; + return `${w}${jong === 0 || jong === 8 ? '로' : '으로'}`; +}; + +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 reducedMotion = () => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +const spaceName = (pos: number) => SPACES[pos]!.name; + +function useCompact(): boolean { + const q = '(max-width: 640px)'; + const [c, setC] = useState(() => typeof window !== 'undefined' && !!window.matchMedia?.(q).matches); + useEffect(() => { + const m = window.matchMedia?.(q); + if (!m) return; + const on = () => setC(m.matches); + m.addEventListener('change', on); + return () => m.removeEventListener('change', on); + }, []); + return c; +} + +const CSS = ` +.mb-wrap { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 10px; } +.mb-box { position: relative; width: 100%; max-width: min(100%, calc(100vh - 64px - 52px - 36px)); aspect-ratio: 1 / 1; } +@media (max-width: 1099px) { .mb-box { max-width: min(100%, 760px); } } +.mb-svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; border-radius: 16px; box-shadow: var(--shadow); background: var(--surface-2); touch-action: manipulation; user-select: none; } +.mb-cell { cursor: pointer; outline: none; } +.mb-cell .mb-focus { opacity: 0; } +.mb-cell:focus-visible .mb-focus { opacity: 1; } +.mb-cell.pick .mb-pick { animation: mb-pulse 1.1s ease-in-out infinite; } +.mb-trail { animation: mb-flash .5s ease-out both; } +@keyframes mb-pulse { 0%, 100% { opacity: .25; } 50% { opacity: .9; } } +@keyframes mb-flash { from { opacity: .85; } to { opacity: 0; } } +@keyframes mb-hop { 0% { transform: translateY(0); } 50% { transform: translateY(-9px); } 100% { transform: translateY(0); } } +.mb-hop { transform-box: fill-box; transform-origin: center bottom; animation: mb-hop .14s ease-out; } +.mb-center { position: absolute; left: 13.6%; top: 13.6%; width: 72.8%; height: 72.8%; display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 10px; padding: 12px; text-align: center; overflow: auto; } +.mb-title { font-weight: 900; font-size: 1.5rem; letter-spacing: -.02em; color: var(--text); opacity: .9; } +.mb-meta { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } +.mb-pill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; background: var(--surface); border: 1px solid var(--border); font-weight: 700; font-size: .9rem; white-space: nowrap; } +.mb-dice { display: flex; gap: 10px; align-items: center; justify-content: center; min-height: 64px; } +.mb-die { width: 60px; height: 60px; border-radius: 13px; background: #fffdf8; border: 3px solid #c9c4b8; box-shadow: 0 3px 0 #b7b1a3, 0 4px 10px rgba(0,0,0,.15); flex: none; } +.mb-die svg { width: 100%; height: 100%; display: block; } +.mb-die.blank { background: var(--surface); border-style: dashed; box-shadow: none; } +.mb-die.rolling { animation: mb-tumble .6s ease-out both; } +@keyframes mb-tumble { 0% { transform: translateY(-16px) rotate(-40deg) scale(.9); } 50% { transform: translateY(4px) rotate(200deg); } 100% { transform: translateY(0) rotate(360deg); } } +.mb-double { font-weight: 900; color: var(--warning); } +.mb-prompt { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 8px; align-items: stretch; background: var(--surface); border-radius: 14px; padding: 12px; box-shadow: var(--shadow); border: 2px solid var(--border); } +.mb-prompt.mine { border-color: var(--primary); } +.mb-prompt h3 { margin: 0; font-size: 1.12rem; line-height: 1.35; } +.mb-prompt p { margin: 0; } +.mb-row { display: flex; gap: 8px; } +.mb-row > .btn { flex: 1; min-width: 0; } +.mb-roll { min-height: 60px; font-size: 1.2rem; } +.mb-warn { color: var(--warning); font-weight: 700; } +.mb-bar { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; } +.mb-bar > span { display: block; height: 100%; background: var(--danger); transition: width .3s; } +.mb-card { width: min(100%, 320px); border-radius: 16px; padding: 12px 14px; text-align: center; font: inherit; + background: linear-gradient(160deg, #fef3c7, #fde68a); color: #3b2a05; border: 3px solid #b45309; box-shadow: 0 10px 30px rgba(0,0,0,.35); cursor: pointer; animation: mb-card .35s ease-out both; } +.mb-card b { display: block; font-size: 1.3rem; margin: 4px 0; } +@keyframes mb-card { from { transform: rotateY(90deg) scale(.7); opacity: 0; } to { transform: rotateY(0) scale(1); opacity: 1; } } +.mb-pop { position: absolute; right: 8px; top: -6px; font-weight: 900; font-size: .95rem; animation: mb-rise 1.6s ease-out both; pointer-events: none; } +.mb-pop.plus { color: var(--success); } +.mb-pop.minus { color: var(--danger); } +@keyframes mb-rise { from { transform: translateY(6px); opacity: 0; } 15% { opacity: 1; } to { transform: translateY(-14px); opacity: 0; } } +.mb-pcard { position: relative; padding: 10px 12px; border: 3px solid transparent; } +.mb-pcard.turn { border-color: var(--primary); } +.mb-pcard.out { opacity: .55; } +.mb-pcard .mb-num { font-weight: 800; font-size: 1.2rem; font-variant-numeric: tabular-nums; } +.mb-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .76rem; color: #fff; background: var(--text-sub); white-space: nowrap; } +.mb-lands { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.mb-lands li { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.mb-dot { width: 12px; height: 12px; border-radius: 3px; flex: none; display: inline-block; } +.mb-log { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow: auto; font-size: .9rem; } +.mb-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } +.mb-table th, .mb-table td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: center; } +.mb-check { display: flex; align-items: center; gap: 8px; min-height: 40px; } +.mb-check input { width: 22px; height: 22px; } +@media (max-width: 640px) { + .mb-center { gap: 6px; padding: 6px; } + .mb-title { display: none; } + .mb-card { padding: 6px 8px; } + .mb-card b { font-size: 1rem; margin: 0; } + .mb-pill { font-size: .78rem; padding: 1px 7px; } + .mb-dice { min-height: 40px; gap: 6px; } + .mb-die { width: 38px; height: 38px; border-radius: 9px; border-width: 2px; } + .mb-prompt { padding: 8px; gap: 6px; border-radius: 10px; } + .mb-prompt h3 { font-size: .95rem; } + .mb-prompt p { font-size: .82rem; } + .mb-prompt .btn { min-height: 44px; padding: 6px 8px; font-size: .95rem; } + .mb-roll { min-height: 48px; font-size: 1rem; } +} +@media (prefers-reduced-motion: reduce) { + .mb-cell.pick .mb-pick, .mb-trail, .mb-hop, .mb-die.rolling, .mb-card, .mb-pop { animation: none; } +} +`; + +// ---------------------------------------------------------------- geometry (viewBox 0..1000) + +const C = 136; +const W = (1000 - 2 * C) / 9; +type Side = 'bottom' | 'left' | 'top' | 'right' | 'corner'; +interface Cell { + x: number; + y: number; + w: number; + h: number; + side: Side; +} +const CELLS: Cell[] = Array.from({ length: 40 }, (_, pos): Cell => { + if (pos === 0) return { x: 1000 - C, y: 1000 - C, w: C, h: C, side: 'corner' }; + if (pos === 10) return { x: 0, y: 1000 - C, w: C, h: C, side: 'corner' }; + if (pos === 20) return { x: 0, y: 0, w: C, h: C, side: 'corner' }; + if (pos === 30) return { x: 1000 - C, y: 0, w: C, h: C, side: 'corner' }; + if (pos < 10) return { x: 1000 - C - pos * W, y: 1000 - C, w: W, h: C, side: 'bottom' }; + if (pos < 20) return { x: 0, y: 1000 - C - (pos - 10) * W, w: C, h: W, side: 'left' }; + if (pos < 30) return { x: C + (pos - 21) * W, y: 0, w: W, h: C, side: 'top' }; + return { x: 1000 - C, y: C + (pos - 31) * W, w: C, h: W, side: 'right' }; +}); +const center = (pos: number): [number, number] => { + const c = CELLS[pos]!; + return [c.x + c.w / 2, c.y + c.h / 2]; +}; + +/** Areas inside a cell: the group band (inner edge), the text block and the token strip (outer edge). */ +function areas(pos: number, band: number, compact = false) { + const c = CELLS[pos]!; + // Phone: tokens sit in the middle of the square, so the text may use the whole cell. + const tok = compact && c.side !== 'corner' ? 0 : 40; + switch (c.side) { + case 'bottom': + return { band: { x: c.x, y: c.y, w: c.w, h: band }, text: { x: c.x, y: c.y + band, w: c.w, h: c.h - band - tok }, tok: { x: c.x, y: c.y + c.h - tok, w: c.w, h: tok } }; + case 'top': + return { band: { x: c.x, y: c.y + c.h - band, w: c.w, h: band }, text: { x: c.x, y: c.y + tok, w: c.w, h: c.h - band - tok }, tok: { x: c.x, y: c.y, w: c.w, h: tok } }; + case 'left': + return { band: { x: c.x + c.w - band, y: c.y, w: band, h: c.h }, text: { x: c.x + tok, y: c.y, w: c.w - band - tok, h: c.h }, tok: { x: c.x, y: c.y, w: tok, h: c.h } }; + case 'right': + return { band: { x: c.x, y: c.y, w: band, h: c.h }, text: { x: c.x + band, y: c.y, w: c.w - band - tok, h: c.h }, tok: { x: c.x + c.w - tok, y: c.y, w: tok, h: c.h } }; + default: + return { band: null, text: { x: c.x, y: c.y, w: c.w, h: c.h - tok }, tok: { x: c.x, y: c.y + c.h - tok, w: c.w, h: tok } }; + } +} + +/** Token spot `i` (0..3) of `n` on a square. */ +function tokenSpot(pos: number, i: number, n: number, compact: boolean): [number, number] { + if (compact) { + const [cx, cy] = center(pos); + const d = n === 1 ? 0 : 17; + const offs: [number, number][] = n <= 2 ? [[-d, 0], [d, 0]] : [[-d, -d], [d, -d], [-d, d], [d, d]]; + const [ox, oy] = offs[i] ?? [0, 0]; + return [cx + ox, cy + oy]; + } + const t = areas(pos, 22).tok; + const horizontal = t.w >= t.h; + const k = (i + 0.5) / Math.max(n, 1); + return horizontal ? [t.x + t.w * (0.1 + 0.8 * k), t.y + t.h / 2] : [t.x + t.w / 2, t.y + t.h * (0.1 + 0.8 * k)]; +} + +// ---------------------------------------------------------------- small drawings + +function Token({ seat, x, y, r }: { seat: number; x: number; y: number; r: number }) { + const color = SEAT_COLORS[seat] ?? '#888'; + const shape = SHAPES[seat % 4]!; + const common = { fill: color, stroke: '#fff', strokeWidth: r * 0.2, 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.15 : r * 0.52; + return `${(Math.cos(a) * rr).toFixed(1)},${(Math.sin(a) * rr).toFixed(1)}`; + }); + body = ; + } + return ( + + + {body} + + ); +} + +function TokenIcon({ seat, size = 22 }: { seat: number; size?: number }) { + return ( + + + + ); +} + +/** Building stage icon (white on the group band). */ +function BuildingIcon({ level, x, y, s }: { level: Level; x: number; y: number; s: number }) { + const f = { fill: '#fff', stroke: '#1c1e21', strokeWidth: s * 0.08, strokeLinejoin: 'round' as const }; + const h = s / 2; + return ( + + {level === 1 && } + {level === 2 && } + {level === 3 && ( + <> + + + + )} + {level === 4 && } + + ); +} + +const PIPS: Record = { + 1: [[50, 50]], + 2: [[28, 28], [72, 72]], + 3: [[26, 26], [50, 50], [74, 74]], + 4: [[28, 28], [72, 28], [28, 72], [72, 72]], + 5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]], + 6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]], +}; +function DieFace({ value }: { value: number | null }) { + return ( + + {value !== null && PIPS[value]!.map(([x, y], i) => )} + + ); +} + +// ---------------------------------------------------------------- board + +interface Anim { + seq: number; + player: string; + path: number[]; + i: number; +} + +function cellLabel(pos: number, v: MarbleView, players: Record, me: string | null): string { + const sp = SPACES[pos]!; + const parts = [`${pos}번 칸 ${sp.name}`]; + const o = v.owned.find((x) => x.pos === pos); + if (sp.kind === 'city' || sp.kind === 'tour') { + if (o) parts.push(`${o.owner === me ? '내' : `${nick(players, o.owner)}님`} 땅${sp.kind === 'city' ? `, ${LEVEL_KO[o.level]}` : ''}, 통행료 ${P(o.toll)}`); + else parts.push(`주인 없음, 땅값 ${P(sp.price!)}`); + } + const here = v.players.filter((p) => p.alive && p.pos === pos).map((p) => (p.id === me ? '나' : nick(players, p.id))); + if (here.length) parts.push(`말: ${here.join(', ')}`); + return parts.join('. '); +} + +function MarbleBoard({ view: v, me, players, act, pending }: BoardProps) { + const compact = useCompact(); + const my = v.my; + const [focus, setFocus] = useState(0); + const [info, setInfo] = useState(null); + const [anim, setAnim] = useState(null); + const [rolling, setRolling] = useState(false); + const [card, setCard] = useState<{ id: CardId; player: string; seq: number } | null>(null); + const [announce, setAnnounce] = useState(''); + const seen = useRef({ roll: v.lastRoll?.seq ?? 0, move: v.lastMove?.seq ?? 0, card: v.lastCard?.seq ?? 0, current: v.current }); + const cellRefs = useRef<(SVGGElement | null)[]>([]); + const spaceMode = !!my?.space; + const who = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`); + + // Dice roll: tumble + screen-reader text. + useEffect(() => { + const r = v.lastRoll; + if (!r || r.seq === seen.current.roll) return; + seen.current.roll = r.seq; + setCard(null); // a new roll replaces an old card on the center + setAnnounce(`${who(r.player)} ${r.dice[0]}, ${r.dice[1]}을 굴렸어요${r.double ? '. 더블!' : ''}`); + sounds.stone(); + if (reducedMotion()) return; + setRolling(true); + const t = setTimeout(() => setRolling(false), 600); + return () => clearTimeout(t); + }, [v.lastRoll?.seq]); // eslint-disable-line react-hooks/exhaustive-deps + + // Token walks along the path (forward / back); jumps for warps and space travel. + useEffect(() => { + const m = v.lastMove; + if (!m || m.seq === seen.current.move) return; + seen.current.move = m.seq; + if (reducedMotion() || m.kind === 'warp' || m.kind === 'space') return; + const path: number[] = []; + if (m.kind === 'back') for (let p = m.from; p !== m.to; ) path.push((p = (p + 39) % 40)); + else for (let p = m.from; p !== m.to; ) path.push((p = (p + 1) % 40)); + if (path.length) setAnim({ seq: m.seq, player: m.player, path: [m.from, ...path], i: 0 }); + }, [v.lastMove?.seq]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!anim) return; + if (anim.i >= anim.path.length - 1) { + const t = setTimeout(() => setAnim(null), 100); + return () => clearTimeout(t); + } + const step = Math.max(45, Math.min(130, 1100 / anim.path.length)); + const t = setTimeout(() => setAnim((a) => (a && a.seq === anim.seq ? { ...a, i: a.i + 1 } : a)), step); + return () => clearTimeout(t); + }, [anim]); + + // Card reveal. + useEffect(() => { + const c = v.lastCard; + if (!c || c.seq === seen.current.card) return; + seen.current.card = c.seq; + setCard({ id: c.id, player: c.player, seq: c.seq }); + setAnnounce(`${who(c.player)} 행운카드를 뽑았어요: ${CARDS[c.id].name}. ${CARDS[c.id].text}`); + const t = setTimeout(() => setCard((x) => (x && x.seq === c.seq ? null : x)), 3200); + return () => clearTimeout(t); + }, [v.lastCard?.seq]); // eslint-disable-line react-hooks/exhaustive-deps + + // My-turn cue. + useEffect(() => { + if (v.current !== seen.current.current && v.current === me && v.phase !== 'finished') { + haptic(40); + sounds.myTurn(); + } + seen.current.current = v.current; + }, [v.current]); // eslint-disable-line react-hooks/exhaustive-deps + + const moveCell = (to: number) => { + const n = (to + 40) % 40; + setFocus(n); + cellRefs.current[n]?.focus(); + }; + const onCellKey = (pos: number) => (e: React.KeyboardEvent) => { + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + moveCell(pos + 1); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + moveCell(pos - 1); + } else if (e.key === 'Home') { + e.preventDefault(); + moveCell(0); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); // keep the chat's global Enter shortcut away + setInfo(pos); + } + }; + + const ownedAt = useMemo(() => { + const m = new Map(); + for (const o of v.owned) m.set(o.pos, o); + return m; + }, [v.owned]); + const seatOf = (id: string) => v.players.find((p) => p.id === id)?.seat ?? 0; + const trail = anim ? new Set(anim.path.slice(1, anim.i + 1)) : null; + const band = compact ? 28 : 22; + + // Tokens: the moving one is drawn at its animation spot. + const tokens: { p: MarblePlayerView; pos: number; idx: number; n: number }[] = []; + const alive = v.players.filter((p) => p.alive); + for (const p of alive) { + const pos = anim && anim.player === p.id ? anim.path[anim.i]! : p.pos; + tokens.push({ p, pos, idx: 0, n: 0 }); + } + for (const t of tokens) { + const same = tokens.filter((x) => x.pos === t.pos); + t.n = same.length; + t.idx = same.indexOf(t); + } + + const decisionPos = v.phase === 'decide' ? v.decision?.pos : undefined; + + return ( +
+ +
+ + {CELLS.map((c, pos) => { + const sp = SPACES[pos]!; + const o = ownedAt.get(pos); + const a = areas(pos, band, compact); + const ownerColor = o ? SEAT_COLORS[seatOf(o.owner)]! : null; + const pick = spaceMode && pos !== 30; + const lines = compact ? splitShort(pos) : FULL_LINES[pos] ?? (sp.name.length > 5 ? [sp.name.slice(0, 4), sp.name.slice(4)] : [sp.name]); + const fs = compact ? (c.side === 'corner' ? 30 : c.side === 'left' || c.side === 'right' ? 31 : 28) : c.side === 'corner' ? 19 : 17; + const tx = a.text.x + a.text.w / 2; + const showPrice = !compact && (sp.kind === 'city' || sp.kind === 'tour'); + const icon = CORNER_ICON[pos] ?? (sp.kind === 'card' ? '🍀' : sp.kind === 'tour' ? '🗺️' : null); + const iconSize = compact ? 30 : c.side === 'corner' ? 34 : 20; + const blockH = (icon ? iconSize + 4 : 0) + lines.length * fs * 1.12 + (showPrice ? 18 : 0); + let ty = a.text.y + (a.text.h - blockH) / 2; + return ( + { + cellRefs.current[pos] = el; + }} + className={`mb-cell${pick ? ' pick' : ''}`} + role="button" + tabIndex={pos === focus ? 0 : -1} + aria-label={cellLabel(pos, v, players, me) + (pick ? '. 우주여행으로 갈 수 있어요' : '')} + onClick={() => { + setFocus(pos); + setInfo(pos); + }} + onKeyDown={onCellKey(pos)} + onFocus={() => setFocus(pos)} + > + + {a.band && sp.group && } + {a.band && sp.kind === 'tour' && } + {ownerColor && } + {o && o.level > 0 && a.band && ( + + {Array.from({ length: o.level }, (_, k) => { + const horiz = a.band!.w >= a.band!.h; + const step = (horiz ? a.band!.w : a.band!.h) / (o.level + 1); + const x = horiz ? a.band!.x + step * (k + 1) : a.band!.x + a.band!.w / 2; + const y = horiz ? a.band!.y + a.band!.h / 2 : a.band!.y + step * (k + 1); + return ; + })} + + )} + {icon && ( + + {icon} + + )} + {lines.map((l, i) => { + ty += fs * 1.12; + const maxW = a.text.w - 8; + const est = l.length * fs * 0.98; + return ( + maxW ? { textLength: maxW, lengthAdjust: 'spacingAndGlyphs' } : {})}> + {l} + + ); + })} + {showPrice && ( + + {o ? `통행료 ${P(o.toll)}` : P(sp.price!)} + + )} + {trail?.has(pos) && } + {(pick || pos === decisionPos) && ( + + )} + + + ); + })} + {tokens.map(({ p, pos, idx, n }) => { + const [x, y] = tokenSpot(pos, idx, n, compact); + const moving = anim && anim.player === p.id; + return ( + + + + ); + })} + +
+
setCard(null)} /> +
+
+

+ {announce} +

+ {info !== null && ( + setInfo(null)} + onTravel={ + spaceMode && info !== 30 && !pending + ? () => { + act({ type: 'SPACE_TRAVEL', dest: info }); + setInfo(null); + } + : null + } + /> + )} +
+ ); +} + +function splitShort(pos: number): string[] { + const n = SHORT[pos] ?? SPACES[pos]!.name; + if (n.length <= 3) return [n]; + return [n.slice(0, 2), n.slice(2, 4)]; +} + +// ---------------------------------------------------------------- center (dice + prompt) + +function Center({ + view: v, + me, + players, + act, + pending, + rolling, + card, + onCardClose, +}: { + view: MarbleView; + me: string | null; + players: Record; + act: (a: unknown) => void; + pending: boolean; + rolling: boolean; + card: { id: CardId; player: string; seq: number } | null; + onCardClose: () => void; +}) { + const [flick, setFlick] = useState<[number, number] | null>(null); + useEffect(() => { + if (!rolling) { + setFlick(null); + return; + } + const t = setInterval(() => setFlick([1 + Math.floor(Math.random() * 6), 1 + Math.floor(Math.random() * 6)]), 70); + return () => clearInterval(t); + }, [rolling]); + const r = v.lastRoll; + const dice = flick ?? r?.dice ?? null; + const meP = me ? v.players.find((p) => p.id === me) : undefined; + return ( + <> +
+ 주사위 땅따먹기 +
+
+ {v.maxRounds > 0 ? `${v.round} / ${v.maxRounds} 라운드` : `${v.round} 라운드`} + + 💝 기금 {P(v.fund)} + + + 🍀 {v.deckCount}장 + +
+ {card ? ( + + ) : ( +
+ {[0, 1].map((i) => ( + + + + ))} + {r && !rolling && ( + + = {r.dice[0] + r.dice[1]} + {r.double &&
더블!
} +
+ )} +
+ )} + {v.phase !== 'finished' && } + {v.phase === 'finished' && v.result && ( +
+

{v.result.summary}

+
+ )} + {meP && !meP.alive && v.phase !== 'finished' &&

{meP.conceded ? '기권했어요.' : '파산했어요.'} 끝까지 구경할 수 있어요.

} + + ); +} + +function Prompt({ view: v, me, players, act, pending }: { view: MarbleView; me: string | null; players: Record; act: (a: unknown) => void; pending: boolean }) { + const my = v.my; + const meP = me ? v.players.find((p) => p.id === me) : undefined; + const cur = v.current ?? ''; + const cash = meP?.cash ?? 0; + const left = (cost: number) => { + const after = cash - cost; + return

사고 나면 남는 포인트 {P(after)}

; + }; + + if (my?.respond && v.trade) { + const t = v.trade; + return ( +
+

{nick(players, t.from)}님의 교환 제안

+ +
+ + +
+
+ ); + } + if (my?.sell && v.debt) { + const d = v.debt; + const pct = Math.min(100, (cash / d.amount) * 100); + const auto = my.autoSell ? my.sell.find((o) => o.pos === my.autoSell!.pos && o.what === my.autoSell!.what) : null; + return ( +
+

{P(d.short)}가 모자라요

+

+ {debtWhat(d.reason, d.to, players, me)} {P(d.amount)}를 내야 해요. 땅이나 건물을 팔아 채우면 자동으로 내요. +

+
+ +
+ {auto && ( + + )} +

‘내 땅’ 목록에서 직접 골라 팔 수도 있어요.

+
+ ); + } + if (my?.decide) { + const d = my.decide; + const name = spaceName(d.pos); + if (d.buy !== null) { + return ( +
+

{eul(name)} {P(d.buy)}에 살까요?

+ {left(d.buy)} +
+ + +
+
+ ); + } + if (d.build) { + return ( +
+

+ {name}에 {eul(LEVEL_KO[d.build.level]!)} {P(d.build.cost)}에 지을까요? +

+

지으면 통행료가 {P(SPACES[d.pos]!.tolls![d.build.level]!)}가 돼요.

+ {left(d.build.cost)} +
+ + +
+
+ ); + } + if (d.takeover !== null) { + return ( +
+

+ {eul(name)} {P(d.takeover)}에 인수할까요? +

+

건물째로 내 땅이 돼요. 주인은 거절할 수 없어요.

+ {left(d.takeover)} +
+ + +
+
+ ); + } + } + if (my?.space) { + return ( +
+

🚀 우주여행! 갈 칸을 판에서 골라 주세요

+

반짝이는 칸을 누르면 미리 보기가 나와요. 출발을 지나가면 {SALARY_WORD}을 받아요.

+ +
+ ); + } + if (my?.island) { + const isl = my.island; + return ( +
+

🏝️ 무인도예요 ({isl.left}번 남음)

+

더블이 나오면 바로 탈출해서 그만큼 가요.

+ + {(isl.hasCard || isl.fee !== null) && ( +
+ {isl.hasCard && ( + + )} + {isl.fee !== null && ( + + )} +
+ )} +
+ ); + } + if (my?.canRoll) { + return ( +
+ {v.extraRoll &&

더블! 한 번 더 굴려요{v.doublesThisTurn === 2 ? ' (또 더블이면 무인도)' : ''}

} + +
+ ); + } + // Somebody else is acting. + let text = `${nick(players, cur)}님 차례예요`; + if (v.phase === 'decide' && v.decision) text = `${nick(players, cur)}님이 ${spaceName(v.decision.pos)}에서 고민 중이에요`; + if (v.phase === 'debt' && v.debt) text = `${nick(players, v.debt.debtor)}님이 모자란 ${P(v.debt.short)}를 채우는 중이에요`; + if (v.phase === 'tradeResponse' && v.trade) text = `${nick(players, v.trade.to)}님이 교환 제안을 보는 중이에요`; + return ( +
+

{text}

+
+ ); +} + +function debtWhat(reason: string, to: { kind: string; id?: string }, players: Record, me: string | null): string { + const target = to.kind === 'player' ? (to.id === me ? '나' : `${nick(players, to.id!)}님`) : to.kind === 'fund' ? '사회복지기금' : '은행'; + if (reason === 'toll') return `${target}에게 통행료`; + if (reason === 'fare') return `${target}에게 우주여행 탑승료`; + if (reason === 'fund') return '사회복지기금에'; + return `${target}에게 행운카드로`; +} + +function TradeSummary({ trade: t, me, players }: { trade: NonNullable; me: string | null; players: Record }) { + const side = (lands: number[], pts: number) => [...lands.map(spaceName), ...(pts ? [P(pts)] : [])].join(', ') || '없음'; + return ( +

+ {t.from === me ? '내가' : `${nick(players, t.from)}님이`} 주는 것: {side(t.give.lands, t.give.cash)} +
+ {t.to === me ? '내가' : `${nick(players, t.to)}님이`} 주는 것: {side(t.take.lands, t.take.cash)} +

+ ); +} + +// ---------------------------------------------------------------- square info / space travel preview + +function SpaceSheet({ + pos, + view: v, + me, + players, + onClose, + onTravel, +}: { + pos: number; + view: MarbleView; + me: string | null; + players: Record; + onClose: () => void; + onTravel: (() => void) | null; +}) { + const sp = SPACES[pos]!; + const o = v.owned.find((x) => x.pos === pos); + const pre = v.spacePreview?.find((p) => p.dest === pos); + const KIND: Record = { + start: `지나가거나 도착하면 ${SALARY_WORD} ${P(v.options.salary)}를 받아요.`, + card: '행운카드를 한 장 뽑아 바로 따라요.', + island: '도착하면 무인도에 갇혀요. 더블이 나오거나 정해진 차례가 지나면 나가요.', + fundIn: '모인 사회복지기금을 모두 받아요.', + fundOut: '사회복지기금에 15P를 내요.', + space: '다음 차례에 판의 원하는 칸으로 날아가요.', + tour: '관광지예요. 건물은 못 짓고, 2곳 이상 가지면 통행료가 2배예요.', + city: '도시예요. 별장 → 빌딩 → 호텔 순서로 한 방문에 한 단계씩 지을 수 있어요.', + }; + return ( + +

+ {sp.group && } + {sp.name} ({pos}번 칸) +

+

{KIND[sp.kind]}

+ {(sp.kind === 'city' || sp.kind === 'tour') && ( +

+ {o ? ( + <> + 주인: {o.owner === me ? '나' : nick(players, o.owner)} + {sp.kind === 'city' && ` · ${LEVEL_KO[o.level]}`} · 지금 통행료 {P(o.toll)} + {o.takeoverPrice !== null && ` · 인수가 ${P(o.takeoverPrice)}`} + + ) : ( + <> + 주인 없음 · 땅값 {P(sp.price!)} + + )} +

+ )} + {sp.kind === 'city' && ( + + + + + + + + + + {LEVEL_KO.slice(0, v.options.landmark ? 5 : 4).map((name, l) => ( + + + + + + ))} + +
단계짓는 값통행료
{name}{P(l === 0 ? sp.price! : sp.build![l - 1]!)}{P(sp.tolls![l]!)}
+ )} + {sp.kind === 'tour' &&

통행료 {P(sp.tolls![0]!)} (관광지 2곳 이상이면 2배)

} + {pre && onTravel && ( +
+ 여기로 가면 + {pre.fare > 0 && 우주센터 주인에게 탑승료 {P(pre.fare)}} + {pre.salary ? `출발을 지나서 ${SALARY_WORD} +${P(v.options.salary)}` : `${SALARY_WORD} 없음`} + {pre.toll > 0 && 통행료 {P(pre.toll)}} + {pre.owner === me && 내 땅이라 건물을 지을 수 있어요} + {pre.owner === null && (sp.kind === 'city' || sp.kind === 'tour') && 주인 없는 땅이라 살 수 있어요} +
+ )} +
+ {onTravel && ( + + )} + +
+
+ ); +} + +// ---------------------------------------------------------------- side panel + +function PlayerCard({ p, v, me, players, deadline }: { p: MarblePlayerView; v: MarbleView; me: string | null; players: Record; deadline: number | null }) { + const active = v.phase !== 'finished' && ((v.phase === 'debt' && v.debt?.debtor === p.id) || (v.phase === 'tradeResponse' && v.trade?.to === p.id) || (v.phase !== 'debt' && v.phase !== 'tradeResponse' && v.current === p.id)); + const prev = useRef(p.cash); + const [delta, setDelta] = useState<{ n: number; k: number } | null>(null); + useEffect(() => { + const d = p.cash - prev.current; + prev.current = p.cash; + if (d === 0) return; + setDelta({ n: d, k: Date.now() }); + const t = setTimeout(() => setDelta(null), 1600); + return () => clearTimeout(t); + }, [p.cash]); + const status: string[] = []; + if (!p.alive) status.push(p.conceded ? '기권' : '파산'); + if (p.islandLeft > 0) status.push(`무인도 ${p.islandLeft}`); + if (p.spacePending) status.push('우주여행 대기'); + if (p.away) status.push('자리 비움'); + const totalMs = v.phase === 'tradeResponse' ? 20_000 : p.away ? 5000 : (v.options.turnSeconds ?? 0) * 1000; + return ( +
+
+ + + + {nick(players, p.id)} + {p.id === me && (나)} + + + {P(p.cash)} + + {active && } +
+
+ + 땅 {p.lands.length}곳 · 총자산 {P(p.totalAssets)} + + {status.map((x) => ( + + {x} + + ))} + {p.heldCards.map((c) => ( + + 🍀 {CARDS[c].name} + + ))} +
+ {delta && ( + 0 ? 'plus' : 'minus'}`} aria-hidden> + {delta.n > 0 ? `+${delta.n}` : `−${-delta.n}`} + + )} +
+ ); +} + +function MyLands({ v, me, act, pending }: { v: MarbleView; me: string; act: (a: unknown) => void; pending: boolean }) { + const mine = v.owned.filter((o) => o.owner === me); + const sell = v.my?.sell ?? null; + return ( +
+ + 내 땅 ({mine.length}곳){sell ? ' · 팔 것을 골라 주세요' : ''} + + {mine.length === 0 ? ( +

+ 아직 가진 땅이 없어요. +

+ ) : ( +
    + {mine.map((o) => { + const sp = SPACES[o.pos]!; + const top = sell?.find((x) => x.pos === o.pos && x.what === 'top'); + const all = sell?.find((x) => x.pos === o.pos && x.what === 'all'); + return ( +
  • + + + {sp.name} {sp.kind === 'city' ? LEVEL_KO[o.level] : '관광지'} · 통행료 {P(o.toll)} + + {top && ( + + )} + {all && ( + + )} +
  • + ); + })} +
+ )} +
+ ); +} + +function TradeSheet({ v, me, players, act, onClose }: { v: MarbleView; me: string; players: Record; act: (a: unknown) => void; onClose: () => void }) { + const others = v.players.filter((p) => p.alive && p.id !== me); + const [to, setTo] = useState(others[0]?.id ?? ''); + const [give, setGive] = useState([]); + const [take, setTake] = useState([]); + const [giveCash, setGiveCash] = useState(0); + const [takeCash, setTakeCash] = useState(0); + const mine = v.players.find((p) => p.id === me)!; + const them = v.players.find((p) => p.id === to); + const toggle = (list: number[], set: (x: number[]) => void, pos: number) => set(list.includes(pos) ? list.filter((x) => x !== pos) : [...list, pos]); + const empty = give.length + take.length + giveCash + takeCash === 0; + const landList = (lands: number[], sel: number[], set: (x: number[]) => void) => + lands.length === 0 ? ( + 땅이 없어요 + ) : ( + lands.map((pos) => ( + + )) + ); + return ( + +

교환 제안하기

+
+ {others.map((p) => ( + + ))} +
+
+
+ 내가 줄 것 + {landList(mine.lands, give, setGive)} + +
+
+ 받을 것 + {them && landList(them.lands, take, setTake)} + +
+
+

상대가 20초 안에 답하지 않으면 거절돼요. 건물이 있는 땅은 건물째로 넘어가요.

+
+ + +
+
+ ); +} + +function logLine(e: GameEvent, players: Record): string | null { + const who = (k = 'player') => (typeof e[k] === 'string' ? nick(players, e[k] as string) : ''); + const at = (k = 'pos') => spaceName(e[k] as number); + switch (e.type) { + case 'turn': + return `— ${who()}님 차례 (${e.round}라운드)`; + case 'rolled': { + const d = e.dice as [number, number]; + return `${who()}: 🎲 ${d[0]}+${d[1]}${e.double ? ' 더블!' : ''}${e.island ? ' (무인도)' : ''}`; + } + case 'moved': + return e.kind === 'roll' ? null : `${who()}: ${ro(at('to'))} 이동`; + case 'salary': + return `${who()}: ${SALARY_WORD} +${P(e.amount as number)}`; + case 'bought': + return `${who()}: ${eul(at())} ${P(e.price as number)}에 샀어요`; + case 'built': + return `${who()}: ${at()}에 ${eul(LEVEL_KO[e.level as number]!)} 지었어요`; + case 'tollPaid': + return e.waived ? `${who('from')}: 통행료 면제권을 썼어요 (${at()})` : `${who('from')} → ${who('to')}: 통행료 ${P(e.amount as number)}`; + case 'takeover': + return `${who()}: ${eul(at())} ${P(e.price as number)}에 인수했어요`; + case 'cardDrawn': + return `${who()}: 🍀 ${CARDS[e.cardId as CardId].name}`; + case 'cardGain': + return `${who()}: +${P(e.amount as number)}`; + case 'paid': { + const to = e.to as { kind: string; id?: string }; + return `${who('from')} → ${to.kind === 'player' ? nick(players, to.id!) : to.kind === 'fund' ? '기금' : '은행'}: ${P(e.amount as number)}`; + } + case 'fundPaid': + return `${who()}: 기금에 ${P(e.amount as number)}`; + case 'fundReceived': + return (e.amount as number) > 0 ? `${who()}: 기금 ${P(e.amount as number)}를 받았어요` : `${who()}: 모인 기금이 없어요`; + case 'toIsland': + return `${who()}: 무인도로! ${e.why === 'doubles' ? '(더블 3번)' : e.why === 'card' ? '(폭풍 경보)' : ''}`; + case 'islandStay': + return `${who()}: 무인도에 머물러요 (${e.left}번 남음)`; + case 'escaped': + return `${who()}: 무인도 탈출 ${e.how === 'double' ? '(더블)' : e.how === 'card' ? '(탈출권)' : `(탈출비 ${P(e.amount as number)})`}`; + case 'spaceWait': + return `${who()}: 다음 차례에 우주여행`; + case 'spaceTravel': + return `${who()}: 🚀 ${ro(at('dest'))} 우주여행`; + case 'debt': + return `${who()}: ${P(e.short as number)}가 모자라요`; + case 'sold': + return `${who()}: ${at()} ${e.what === 'top' ? '건물' : '땅'} 팔기 +${P(e.amount as number)}`; + case 'bankrupt': + return `${who()}님 파산`; + case 'conceded': + return `${who()}님 기권`; + case 'cannotAfford': + return `${who()}: 포인트가 모자라 ${e.what === 'buy' ? '못 샀어요' : '못 지었어요'} (${at()})`; + case 'tradeProposed': + return `${who('from')} → ${who('to')}: 교환 제안`; + case 'tradeResolved': + return `교환 ${e.accepted ? '성사!' : e.failed ? '실패(조건이 바뀜)' : e.cancelled ? '취소' : '거절'}`; + case 'roundEnd': + return `${e.round}라운드 끝`; + case 'monopoly': + return `${who()}: 독점 달성!`; + case 'timeout': + return `${who()}님 시간 초과 · 자동으로 했어요`; + case 'away': + return `${who()}님 자리 비움`; + case 'gameOver': + return String(e.summary ?? '게임이 끝났어요'); + default: + return null; + } +} + +function MarbleSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [confirm, setConfirm] = useState(false); + const [trade, setTrade] = useState(false); + const ref = useRef(null); + const lines = v.log.map((e) => logLine(e, players)).filter((x): x is string => !!x); + useEffect(() => { + ref.current?.scrollTo({ top: ref.current.scrollHeight }); + }, [v.turnSeq]); + const meP = me ? v.players.find((p) => p.id === me) : undefined; + return ( + <> +
+ {v.players.map((p) => ( + + ))} +
+ {me && meP?.alive && v.phase !== 'finished' && } + {v.my?.canPropose && ( + + )} + {lines.length > 0 && ( +
+ 진행 기록 +
    + {lines.slice(-40).map((l, i) => ( +
  1. {l}
  2. + ))} +
+
+ )} + {meP?.alive && v.phase !== 'finished' && ( + + )} + {trade && me && setTrade(false)} />} + {confirm && ( + act({ type: 'CONCEDE' })} + onClose={() => setConfirm(false)} + /> + )} + + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: MarbleView, me: string | null): boolean { + const m = v.my; + return !!m && (m.canRoll || !!m.island || m.space || !!m.decide || !!m.sell || m.respond); +} + +function statusText(v: MarbleView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + const meP = me ? v.players.find((p) => p.id === me) : undefined; + if (meP && !meP.alive) return meP.conceded ? '기권했어요. 구경하는 중이에요' : '파산했어요. 구경하는 중이에요'; + const m = v.my; + if (m) { + if (m.respond) return '교환 제안이 왔어요. 받을지 정해 주세요'; + if (m.sell && v.debt) return `포인트가 ${P(v.debt.short)} 모자라요. 땅이나 건물을 팔아 주세요`; + if (m.decide) { + if (m.decide.buy !== null) return `내 차례예요. ${eul(spaceName(m.decide.pos))} 살지 정해 주세요`; + if (m.decide.build) return `내 차례예요. ${eul(LEVEL_KO[m.decide.build.level]!)} 지을지 정해 주세요`; + return '내 차례예요. 인수할지 정해 주세요'; + } + if (m.space) return '내 차례예요. 우주여행으로 갈 칸을 골라 주세요'; + if (m.island) return '내 차례예요. 무인도에서 주사위를 굴려 주세요(더블이면 탈출)'; + if (m.canRoll) return v.extraRoll ? '내 차례예요. 더블! 한 번 더 굴려 주세요' : '내 차례예요. 주사위를 굴려 주세요'; + } + const round = v.maxRounds > 0 ? ` · ${v.round}/${v.maxRounds}라운드` : ` · ${v.round}라운드`; + if (v.phase === 'debt' && v.debt) return `${nick(players, v.debt.debtor)}님이 포인트를 채우는 중이에요${round}`; + if (v.phase === 'tradeResponse' && v.trade) return `${nick(players, v.trade.to)}님이 교환 제안을 보는 중이에요`; + return v.current ? `${nick(players, v.current)}님 차례예요${round}` : '잠시만 기다려 주세요'; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + 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 = (label: string, key: 'buildOnPurchase' | 'takeover' | 'landmark' | 'monopolyWin' | 'trade', on: string, off: string) => ( + + set({ [key]: k === 'on' } as Partial)} /> + + ); + return ( +
+ + set({ maxRounds })} /> + + + set({ startMoney })} /> + + + set({ turnSeconds: k === 'none' ? null : (Number(k) as 15 | 30 | 60) })} + /> + + + {more && ( + <> + + set({ salary })} /> + + + set({ islandTurns })} /> + + + set({ islandFee })} /> + + {onOff('산 자리에서 바로 별장 짓기', 'buildOnPurchase', '가능', '다음 방문부터')} + {onOff('인수(2배 값에 사들이기)', 'takeover', '켜기', '끄기')} + {onOff('랜드마크(호텔 다음 단계)', 'landmark', '켜기', '끄기')} + {onOff('독점 즉시 승리', 'monopolyWin', '켜기', '끄기')} + {onOff('땅·포인트 교환', 'trade', '켜기', '끄기')} + + )} +
+ ); +} + +function optionsSummary(o: MarbleOptions): string { + return [ + o.maxRounds > 0 ? `${o.maxRounds}라운드` : '라운드 무제한', + `시작 ${P(o.startMoney)}`, + `${SALARY_WORD} ${P(o.salary)}`, + ...(o.islandTurns !== 3 ? [`무인도 ${o.islandTurns}번`] : []), + ...(o.islandFee ? [`탈출비 ${P(o.islandFee)}`] : []), + ...(o.buildOnPurchase ? ['산 자리에서 별장'] : []), + ...(o.takeover ? ['인수'] : []), + ...(o.landmark ? ['랜드마크'] : []), + ...(o.monopolyWin ? ['독점 승리'] : []), + ...(o.trade ? ['교환'] : []), + o.turnSeconds === null ? '시간 무제한' : `${o.turnSeconds}초`, + ].join(' · '); +} + +// ---------------------------------------------------------------- rules + +function Rules({ options }: { options?: MarbleOptions }) { + const o = options; + return ( +
+

+ 주사위 2개를 굴려 세계 도시 40칸 판을 시계 방향으로 돌아요. 주인 없는 땅에 서면 살 수 있고, 내 땅에 다시 오면 별장 → 빌딩 → 호텔 + {o?.landmark ? ' → 랜드마크' : ''} 순서로 한 단계씩 지어요. 다른 사람이 내 땅에 서면 통행료를 받아요. +

+

+ 포인트(P)는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 저장하거나 바꿀 수 없어요. +

+

+ 출발을 지나거나 도착하면 {SALARY_WORD} {P(o?.salary ?? 20)}를 받아요. 더블(두 주사위가 같음)이면 한 번 더 굴리고, 한 차례에 더블이 3번 나오면 바로 + 무인도로 가요. +

+

+ 무인도: 최대 {o?.islandTurns ?? 3}번 쉬어요. 내 차례에 더블이 나오면 바로 나가요{o?.islandFee ? `. 탈출비 ${P(o.islandFee)}를 내고 나갈 수도 있어요` : ''}. 무인도에 + 있어도 통행료는 받아요. +

+

+ 우주여행: 도착하면 다음 차례에 판의 원하는 칸으로 날아가요. 우주센터 주인에게 탑승료 {P(20)}를 내요(내 것이거나 주인이 없으면 무료). +

+

+ 사회복지기금: ‘내는 곳’에서 {P(15)}를 내고, 행운카드 벌금도 여기에 모여요. ‘받는 곳’에 도착하면 모인 기금을 모두 받아요. +

+

+ 행운카드: 20장 중 맨 위 카드를 뽑아 바로 따라요. 무인도 탈출권과 통행료 면제권은 가지고 있다가 써요. +

+

+ 관광지(제주도·경주·우주센터·부산)에는 건물을 못 짓지만, 2곳 이상 가지면 통행료가 2배예요. +

+

+ 내야 할 포인트가 모자라면 땅이나 건물을 은행에 팔아(낸 값의 절반) 채워요. 다 팔아도 모자라면 파산해서 빠져요. +

+ {o?.takeover &&

인수: 남의 도시에서 통행료를 낸 뒤, 땅값과 건물값 합의 2배를 주고 사들일 수 있어요(랜드마크·관광지 제외).

} + {o?.monopolyWin &&

독점 승리: 도시 그룹 3개를 모두 갖거나(트리플), 한 줄의 도시 6곳을 모두 갖거나(라인), 관광지 4곳을 모두 가지면 바로 이겨요.

} + {o?.trade &&

교환: 주사위를 굴리기 전에 다른 사람에게 땅·포인트 교환을 제안할 수 있어요(한 차례 2번까지).

} +

+ {o?.maxRounds === 0 ? '한 사람만 남을 때까지 해요.' : `${o?.maxRounds ?? 20}라운드가 끝나면`} 총자산(포인트 + 땅값 + 건물값)이 가장 많은 사람이 이겨요. 그 전에 다른 사람이 모두 + 파산해도 이겨요. +

+

시간 안에 정하지 않으면 자동으로 굴리고, 사거나 짓지 않고 넘어가요. 모자란 포인트는 가장 싼 것부터 팔아 채워요.

+
+ ); +} + +const ui: GameUI = { + Board: MarbleBoard, + Side: MarbleSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 570d217..238fa91 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -14,6 +14,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { 'one-card': () => import('./one-card'), rummikub: () => import('./rummikub'), 'davinci-code': () => import('./davinci-code'), + marble: () => import('./marble'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 0124b59..8e72ff6 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -12,6 +12,7 @@ export const GAME_ICON: Record = { 'one-card': '♣', rummikub: '🔢', 'davinci-code': '🔐', + marble: '🏙️', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ diff --git a/apps/web/src/no-money-wording.test.ts b/apps/web/src/no-money-wording.test.ts index 7095d2d..ae36b91 100644 --- a/apps/web/src/no-money-wording.test.ts +++ b/apps/web/src/no-money-wording.test.ts @@ -6,7 +6,7 @@ import { expect, test } from 'bun:test'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; -const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'components/cards'].map((p) => join(import.meta.dir, p)); +const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'components/cards'].map((p) => join(import.meta.dir, p)); const FORBIDDEN: [RegExp, string][] = [ [/₩|\$\s?\d|\d\s?\$/, '통화 기호'], [/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'], diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 8a6fd27..7f70414 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -150,6 +150,50 @@ async function yutStep(p: Page): Promise { } } +/** + * 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip), + * pick a space-travel square on the board, escape the island or roll. Returns the label pressed. + */ +const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc'); +async function marbleStep(p: Page, stats: Record): Promise { + const prompt = p.locator('.mb-prompt.mine'); + if (!(await prompt.count())) return null; + const btn = (name: string | RegExp) => prompt.getByRole('button', { name, exact: typeof name === 'string' }); + const press = async (name: string | RegExp) => { + const b = btn(name); + if (!(await b.count()) || !(await b.first().isEnabled().catch(() => false))) return false; + return b.first().click({ timeout: 1500 }).then(() => true, () => false); + }; + // Space travel: tap a glowing square on the board, check the preview, fly there. + if (await btn(/^출발로 가기/).count()) { + const cell = p.locator('g.mb-cell.pick').nth(5 + (stats.space ?? 0) * 7); + await cell.click({ timeout: 1500 }).catch(() => {}); + const go = p.getByRole('button', { name: '🚀 여기로 가기' }); + if (await go.waitFor({ timeout: 1500 }).then(() => true, () => false)) { + await p.getByText('여기로 가면').waitFor({ timeout: 1000 }); + if (!stats.space) await p.screenshot({ path: join(SHOTS, `marble-space-${tag(p)}.png`) }); + await go.click(); + stats.space = (stats.space ?? 0) + 1; + return 'space'; + } + return (await press(/^출발로 가기/)) ? 'space-start' : null; + } + // First buy prompt on each screen size, for the visual check. + const shotKey = `shot-${tag(p)}`; + if (!stats[shotKey] && (await btn('사기').count())) { + stats[shotKey] = 1; + await p.screenshot({ path: join(SHOTS, `marble-buy-${tag(p)}.png`) }); + } + for (const name of ['거절', /^가장 싼 것부터 팔기/, '사기', '짓기', '인수하기', '🎲 탈출 굴리기', '🎲 주사위 굴리기', '🎲 한 번 더 굴리기', '안 사기', '안 짓기', '안 하기'] as const) { + if (await press(name)) { + const key = typeof name === 'string' ? name : 'sell'; + stats[key] = (stats[key] ?? 0) + 1; + return key; + } + } + return null; +} + const PLANS: Record = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -194,6 +238,7 @@ const PLANS: Record = { await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') }); await guest.emulateMedia({ colorScheme: 'light' }); } + if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1; if (!acted) await host.waitForTimeout(120); } if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`); @@ -201,6 +246,59 @@ const PLANS: Record = { console.log(` yut: finished after ${actions} UI actions`); }, }, + marble: { + // PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or + // build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots. + lobby: async (host) => { + await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click(); + await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/^15라운드 · 시작 200P/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + const stats: Record = {}; + let actions = 0; + let shot = false; + const until = Date.now() + 420_000; + const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + while (Date.now() < until && !(await over())) { + let acted = false; + for (const p of pages) { + if (await marbleStep(p, stats)) { + acted = true; + actions++; + await p.waitForTimeout(80); + break; + } + } + if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) { + shot = true; + await host.waitForTimeout(1300); + await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') }); + await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true }); + await host.emulateMedia({ colorScheme: 'dark' }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); // let the button colour transitions settle + await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') }); + await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') }); + await host.emulateMedia({ colorScheme: 'light' }); + await guest.emulateMedia({ colorScheme: 'light' }); + } + if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1; + if (!acted) await host.waitForTimeout(120); + } + if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`); + await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); + await host.waitForTimeout(600); // result dialog fade-in + await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') }); + const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0); + if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`); + console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); + }, + }, 'one-card': { // 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play, // pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends. diff --git a/packages/games/package.json b/packages/games/package.json index 26e5133..f27c818 100644 --- a/packages/games/package.json +++ b/packages/games/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./src/index.ts", "./rummikub/rules": "./src/rummikub/rules.ts", + "./marble/board": "./src/marble/board.ts", "./*": "./src/*/index.ts" }, "dependencies": { diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index a4d2a12..0fda668 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -11,6 +11,7 @@ import { yut } from './yut'; import { oneCard } from './one-card'; import { rummikub } from './rummikub'; import { davinci } from './davinci-code'; +import { marble } from './marble'; export const GAMES: Record = { [omok.id]: omok, @@ -25,4 +26,5 @@ export const GAMES: Record = { [oneCard.id]: oneCard, [rummikub.id]: rummikub, [davinci.id]: davinci, + [marble.id]: marble, }; diff --git a/packages/games/src/marble/board.ts b/packages/games/src/marble/board.ts new file mode 100644 index 0000000..eed135a --- /dev/null +++ b/packages/games/src/marble/board.ts @@ -0,0 +1,182 @@ +/** + * 주사위 땅따먹기 판 데이터 (docs/games/marble.md §3). Pure data + price helpers, no zod, + * so it stays cheap to import. Every number here is copied from the doc tables (tests compare + * them with the §3.2 formulas). + */ + +export type SpaceKind = 'start' | 'city' | 'tour' | 'card' | 'island' | 'fundIn' | 'fundOut' | 'space'; +export type Group = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H'; +/** 0 땅만, 1 별장, 2 빌딩, 3 호텔, 4 랜드마크 */ +export type Level = 0 | 1 | 2 | 3 | 4; + +export interface Space { + pos: number; + name: string; + kind: SpaceKind; + group: Group | null; + /** 땅값 (cities and tourist spots). */ + price: number | null; + /** 건설비 per stage: [별장, 빌딩, 호텔, 랜드마크] (cities only). */ + build: [number, number, number, number] | null; + /** 통행료 by level: [땅, 별장, 빌딩, 호텔, 랜드마크] (cities); tourist spots have one value. */ + tolls: number[] | null; +} + +type CityRow = [pos: number, name: string, group: Group, price: number, build: [number, number, number, number], tolls: [number, number, number, number, number]]; + +/** §3.2 */ +const CITIES: CityRow[] = [ + [1, '타이베이', 'A', 6, [3, 6, 9, 12], [1, 4, 9, 15, 24]], + [3, '마닐라', 'A', 6, [3, 6, 9, 12], [1, 4, 9, 15, 24]], + [4, '하노이', 'A', 8, [4, 8, 12, 16], [2, 5, 12, 20, 32]], + [6, '방콕', 'B', 10, [5, 10, 15, 20], [2, 6, 15, 25, 40]], + [8, '싱가포르', 'B', 10, [5, 10, 15, 20], [2, 6, 15, 25, 40]], + [9, '카이로', 'B', 12, [6, 12, 18, 24], [2, 7, 18, 30, 48]], + [11, '이스탄불', 'C', 14, [7, 14, 21, 28], [3, 8, 21, 35, 56]], + [13, '아테네', 'C', 14, [7, 14, 21, 28], [3, 8, 21, 35, 56]], + [14, '로마', 'C', 16, [8, 16, 24, 32], [3, 10, 24, 40, 64]], + [16, '코펜하겐', 'D', 18, [9, 18, 27, 36], [4, 11, 27, 45, 72]], + [18, '스톡홀름', 'D', 18, [9, 18, 27, 36], [4, 11, 27, 45, 72]], + [19, '베를린', 'D', 20, [10, 20, 30, 40], [4, 12, 30, 50, 80]], + [21, '부에노스아이레스', 'E', 22, [11, 22, 33, 44], [4, 13, 33, 55, 88]], + [23, '상파울루', 'E', 22, [11, 22, 33, 44], [4, 13, 33, 55, 88]], + [24, '시드니', 'E', 24, [12, 24, 36, 48], [5, 14, 36, 60, 96]], + [26, '리스본', 'F', 26, [13, 26, 39, 52], [5, 16, 39, 65, 104]], + [27, '마드리드', 'F', 26, [13, 26, 39, 52], [5, 16, 39, 65, 104]], + [29, '파리', 'F', 28, [14, 28, 42, 56], [6, 17, 42, 70, 112]], + [31, '런던', 'G', 30, [15, 30, 45, 60], [6, 18, 45, 75, 120]], + [32, '밴쿠버', 'G', 30, [15, 30, 45, 60], [6, 18, 45, 75, 120]], + [34, '샌프란시스코', 'G', 32, [16, 32, 48, 64], [6, 19, 48, 80, 128]], + [36, '뉴욕', 'H', 35, [18, 35, 53, 70], [7, 21, 53, 88, 140]], + [37, '도쿄', 'H', 35, [18, 35, 53, 70], [7, 21, 53, 88, 140]], + [39, '서울', 'H', 40, [20, 40, 60, 80], [8, 24, 60, 100, 160]], +]; + +/** §3.3 */ +const TOURS: [pos: number, name: string, price: number, toll: number][] = [ + [5, '제주도', 20, 10], + [15, '경주', 20, 10], + [25, '우주센터', 30, 15], + [35, '부산', 25, 12], +]; + +const OTHERS: [pos: number, name: string, kind: SpaceKind][] = [ + [0, '출발', 'start'], + [10, '무인도', 'island'], + [20, '사회복지기금 받는 곳', 'fundIn'], + [30, '우주여행', 'space'], + [38, '사회복지기금 내는 곳', 'fundOut'], + ...[2, 7, 12, 17, 22, 28, 33].map((p): [number, string, SpaceKind] => [p, '행운카드', 'card']), +]; + +export const SPACES: readonly Space[] = (() => { + const out: Space[] = []; + for (const [pos, name, group, price, build, tolls] of CITIES) out[pos] = { pos, name, kind: 'city', group, price, build, tolls }; + for (const [pos, name, price, toll] of TOURS) out[pos] = { pos, name, kind: 'tour', group: null, price, build: null, tolls: [toll] }; + for (const [pos, name, kind] of OTHERS) out[pos] = { pos, name, kind, group: null, price: null, build: null, tolls: null }; + if (out.length !== 40 || out.some((s) => !s)) throw new Error('marble: board must have 40 spaces'); + return out; +})(); + +export const BOARD_SIZE = 40; +export const START = 0; +export const ISLAND = 10; +export const FUND_IN = 20; +export const SPACE_TRAVEL = 30; +export const SPACE_CENTER = 25; +export const SEOUL = 39; +export const TOUR_POSITIONS = [5, 15, 25, 35]; +export const CITY_POSITIONS = CITIES.map((c) => c[0]); +export const PROPERTY_POSITIONS = [...CITY_POSITIONS, ...TOUR_POSITIONS].sort((a, b) => a - b); +/** §3.1 라인(독점 판정용): 각 줄의 도시 6곳. */ +export const LINES: number[][] = [0, 1, 2, 3].map((k) => CITY_POSITIONS.filter((p) => p > k * 10 && p < k * 10 + 10)); +export const GROUPS: Record = (() => { + const g = {} as Record; + for (const [pos, , group] of CITIES) (g[group] ??= []).push(pos); + return g; +})(); + +/** §3.4 고정 금액 */ +export const FUND_PAYMENT = 15; +export const SPACE_FARE = 20; + +// ---------------------------------------------------------------- 행운카드 (§3.5) + +export type CardId = + | 'L01' | 'L02' | 'L03' | 'L04' | 'L05' | 'L06' | 'L07' | 'L08' | 'L09' | 'L10' + | 'L11' | 'L12' | 'L13' | 'L14' | 'L15' | 'L16' | 'L17' | 'L18' | 'L19' | 'L20'; + +export const CARD_IDS: CardId[] = Array.from({ length: 20 }, (_, i) => `L${String(i + 1).padStart(2, '0')}` as CardId); +export const KEEP_CARDS: CardId[] = ['L11', 'L12']; + +export const CARDS: Record = { + L01: { name: '복권 당첨', text: '은행에서 20 받기' }, + L02: { name: '장학금', text: '은행에서 10 받기' }, + L03: { name: '주식 배당', text: '은행에서 15 받기' }, + L04: { name: '생일 축하', text: '다른 사람 모두에게서 5씩 받기' }, + L05: { name: '예금 이자', text: '은행에서 10 받기' }, + L06: { name: '과속 벌금', text: '사회복지기금에 5 내기' }, + L07: { name: '병원비', text: '은행에 10 내기' }, + L08: { name: '이웃 돕기 성금', text: '사회복지기금에 10 내기' }, + L09: { name: '건물 수리비', text: '내 건물마다 별장 3, 빌딩 6, 호텔 10, 랜드마크 15를 은행에 내기' }, + L10: { name: '폭풍 경보', text: '무인도로 바로 가기(월급 없음)' }, + L11: { name: '무인도 탈출권', text: '가지고 있다가 무인도에서 쓰면 바로 탈출' }, + L12: { name: '통행료 면제권', text: '다음에 통행료를 낼 때 한 번 면제' }, + L13: { name: '출발지로', text: '출발 칸으로 가서 월급 받기' }, + L14: { name: '우주 초대장', text: '우주여행 칸으로 가기' }, + L15: { name: '서울 관광', text: '서울로 가기' }, + L16: { name: '뒤로 3칸', text: '뒤로 3칸 가기(월급 없음)' }, + L17: { name: '기부 천사', text: '사회복지기금 받는 곳으로 가서 모인 기금 받기' }, + L18: { name: '관광 안내', text: '앞쪽의 가장 가까운 관광지로 가기' }, + L19: { name: '여행 경비', text: '다른 사람 모두에게 5씩 주기' }, + L20: { name: '재산세', text: '내 땅 한 곳마다 2를 은행에 내기' }, +}; + +/** L09 건물 수리비 per stage (별장, 빌딩, 호텔, 랜드마크). */ +export const REPAIR_PER_STAGE = [3, 6, 10, 15]; +export const PROPERTY_TAX = 2; +export const BIRTHDAY_GIFT = 5; +export const TRAVEL_GIFT = 5; +/** §3.5: from the 5th card in one turn on, card spaces do nothing. */ +export const CARD_CAP = 5; + +// ---------------------------------------------------------------- price helpers + +export const isProperty = (pos: number) => SPACES[pos]!.kind === 'city' || SPACES[pos]!.kind === 'tour'; +export const isCity = (pos: number) => SPACES[pos]!.kind === 'city'; + +/** Amount paid for one stage: level 0 = 땅값, 1.. = 건설비. */ +export function stageCost(pos: number, level: Level): number { + const s = SPACES[pos]!; + if (level === 0) return s.price!; + return s.build![level - 1]!; +} + +/** 땅값 + 지어진 건물 건설비 합 (정가). */ +export function investment(pos: number, level: Level): number { + let t = 0; + for (let l = 0; l <= level; l++) t += stageCost(pos, l as Level); + return t; +} + +/** 매각가: 그 단계에 낸 금액의 50%, 내림. */ +export const stageSale = (pos: number, level: Level) => Math.floor(stageCost(pos, level) / 2); + +/** Selling the whole land (every building, top down, then the land). */ +export function fullSale(pos: number, level: Level): number { + let t = 0; + for (let l = 0; l <= level; l++) t += stageSale(pos, l as Level); + return t; +} + +/** 인수가 = 2 × 투자액 (§5.7). */ +export const takeoverPrice = (pos: number, level: Level) => 2 * investment(pos, level); + +/** Base toll from the tables (no tourist doubling). */ +export function baseToll(pos: number, level: Level): number { + const s = SPACES[pos]!; + return s.kind === 'tour' ? s.tolls![0]! : s.tolls![level]!; +} + +/** Forward distance from `from` to `to` (0..39). */ +export const forwardSteps = (from: number, to: number) => (to - from + BOARD_SIZE) % BOARD_SIZE; diff --git a/packages/games/src/marble/index.ts b/packages/games/src/marble/index.ts new file mode 100644 index 0000000..01c9c8f --- /dev/null +++ b/packages/games/src/marble/index.ts @@ -0,0 +1,559 @@ +/** 주사위 땅따먹기 (docs/games/marble.md). Hidden info: the 행운카드 deck order (only its size is public). */ +import type { ZodType } from 'zod'; +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine'; +import { + CARD_IDS, + PROPERTY_POSITIONS, + SPACES, + SPACE_CENTER, + SPACE_FARE, + SPACE_TRAVEL, + forwardSteps, + fullSale, + isProperty, + stageCost, + stageSale, + takeoverPrice, + type CardId, + type Level, +} from './board'; +import { + AWAY_AFTER, + AWAY_MS, + MAX_TRADES_PER_TURN, + TRADE_MS, + aliveIds, + autoSell, + canTakeoverAt, + createState, + curId, + doBuild, + doBuy, + doConcede, + doEscape, + doPass, + doPropose, + doRespond, + doRoll, + doSell, + doSpaceTravel, + doTakeover, + marbleActionSchema, + marbleOptionsSchema, + maxLevel, + ownedBy, + pl, + pushLog, + raisable, + tollAt, + totalAssets, + type Creditor, + type DebtReason, + type MarbleAction, + type MarbleClientAction, + type MarbleOptions, + type MarbleResult, + type MarbleState, + type Phase, + type Trade, +} from './rules'; + +export * from './board'; +export * from './rules'; + +// ---------------------------------------------------------------- view types + +export interface MarblePlayerView { + id: PlayerId; + /** Seat colour index (turn order). */ + seat: number; + pos: number; + cash: number; + alive: boolean; + conceded: boolean; + bankruptAtRound: number | null; + islandLeft: number; + spacePending: boolean; + heldCards: CardId[]; + lands: number[]; + totalAssets: number; + away: boolean; +} + +export interface MarbleOwnedView { + pos: number; + owner: PlayerId; + level: Level; + /** Toll someone else pays here right now. */ + toll: number; + /** 인수가 when the takeover option applies to this square. */ + takeoverPrice: number | null; + /** Sale value of the top building stage (null = no building). */ + sellTop: number | null; + /** Sale value of the whole land with its buildings. */ + sellAll: number; +} + +export interface MarbleSellOption { + pos: number; + what: 'top' | 'all'; + amount: number; + level: Level; +} + +/** What the viewer can do right now, with costs precomputed (§8.3). */ +export interface MarbleMyView { + canRoll: boolean; + island: { left: number; fee: number | null; canPayFee: boolean; hasCard: boolean } | null; + space: boolean; + decide: { pos: number; buy: number | null; build: { level: Level; cost: number } | null; takeover: number | null; boughtThisVisit: boolean } | null; + sell: MarbleSellOption[] | null; + autoSell: { pos: number; what: 'top' | 'all' } | null; + canPropose: boolean; + respond: boolean; +} + +export interface MarbleSpacePreview { + dest: number; + salary: boolean; + owner: PlayerId | null; + toll: number; + fare: number; +} + +export interface MarbleView { + options: MarbleOptions; + me: PlayerId | null; + order: PlayerId[]; + current: PlayerId | null; + phase: Phase; + round: number; + maxRounds: number; + fund: number; + deckCount: number; + players: MarblePlayerView[]; + owned: MarbleOwnedView[]; + lastRoll: MarbleState['lastRoll']; + lastMove: MarbleState['lastMove']; + lastCard: MarbleState['lastCard']; + doublesThisTurn: number; + extraRoll: boolean; + decision: MarbleState['decision']; + debt: { debtor: PlayerId; amount: number; to: Creditor; reason: DebtReason; short: number; raisable: number } | null; + trade: Trade | null; + tradesLeft: number; + my: MarbleMyView | null; + spacePreview: MarbleSpacePreview[] | null; + turnSeq: number; + deadlineAt: number | null; + log: GameEvent[]; + result: MarbleResult | null; +} + +// ---------------------------------------------------------------- helpers + +type ClientAction = MarbleClientAction; + +function activeOf(s: MarbleState): PlayerId[] { + if (s.phase === 'finished') return []; + if (s.phase === 'debt') return [s.debt!.debtor]; + if (s.phase === 'tradeResponse') return [s.pendingTrade!.to]; + return [curId(s)]; +} + +/** The deterministic action a timeout plays (§8.5): never spends points by choice. */ +export function autoAction(s: MarbleState, player: PlayerId): ClientAction { + switch (s.phase) { + case 'tradeResponse': + return { type: 'TRADE_RESPOND', accept: false }; + case 'debt': { + const a = autoSell(s, player); + return a ? { type: 'SELL', ...a } : { type: 'CONCEDE' }; + } + case 'decide': + return { type: 'PASS' }; + default: + return pl(s, curId(s)).spacePending ? { type: 'SPACE_TRAVEL', dest: 0 } : { type: 'ROLL' }; + } +} + +function sellOptions(s: MarbleState, id: PlayerId): MarbleSellOption[] { + const out: MarbleSellOption[] = []; + for (const pos of ownedBy(s, id)) { + const o = s.owners[pos]!; + if (o.level >= 1) out.push({ pos, what: 'top', amount: stageSale(pos, o.level), level: o.level }); + out.push({ pos, what: 'all', amount: fullSale(pos, o.level), level: o.level }); + } + return out; +} + +const PHASE_BUSY: Record = { + turnStart: '지금은 주사위를 굴릴 차례예요.', + decide: '먼저 이 칸에서 할 일을 정해 주세요.', + debt: '먼저 모자란 포인트를 채워야 해요.', + tradeResponse: '교환 제안의 답을 기다리는 중이에요.', + finished: '게임이 이미 끝났어요.', +}; + +function validateTrade(s: MarbleState, actor: PlayerId, a: Extract): Validation { + if (!s.options.trade) return fail('교환 규칙을 쓰지 않는 방이에요.'); + if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]); + if (s.tradesThisTurn >= MAX_TRADES_PER_TURN) return fail('이번 차례에는 더 제안할 수 없어요(2번까지).'); + const to = s.players[a.to]; + if (!to || a.to === actor || !to.alive) return fail('그 사람에게는 제안할 수 없어요.'); + if (a.give.lands.length + a.take.lands.length + a.give.cash + a.take.cash === 0) return fail('주거나 받을 것을 하나 이상 골라 주세요.'); + const all = [...a.give.lands, ...a.take.lands]; + if (new Set(all).size !== all.length || !all.every(isProperty)) return fail('땅을 다시 골라 주세요.'); + if (!a.give.lands.every((p) => s.owners[p]?.owner === actor)) return fail('내 땅만 줄 수 있어요.'); + if (!a.take.lands.every((p) => s.owners[p]?.owner === a.to)) return fail('그 사람의 땅만 받을 수 있어요.'); + if (a.give.cash > pl(s, actor).cash) return fail('가진 포인트보다 많이 줄 수 없어요.'); + if (a.take.cash > to.cash) return fail('상대가 가진 포인트보다 많이 받을 수 없어요.'); + return ok; +} + +function refreshDeadline(s: MarbleState, now: number): void { + if (s.phase === 'finished') { + s.deadlineAt = null; + return; + } + if (s.phase === 'tradeResponse') { + s.deadlineAt = now + TRADE_MS; + return; + } + if (s.options.turnSeconds === null) { + s.deadlineAt = null; + return; + } + const who = activeOf(s)[0]!; + s.deadlineAt = now + ((s.timeouts[who] ?? 0) >= AWAY_AFTER ? AWAY_MS : s.options.turnSeconds * 1000); +} + +function spacePreview(s: MarbleState, id: PlayerId): MarbleSpacePreview[] { + const center = s.owners[SPACE_CENTER]; + const fare = center && center.owner !== id && pl(s, center.owner).alive ? SPACE_FARE : 0; + const from = pl(s, id).pos; + const out: MarbleSpacePreview[] = []; + for (let dest = 0; dest < 40; dest++) { + if (dest === SPACE_TRAVEL) continue; + const o = s.owners[dest]; + const other = o && o.owner !== id ? o.owner : null; + out.push({ dest, salary: from + forwardSteps(from, dest) >= 40, owner: o?.owner ?? null, toll: other ? tollAt(s, dest) : 0, fare }); + } + return out; +} + +// ---------------------------------------------------------------- definition + +export const marble: GameDefinition = { + id: 'marble', + nameKo: '주사위 땅따먹기', + minPlayers: 2, + maxPlayers: 4, + stateVersion: 1, + defaultOptions: marbleOptionsSchema.parse({}), + optionsSchema: marbleOptionsSchema as unknown as ZodType, + actionSchema: marbleActionSchema as unknown as ZodType, + + setup({ players, options, rng, now }) { + if (players.length < 2 || players.length > 4) throw new Error('marble: 2~4명이 할 수 있어요.'); + const s = createState(players, options, rng, [...CARD_IDS]); + pushLog(s, [], { type: 'turn', player: curId(s), round: 1 }); + refreshDeadline(s, now); + return s; + }, + + validate(s, actor, a) { + const p = s.players[actor]; + if (!p) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); + if (!p.alive) return fail('이미 게임에서 빠졌어요.'); + switch (a.type) { + case 'CONCEDE': + return ok; + case 'TIMEOUT': + return activeOf(s).includes(actor) ? ok : fail('지금은 시간 초과를 처리할 수 없어요.'); + case 'TRADE_RESPOND': + return s.phase === 'tradeResponse' && s.pendingTrade?.to === actor ? ok : fail('답할 교환 제안이 없어요.'); + case 'SELL': { + if (s.phase !== 'debt' || s.debt?.debtor !== actor) return fail('지금은 팔 수 없어요. 모자란 포인트를 채울 때만 팔 수 있어요.'); + const o = s.owners[a.pos]; + if (!o || o.owner !== actor) return fail('내 땅만 팔 수 있어요.'); + if (a.what === 'top' && o.level < 1) return fail('그 땅에는 팔 건물이 없어요.'); + return ok; + } + } + if (actor !== curId(s)) return fail('지금은 다른 사람 차례예요.'); + const o = s.options; + const d = s.decision; + switch (a.type) { + case 'ROLL': + if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]); + if (p.spacePending) return fail('우주여행 차례예요. 갈 칸을 골라 주세요.'); + return ok; + case 'PAY_ISLAND_FEE': + if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]); + if (p.islandLeft <= 0) return fail('무인도에 있지 않아요.'); + if (o.islandFee <= 0) return fail('이 방에서는 탈출비를 내고 나갈 수 없어요.'); + if (p.cash < o.islandFee) return fail('포인트가 부족해요.'); + return ok; + case 'USE_ESCAPE_CARD': + if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]); + if (p.islandLeft <= 0) return fail('무인도에 있지 않아요.'); + if (!p.heldCards.includes('L11')) return fail('무인도 탈출권이 없어요.'); + return ok; + case 'SPACE_TRAVEL': + if (s.phase !== 'turnStart' || !p.spacePending) return fail('지금은 우주여행을 할 수 없어요.'); + if (a.dest === SPACE_TRAVEL) return fail('우주여행 칸으로는 갈 수 없어요. 다른 칸을 골라 주세요.'); + return ok; + case 'BUY': + if (s.phase !== 'decide' || !d?.canBuy) return fail('지금은 이 땅을 살 수 없어요.'); + if (p.cash < SPACES[d.pos]!.price!) return fail('포인트가 부족해요.'); + return ok; + case 'BUILD': { + if (a.level === 4 && !o.landmark) return fail('이 방에서는 랜드마크를 짓지 않아요.'); + if (s.phase !== 'decide' || !d || d.canBuild === null) return fail('지금은 지을 수 없어요.'); + if (a.level !== d.canBuild) return fail('건물은 한 단계씩만 올릴 수 있어요.'); + if (a.level > maxLevel(o)) return fail('더 지을 수 없어요.'); + if (p.cash < stageCost(d.pos, a.level as Level)) return fail('포인트가 부족해요.'); + return ok; + } + case 'TAKEOVER': + if (!o.takeover) return fail('인수 규칙을 쓰지 않는 방이에요.'); + if (s.phase !== 'decide' || !d || d.canTakeover === null) return fail('지금은 인수할 수 없어요.'); + if (canTakeoverAt(s, d.pos, actor) === null) return fail('이 땅은 인수할 수 없어요.'); + if (p.cash < d.canTakeover) return fail('포인트가 부족해요.'); + return ok; + case 'PASS': + return s.phase === 'decide' ? ok : fail('지금은 넘어갈 결정이 없어요.'); + case 'PROPOSE_TRADE': + return validateTrade(s, actor, a); + } + return fail('알 수 없는 행동이에요.'); + }, + + apply(prev, actor, a, { rng, now }) { + const s = structuredClone(prev); + const ev: GameEvent[] = []; + s.turnSeq += 1; + let act: ClientAction; + if (a.type === 'TIMEOUT') { + act = autoAction(s, actor); + s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1; + pushLog(s, ev, { type: 'timeout', player: actor }); + if (s.timeouts[actor] === AWAY_AFTER) pushLog(s, ev, { type: 'away', player: actor }); + } else { + act = a; + if (a.type !== 'CONCEDE') s.timeouts[actor] = 0; + } + switch (act.type) { + case 'ROLL': + doRoll(s, rng, ev); + break; + case 'PAY_ISLAND_FEE': + doEscape(s, 'fee', ev); + break; + case 'USE_ESCAPE_CARD': + doEscape(s, 'card', ev); + break; + case 'SPACE_TRAVEL': + doSpaceTravel(s, act.dest, ev); + break; + case 'BUY': + doBuy(s, ev); + break; + case 'BUILD': + doBuild(s, act.level as Level, ev); + break; + case 'TAKEOVER': + doTakeover(s, ev); + break; + case 'PASS': + doPass(s, ev); + break; + case 'SELL': + doSell(s, actor, act.pos, act.what, ev); + break; + case 'PROPOSE_TRADE': + doPropose(s, { from: actor, to: act.to, give: act.give, take: act.take }, ev); + break; + case 'TRADE_RESPOND': + doRespond(s, act.accept, ev); + break; + case 'CONCEDE': + doConcede(s, actor, ev); + break; + } + refreshDeadline(s, now); + return { state: s, events: ev }; + }, + + view(s, viewer) { + const me = viewer !== null && s.players[viewer] ? viewer : null; + const cur = s.phase === 'finished' ? null : curId(s); + const players: MarblePlayerView[] = s.order.map((id, seat) => { + const p = s.players[id]!; + return { + id, + seat, + pos: p.pos, + cash: p.cash, + alive: p.alive, + conceded: p.conceded, + bankruptAtRound: p.bankruptAtRound, + islandLeft: p.islandLeft, + spacePending: p.spacePending, + heldCards: [...p.heldCards], + lands: ownedBy(s, id), + totalAssets: totalAssets(s, id), + away: p.alive && (s.timeouts[id] ?? 0) >= AWAY_AFTER, + }; + }); + const owned: MarbleOwnedView[] = PROPERTY_POSITIONS.filter((pos) => s.owners[pos]).map((pos) => { + const o = s.owners[pos]!; + return { + pos, + owner: o.owner, + level: o.level, + toll: tollAt(s, pos), + takeoverPrice: s.options.takeover && SPACES[pos]!.kind === 'city' && o.level < 4 ? takeoverPrice(pos, o.level) : null, + sellTop: o.level >= 1 ? stageSale(pos, o.level) : null, + sellAll: fullSale(pos, o.level), + }; + }); + let my: MarbleMyView | null = null; + if (me && s.phase !== 'finished' && s.players[me]!.alive) { + const p = s.players[me]!; + const mine = me === cur; + const start = mine && s.phase === 'turnStart'; + const d = mine && s.phase === 'decide' ? s.decision : null; + const debtor = s.phase === 'debt' && s.debt?.debtor === me; + my = { + canRoll: start && !p.spacePending, + island: + start && p.islandLeft > 0 + ? { left: p.islandLeft, fee: s.options.islandFee > 0 ? s.options.islandFee : null, canPayFee: s.options.islandFee > 0 && p.cash >= s.options.islandFee, hasCard: p.heldCards.includes('L11') } + : null, + space: start && p.spacePending, + decide: d + ? { + pos: d.pos, + buy: d.canBuy ? SPACES[d.pos]!.price! : null, + build: d.canBuild !== null ? { level: d.canBuild, cost: stageCost(d.pos, d.canBuild) } : null, + takeover: d.canTakeover, + boughtThisVisit: d.boughtThisVisit, + } + : null, + sell: debtor ? sellOptions(s, me) : null, + autoSell: debtor ? autoSell(s, me) : null, + canPropose: start && s.options.trade && s.tradesThisTurn < MAX_TRADES_PER_TURN && aliveIds(s).length > 1, + respond: s.phase === 'tradeResponse' && s.pendingTrade?.to === me, + }; + } + const dv = s.debt; + return { + options: s.options, + me, + order: [...s.order], + current: cur, + phase: s.phase, + round: s.round, + maxRounds: s.options.maxRounds, + fund: s.fund, + deckCount: s.deck.length, + players, + owned, + lastRoll: s.lastRoll, + lastMove: s.lastMove, + lastCard: s.lastCard, + doublesThisTurn: s.doublesThisTurn, + extraRoll: s.extraRoll, + decision: s.decision, + debt: dv ? { debtor: dv.debtor, amount: dv.amount, to: dv.to, reason: dv.reason, short: Math.max(0, dv.amount - s.players[dv.debtor]!.cash), raisable: raisable(s, dv.debtor) } : null, + trade: s.pendingTrade, + tradesLeft: MAX_TRADES_PER_TURN - s.tradesThisTurn, + my, + spacePreview: cur && s.phase === 'turnStart' && s.players[cur]!.spacePending ? spacePreview(s, cur) : null, + turnSeq: s.turnSeq, + deadlineAt: s.deadlineAt, + log: s.log, + result: s.final, + }; + }, + + activePlayers: activeOf, + + deadline(s) { + return s.phase === 'finished' ? null : s.deadlineAt; + }, + + timeoutPlayers: activeOf, + + onTimeout() { + return { type: 'TIMEOUT' }; + }, + + onLeave(s, player) { + const p = s.players[player]; + if (!p || !p.alive || s.phase === 'finished') 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.player]); + const scores: Record = {}; + for (const r of f.ranking) scores[r.player] = r.totalAssets; + const out = s.order.filter((p) => !s.players[p]!.alive); + const resign = f.reason === 'lastSurvivor' && out.length > 0 && out.every((p) => s.players[p]!.conceded); + return { + ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps), + scores, + summary: f.summary, + reason: resign ? 'resign' : 'normal', + }; + }, + + /** Test helper: every legal action (CONCEDE left out; a couple of sample trades when the option is on). */ + legalActions(s, player) { + if (s.phase === 'finished' || !s.players[player]?.alive) return []; + if (s.phase === 'tradeResponse') return s.pendingTrade?.to === player ? [{ type: 'TRADE_RESPOND', accept: true }, { type: 'TRADE_RESPOND', accept: false }] : []; + if (s.phase === 'debt') return s.debt?.debtor === player ? sellOptions(s, player).map((o) => ({ type: 'SELL' as const, pos: o.pos, what: o.what })) : []; + if (player !== curId(s)) return []; + const p = s.players[player]!; + const out: MarbleAction[] = []; + if (s.phase === 'turnStart') { + if (p.spacePending) { + for (let dest = 0; dest < 40; dest++) if (dest !== SPACE_TRAVEL) out.push({ type: 'SPACE_TRAVEL', dest }); + } else out.push({ type: 'ROLL' }); + for (const a of [{ type: 'PAY_ISLAND_FEE' }, { type: 'USE_ESCAPE_CARD' }] as const) if (marble.validate(s, player, a).ok) out.push(a); + if (s.options.trade) { + const mine = ownedBy(s, player); + for (const q of aliveIds(s)) { + if (q === player) continue; + const theirs = ownedBy(s, q); + const offers: MarbleAction[] = []; + if (mine.length && theirs.length) offers.push({ type: 'PROPOSE_TRADE', to: q, give: { lands: [mine[0]!], cash: 0 }, take: { lands: [theirs[0]!], cash: 0 } }); + if (theirs.length) offers.push({ type: 'PROPOSE_TRADE', to: q, give: { lands: [], cash: Math.min(p.cash, 30) }, take: { lands: [theirs[theirs.length - 1]!], cash: 0 } }); + for (const t of offers) if (marble.validate(s, player, t).ok) out.push(t); + } + } + return out; + } + if (s.phase === 'decide') { + const d = s.decision!; + out.push({ type: 'PASS' }); + if (d.canBuy && p.cash >= SPACES[d.pos]!.price!) out.push({ type: 'BUY' }); + if (d.canBuild !== null && p.cash >= stageCost(d.pos, d.canBuild)) out.push({ type: 'BUILD', level: d.canBuild }); + if (d.canTakeover !== null && p.cash >= d.canTakeover) out.push({ type: 'TAKEOVER' }); + } + return out; + }, + + /** Test helper: IDs of cards that were never drawn — no view may contain them. */ + secretsOf(s) { + return CARD_IDS.filter((c) => !s.revealed.includes(c)); + }, +}; + diff --git a/packages/games/src/marble/marble.test.ts b/packages/games/src/marble/marble.test.ts new file mode 100644 index 0000000..d0db0ef --- /dev/null +++ b/packages/games/src/marble/marble.test.ts @@ -0,0 +1,1131 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN, type Rng } from '@bg/engine'; +import { + CARD_IDS, + GROUPS, + LINES, + PROPERTY_POSITIONS, + SPACES, + autoAction, + curId, + marble, + maxLevel, + type CardId, + type Level, + type MarbleAction, + type MarbleOptions, + type MarbleState, +} from './index'; + +// ---------------------------------------------------------------- helpers + +/** Scripted dice: `int(6)` returns the queued die faces (1..6) in order. */ +class Dice implements Rng { + private vals: number[]; + constructor(vals: number[]) { + this.vals = [...vals]; + } + next(): number { + throw new Error('dice script: next() not expected'); + } + int(n: number): number { + const v = this.vals.shift(); + if (v === undefined) throw new Error('dice script exhausted'); + if (n !== 6) throw new Error(`dice script: int(${n})`); + return v - 1; + } + shuffle(a: readonly T[]): T[] { + return [...a]; + } + get left(): number { + return this.vals.length; + } +} +const dice = (...v: number[]) => new Dice(v); + +const PLAYERS = ['A', 'B', 'C', 'D']; + +/** Fresh game, seats in A, B, C… order with A to move and the deck in L01…L20 order. */ +function make(opts: Partial = {}, n = 2): MarbleState { + const options = marble.optionsSchema.parse(opts); + const s = marble.setup({ players: PLAYERS.slice(0, n), options, rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + s.order = PLAYERS.slice(0, n); + s.current = 0; + s.deck = [...CARD_IDS]; + s.log = []; + return s; +} + +let clock = 1000; +function actE(s: MarbleState, p: string, a: MarbleAction, rng: Rng = dice()) { + const v = marble.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return marble.apply(s, p, a, { rng, now: (clock += 1000) }); +} +const act = (s: MarbleState, p: string, a: MarbleAction, rng?: Rng) => actE(s, p, a, rng).state; +const reject = (s: MarbleState, p: string, a: MarbleAction) => { + const v = marble.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const roll = (s: MarbleState, d1: number, d2: number) => act(s, curId(s), { type: 'ROLL' }, dice(d1, d2)); +const own = (s: MarbleState, pos: number, owner: string, level: Level = 0) => (s.owners[pos] = { owner, level }); +const put = (s: MarbleState, id: string, pos: number) => (s.players[id]!.pos = pos); +const cash = (s: MarbleState, id: string) => s.players[id]!.cash; +const setCash = (s: MarbleState, id: string, n: number) => (s.players[id]!.cash = n); +/** Puts `card` on top of the deck. */ +const topCard = (s: MarbleState, card: CardId) => (s.deck = [card, ...s.deck.filter((c) => c !== card)]); +const PASS: MarbleAction = { type: 'PASS' }; + +// ---------------------------------------------------------------- board data + +describe('판 데이터 (§3)', () => { + test('40칸: 도시 24, 관광지 4, 행운카드 7, 특수 5', () => { + expect(SPACES.length).toBe(40); + const count = (k: string) => SPACES.filter((s) => s.kind === k).length; + expect([count('city'), count('tour'), count('card')]).toEqual([24, 4, 7]); + expect(SPACES.filter((s) => ['start', 'island', 'fundIn', 'space', 'fundOut'].includes(s.kind)).map((s) => s.pos)).toEqual([0, 10, 20, 30, 38]); + expect(SPACES.filter((s) => s.kind === 'card').map((s) => s.pos)).toEqual([2, 7, 12, 17, 22, 28, 33]); + expect(Object.values(GROUPS).every((g) => g.length === 3)).toBe(true); + expect(LINES).toEqual([ + [1, 3, 4, 6, 8, 9], + [11, 13, 14, 16, 18, 19], + [21, 23, 24, 26, 27, 29], + [31, 32, 34, 36, 37, 39], + ]); + }); + + test('가격표는 §3.2 공식(건설비 ×0.5/1.0/1.5/2.0, 통행료 ×0.2(최소 1)/0.6/1.5/2.5/4.0, 반올림)과 같다', () => { + for (const s of SPACES.filter((x) => x.kind === 'city')) { + const p = s.price!; + expect(s.build).toEqual([5, 10, 15, 20].map((k) => Math.round((p * k) / 10)) as [number, number, number, number]); + expect(s.tolls).toEqual([Math.max(1, Math.round((p * 2) / 10)), ...[6, 15, 25, 40].map((k) => Math.round((p * k) / 10))]); + } + expect(SPACES.filter((x) => x.kind === 'tour').map((s) => [s.name, s.price, s.tolls![0]])).toEqual([ + ['제주도', 20, 10], + ['경주', 20, 10], + ['우주센터', 30, 15], + ['부산', 25, 12], + ]); + expect(SPACES[39]).toMatchObject({ name: '서울', price: 40, build: [20, 40, 60, 80], tolls: [8, 24, 60, 100, 160] }); + expect(SPACES[36]).toMatchObject({ name: '뉴욕', build: [18, 35, 53, 70], tolls: [7, 21, 53, 88, 140] }); + }); + + test('options: every option has a Korean title; defaults match §2', () => { + expect(marble.defaultOptions).toEqual({ + startMoney: 300, + salary: 20, + maxRounds: 20, + buildOnPurchase: false, + islandTurns: 3, + islandFee: 0, + takeover: false, + landmark: false, + monopolyWin: false, + trade: false, + turnSeconds: 30, + }); + const shape = (marble.optionsSchema as unknown as { shape: Record }).shape; + for (const [k, f] of Object.entries(shape)) expect([k, /[가-힣]/.test(f.meta()?.title ?? '')]).toEqual([k, true]); + }); +}); + +// ---------------------------------------------------------------- setup + +describe('준비 (§4)', () => { + test('everyone on 출발 with startMoney, deck of 20 hidden cards, round 1, order is a shuffle of the seats', () => { + const s = marble.setup({ players: ['A', 'B', 'C'], options: marble.optionsSchema.parse({ startMoney: 500 }), rng: SeededRng.fromSeed(seedN(3)), now: 0 }); + expect([...s.order].sort()).toEqual(['A', 'B', 'C']); + expect(Object.values(s.players).every((p) => p.pos === 0 && p.cash === 500 && p.alive)).toBe(true); + expect([...s.deck].sort()).toEqual(CARD_IDS); + expect([s.round, s.fund, s.phase, Object.keys(s.owners).length]).toEqual([1, 0, 'turnStart', 0]); + expect(() => marble.setup({ players: ['A'], options: marble.defaultOptions, rng: SeededRng.fromSeed(seedN(3)), now: 0 })).toThrow(); + expect(() => marble.setup({ players: PLAYERS.concat('E'), options: marble.defaultOptions, rng: SeededRng.fromSeed(seedN(3)), now: 0 })).toThrow(); + }); +}); + +// ---------------------------------------------------------------- salary & movement + +describe('월급과 이동 (§5.1)', () => { + test('37에서 5칸 → 2 도착, 월급 1번', () => { + let s = make(); + put(s, 'A', 37); + topCard(s, 'L11'); // a card that keeps the points unchanged + s = roll(s, 2, 3); + expect(s.players.A!.pos).toBe(2); + expect(cash(s, 'A')).toBe(320); + expect(s.log.filter((e) => e.type === 'salary')).toHaveLength(1); + }); + + test('출발(0)에 정확히 도착해도 1번', () => { + let s = make(); + put(s, 'A', 35); + s = roll(s, 1, 4); + expect([s.players.A!.pos, cash(s, 'A'), curId(s)]).toEqual([0, 320, 'B']); + }); + + test('뒤로 3칸 카드로 0을 거꾸로 지나면 월급 없음 (2 → 39 서울)', () => { + let s = make(); + put(s, 'A', 37); + topCard(s, 'L16'); + s = roll(s, 2, 3); // 37 → 2 (salary), card: back 3 → 39 + expect(s.players.A!.pos).toBe(39); + expect(cash(s, 'A')).toBe(320); + expect(s.log.filter((e) => e.type === 'salary')).toHaveLength(1); + expect(s.phase).toBe('decide'); + expect(s.decision).toMatchObject({ pos: 39, canBuy: true }); + }); + + test('§5.9 예시 B: 37에서 더블 4-4 → 제주도(5), 월급 받고 살지 정한 뒤 한 번 더 굴림', () => { + let s = make(); + put(s, 'A', 37); + s = roll(s, 4, 4); + expect([s.players.A!.pos, cash(s, 'A'), s.phase, s.extraRoll]).toEqual([5, 320, 'decide', true]); + s = act(s, 'A', { type: 'BUY' }); + expect([s.owners[5]?.owner, cash(s, 'A'), s.phase, curId(s)]).toEqual(['A', 300, 'turnStart', 'A']); + }); +}); + +// ---------------------------------------------------------------- doubles + +describe('더블 (§5.1-4)', () => { + test('더블 → 처리 후 재굴림; 3번째 더블 → 이동 없이 무인도, 월급 없음', () => { + let s = make(); + s = roll(s, 2, 2); // → 4 하노이 + expect([s.phase, s.extraRoll]).toEqual(['decide', true]); + s = act(s, 'A', PASS); + expect([s.phase, curId(s), s.doublesThisTurn]).toEqual(['turnStart', 'A', 1]); + s = roll(s, 1, 1); // → 6 방콕 + s = act(s, 'A', PASS); + expect([curId(s), s.doublesThisTurn]).toEqual(['A', 2]); + put(s, 'A', 37); // the third double would pass 출발 if it moved + s = roll(s, 3, 3); + expect([s.players.A!.pos, s.players.A!.islandLeft, cash(s, 'A'), curId(s)]).toEqual([10, 3, 300, 'B']); + expect(s.log.some((e) => e.type === 'toIsland' && e.why === 'doubles')).toBe(true); + }); + + test('더블이 아니면 차례가 넘어가고 더블 횟수가 0으로', () => { + let s = make(); + s = roll(s, 1, 2); // → 3 마닐라 + s = act(s, 'A', PASS); + expect([curId(s), s.doublesThisTurn, s.extraRoll]).toEqual(['B', 0, false]); + }); + + test('더블로 우주여행 도착 → 추가 굴림 소멸, 다음 차례는 SPACE_TRAVEL만 (ROLL 거부)', () => { + let s = make(); + put(s, 'A', 26); + s = roll(s, 2, 2); + expect([s.players.A!.pos, s.players.A!.spacePending, curId(s), s.extraRoll]).toEqual([30, true, 'B', false]); + s = roll(s, 1, 2); // B → 3 + s = act(s, 'B', PASS); + expect(curId(s)).toBe('A'); + expect(reject(s, 'A', { type: 'ROLL' })).toBe('우주여행 차례예요. 갈 칸을 골라 주세요.'); + expect(reject(s, 'A', { type: 'SPACE_TRAVEL', dest: 30 })).toContain('우주여행 칸으로는 갈 수 없어요'); + expect(marble.legalActions!(s, 'A').every((a) => a.type === 'SPACE_TRAVEL')).toBe(true); + s = act(s, 'A', { type: 'SPACE_TRAVEL', dest: 39 }); // §5.9 C: 30 → 39, no salary + expect([s.players.A!.pos, cash(s, 'A'), s.phase, s.players.A!.spacePending]).toEqual([39, 300, 'decide', false]); + }); + + test('더블로 무인도 칸 도착 → 추가 굴림 없음', () => { + let s = make(); + put(s, 'A', 4); + s = roll(s, 3, 3); + expect([s.players.A!.pos, s.players.A!.islandLeft, curId(s)]).toEqual([10, 3, 'B']); + }); +}); + +// ---------------------------------------------------------------- island + +describe('무인도 (§5.4)', () => { + /** A on the island, A to move again (B's turns are skipped by hand). */ + const onIsland = (opts: Partial = {}) => { + const s = make(opts); + put(s, 'A', 10); + s.players.A!.islandLeft = s.options.islandTurns; + return s; + }; + const backToA = (s: MarbleState) => { + expect(curId(s)).toBe('B'); + s.current = 0; + return s; + }; + + test('islandTurns=3: 비더블 3번 → 4번째 차례는 일반 진행', () => { + let s = onIsland(); + for (const left of [2, 1, 0]) { + s = backToA(roll(s, 1, 2)); + expect([s.players.A!.pos, s.players.A!.islandLeft]).toEqual([10, left]); + } + s = roll(s, 1, 2); + expect([s.players.A!.pos, s.players.A!.islandLeft]).toEqual([13, 0]); + }); + + test('무인도에서 더블 → 탈출·이동, 추가 굴림 없음', () => { + let s = onIsland(); + s = roll(s, 3, 3); + expect([s.players.A!.pos, s.players.A!.islandLeft, s.phase, s.extraRoll]).toEqual([16, 0, 'decide', false]); + s = act(s, 'A', PASS); + expect(curId(s)).toBe('B'); + }); + + test('탈출권 사용 → 같은 차례 일반 굴림, 더블이면 재굴림; 카드는 더미 맨 아래로', () => { + let s = onIsland(); + s.players.A!.heldCards = ['L11']; + s.deck = s.deck.filter((c) => c !== 'L11'); + expect(reject(s, 'A', { type: 'PAY_ISLAND_FEE' })).toBe('이 방에서는 탈출비를 내고 나갈 수 없어요.'); + s = act(s, 'A', { type: 'USE_ESCAPE_CARD' }); + expect([s.players.A!.islandLeft, s.players.A!.heldCards, s.deck.at(-1), s.phase, curId(s)]).toEqual([0, [], 'L11', 'turnStart', 'A']); + s = roll(s, 2, 2); + expect(s.players.A!.pos).toBe(14); + s = act(s, 'A', PASS); + expect([curId(s), s.phase]).toEqual(['A', 'turnStart']); + }); + + test('탈출비(옵션): 내고 바로 나가 같은 차례에 굴림; 포인트가 모자라면 거부', () => { + let s = onIsland({ islandFee: 20 }); + setCash(s, 'A', 19); + expect(reject(s, 'A', { type: 'PAY_ISLAND_FEE' })).toBe('포인트가 부족해요.'); + setCash(s, 'A', 50); + s = act(s, 'A', { type: 'PAY_ISLAND_FEE' }); + expect([cash(s, 'A'), s.players.A!.islandLeft, curId(s)]).toEqual([30, 0, 'A']); + s = roll(s, 1, 2); + expect(s.players.A!.pos).toBe(13); + }); + + test('무인도에 있어도 통행료는 받는다; 출발을 지나 무인도에 와도 그냥 지나가면 아무 일 없음', () => { + let s = onIsland(); + own(s, 3, 'A', 1); + s.current = 1; + put(s, 'B', 1); + s = roll(s, 1, 1); // B → 3 (double), toll 4 to A + expect([cash(s, 'A'), cash(s, 'B')]).toEqual([304, 296]); + put(s, 'B', 8); + s = roll(s, 1, 2); // B passes 10 → 11 + expect([s.players.B!.pos, s.players.B!.islandLeft]).toEqual([11, 0]); + }); +}); + +// ---------------------------------------------------------------- buying & building + +describe('구매·건설 (§5.2)', () => { + test('구매한 방문에 BUILD 거부, 다음 방문 별장만, 빌딩 건너뛰기 거부, 한 방문 2단계 거부', () => { + let s = make(); + s = roll(s, 1, 2); // → 3 마닐라 + s = act(s, 'A', { type: 'BUY' }); + expect([s.owners[3], cash(s, 'A'), curId(s)]).toEqual([{ owner: 'A', level: 0 }, 294, 'B']); + expect(reject(s, 'A', { type: 'BUILD', level: 1 })).toBe('지금은 다른 사람 차례예요.'); + // Next visit. + s.current = 0; + put(s, 'A', 0); + s = roll(s, 1, 2); + expect(s.decision).toMatchObject({ pos: 3, canBuy: false, canBuild: 1 }); + expect(reject(s, 'A', { type: 'BUY' })).toBe('지금은 이 땅을 살 수 없어요.'); + expect(reject(s, 'A', { type: 'BUILD', level: 2 })).toBe('건물은 한 단계씩만 올릴 수 있어요.'); + expect(reject(s, 'A', { type: 'BUILD', level: 4 })).toBe('이 방에서는 랜드마크를 짓지 않아요.'); + s = act(s, 'A', { type: 'BUILD', level: 1 }); + expect([s.owners[3]!.level, cash(s, 'A'), curId(s)]).toEqual([1, 291, 'B']); + expect(reject(s, 'A', { type: 'BUILD', level: 2 })).toBe('지금은 다른 사람 차례예요.'); + }); + + test('한 방문에 1단계: 더블로 같은 차례에 다시 굴려도 결정은 닫혀 있음', () => { + let s = make(); + own(s, 4, 'A', 0); + s = roll(s, 2, 2); // 4 own → build offer + s = act(s, 'A', { type: 'BUILD', level: 1 }); + expect([s.phase, curId(s)]).toEqual(['turnStart', 'A']); + expect(reject(s, 'A', { type: 'BUILD', level: 2 })).toBe('지금은 지을 수 없어요.'); + }); + + test('buildOnPurchase=true: 산 직후 별장까지만', () => { + let s = make({ buildOnPurchase: true }); + s = roll(s, 1, 2); + s = act(s, 'A', { type: 'BUY' }); + expect(s.decision).toMatchObject({ pos: 3, canBuild: 1, boughtThisVisit: true }); + s = act(s, 'A', { type: 'BUILD', level: 1 }); + expect([s.owners[3]!.level, cash(s, 'A'), curId(s)]).toEqual([1, 291, 'B']); + }); + + test('현금이 부족하면 살 수 없다(결정 없이 지나감); 관광지에는 건물이 없음', () => { + let s = make(); + setCash(s, 'A', 5); + s = roll(s, 1, 2); + expect([s.phase, curId(s), s.owners[3]]).toEqual(['turnStart', 'B', undefined]); + expect(s.log.some((e) => e.type === 'cannotAfford')).toBe(true); + s = make(); + own(s, 5, 'A'); + s = roll(s, 1, 4); + expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); + }); + + test('BUY 검증: 포인트가 부족해요', () => { + const s = make(); + s.phase = 'decide'; + s.decision = { pos: 39, canBuy: true, canBuild: null, canTakeover: null, boughtThisVisit: false }; + setCash(s, 'A', 39); + expect(reject(s, 'A', { type: 'BUY' })).toBe('포인트가 부족해요.'); + expect(reject(s, 'B', { type: 'BUY' })).toBe('지금은 다른 사람 차례예요.'); + expect(reject(s, 'A', { type: 'ROLL' })).toBe('먼저 이 칸에서 할 일을 정해 주세요.'); + }); + + test('호텔 다음은 랜드마크 옵션일 때만', () => { + let s = make(); + own(s, 3, 'A', 3); + s = roll(s, 1, 2); + expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); + s = make({ landmark: true }); + own(s, 3, 'A', 3); + s = roll(s, 1, 2); + expect(s.decision).toMatchObject({ canBuild: 4 }); + s = act(s, 'A', { type: 'BUILD', level: 4 }); + expect([s.owners[3]!.level, cash(s, 'A')]).toEqual([4, 288]); + expect(maxLevel(s.options)).toBe(4); + }); +}); + +// ---------------------------------------------------------------- tolls + +describe('통행료 (§5.2, §3.2/3.3)', () => { + test.each([ + [39, 0, 8], + [39, 1, 24], + [39, 2, 60], + [39, 3, 100], + [1, 0, 1], + [9, 1, 7], + [36, 3, 88], + ] as [number, Level, number][])('칸 %i 단계 %i → %i', (pos, level, toll) => { + let s = make(); + own(s, pos, 'B', level); + put(s, 'A', (pos - 3 + 40) % 40); + s = roll(s, 1, 2); + const salary = pos < 3 ? 20 : 0; + expect([cash(s, 'A'), cash(s, 'B')]).toEqual([300 + salary - toll, 300 + toll]); + expect(s.log.find((e) => e.type === 'tollPaid')).toMatchObject({ from: 'A', to: 'B', amount: toll, waived: false }); + }); + + test('랜드마크 통행료 (옵션)', () => { + let s = make({ landmark: true }); + own(s, 39, 'B', 4); + put(s, 'A', 36); + s = roll(s, 1, 2); + expect(cash(s, 'A')).toBe(140); + }); + + test('통행료 면제권: 자동 사용 후 더미 맨 아래로', () => { + let s = make(); + own(s, 39, 'B', 3); + s.players.A!.heldCards = ['L12']; + s.deck = s.deck.filter((c) => c !== 'L12'); + put(s, 'A', 36); + s = roll(s, 1, 2); + expect([cash(s, 'A'), cash(s, 'B'), s.players.A!.heldCards, s.deck.at(-1), s.deck.length]).toEqual([300, 300, [], 'L12', 20]); + expect(s.log.find((e) => e.type === 'tollPaid')).toMatchObject({ waived: true, amount: 100 }); + }); + + test('관광지 2곳 이상 소유 시 각 관광지 통행료 2배', () => { + let s = make(); + own(s, 5, 'B'); + s = roll(s, 1, 4); + expect(cash(s, 'A')).toBe(290); + s = make(); + own(s, 5, 'B'); + own(s, 35, 'B'); + s = roll(s, 1, 4); + expect(cash(s, 'A')).toBe(280); + s = make(); + own(s, 25, 'B'); + own(s, 15, 'B'); + own(s, 5, 'B'); + put(s, 'A', 22); + s = roll(s, 1, 2); + expect(cash(s, 'A')).toBe(270); + }); +}); + +// ---------------------------------------------------------------- debt & bankruptcy + +describe('빚 갚기와 파산 (§5.3)', () => { + test('§5.9 예시 A: 리스본 빌딩·별장·땅을 팔아 102 → 자동으로 100 지불, 현금 2', () => { + let s = make(); + own(s, 39, 'B', 3); + own(s, 26, 'A', 2); // 리스본 빌딩: 13 + 6 + 13 = 32 + own(s, 5, 'A'); // 제주도 10 + own(s, 15, 'A'); // 경주 10 → 확보 가능액 70 + 52 = 122 + setCash(s, 'A', 70); + put(s, 'A', 36); + s = roll(s, 1, 2); + expect(s.phase).toBe('debt'); + expect(s.debt).toMatchObject({ debtor: 'A', amount: 100, to: { kind: 'player', id: 'B' }, reason: 'toll' }); + const v = marble.view(s, 'A'); + expect(v.debt).toMatchObject({ short: 30, raisable: 122 }); + expect(v.my!.sell).toContainEqual({ pos: 26, what: 'top', amount: 13, level: 2 }); + expect(v.my!.sell).toContainEqual({ pos: 26, what: 'all', amount: 32, level: 2 }); + expect(reject(s, 'A', { type: 'ROLL' })).toBe('먼저 모자란 포인트를 채워야 해요.'); + expect(reject(s, 'B', { type: 'SELL', pos: 26, what: 'top' })).toContain('지금은 팔 수 없어요'); + expect(reject(s, 'A', { type: 'SELL', pos: 39, what: 'all' })).toBe('내 땅만 팔 수 있어요.'); + expect(reject(s, 'A', { type: 'SELL', pos: 5, what: 'top' })).toBe('그 땅에는 팔 건물이 없어요.'); + s = act(s, 'A', { type: 'SELL', pos: 26, what: 'top' }); + s = act(s, 'A', { type: 'SELL', pos: 26, what: 'top' }); + expect([cash(s, 'A'), s.owners[26]!.level, s.phase]).toEqual([89, 0, 'debt']); + s = act(s, 'A', { type: 'SELL', pos: 26, what: 'all' }); + expect([cash(s, 'A'), cash(s, 'B'), s.owners[26], s.phase, curId(s)]).toEqual([2, 400, undefined, 'turnStart', 'B']); + }); + + test('확보 가능액 < 빚 → 즉시 파산, 채권자가 매각 후 현금 전부, 땅은 은행으로', () => { + let s = make({}, 3); + own(s, 39, 'B', 3); + own(s, 1, 'A', 1); // 3 + 1 + s.players.A!.heldCards = ['L11']; + s.deck = s.deck.filter((c) => c !== 'L11'); + setCash(s, 'A', 50); + put(s, 'A', 36); + s = roll(s, 1, 2); + expect([s.players.A!.alive, s.players.A!.cash, cash(s, 'B'), s.owners[1], s.players.A!.bankruptAtRound]).toEqual([false, 0, 354, undefined, 1]); + expect([s.deck.length, s.deck.at(-1)]).toEqual([20, 'L11']); + expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); + expect(s.log.find((e) => e.type === 'bankrupt')).toMatchObject({ player: 'A', creditor: { kind: 'player', id: 'B' }, amount: 54 }); + }); + + test('2명 중 1명 파산 → 생존자 1위로 끝', () => { + let s = make(); + own(s, 39, 'B', 3); + setCash(s, 'A', 10); + put(s, 'A', 36); + s = roll(s, 1, 2); + expect(s.phase).toBe('finished'); + const r = marble.result(s)!; + expect(r.ranking).toEqual([['B'], ['A']]); + expect(r.reason).toBe('normal'); + expect(s.final!.reason).toBe('lastSurvivor'); + }); + + test('L19(여행 경비) 중 파산: 좌석 순 앞사람만 받고 이후 사람은 못 받음', () => { + let s = make({}, 4); + setCash(s, 'A', 7); + put(s, 'A', 0); + topCard(s, 'L19'); + s = roll(s, 1, 1); // → 2 행운카드 + expect([cash(s, 'B'), cash(s, 'C'), cash(s, 'D')]).toEqual([305, 302, 300]); + expect(s.players.A!.alive).toBe(false); + expect(curId(s)).toBe('B'); + }); + + test('L04(생일 축하): 다른 사람이 모자라면 그 사람이 빚 갚기, 다 갚으면 내 차례가 이어짐', () => { + let s = make({}, 3); + setCash(s, 'B', 2); + own(s, 6, 'B'); // 방콕: sale 5 + topCard(s, 'L04'); + s = roll(s, 1, 1); // → 2 행운카드, double + expect(s.phase).toBe('debt'); + expect(marble.activePlayers(s)).toEqual(['B']); + s = act(s, 'B', { type: 'SELL', pos: 6, what: 'all' }); + expect([cash(s, 'A'), cash(s, 'B'), cash(s, 'C'), s.phase, curId(s)]).toEqual([310, 2, 295, 'turnStart', 'A']); + }); +}); + +// ---------------------------------------------------------------- cards + +describe('행운카드 (§3.5)', () => { + /** A draws `card` from the card square 7 (rolled from 4 with 1+2) — or from `from`. */ + const draw = (card: CardId, setup?: (s: MarbleState) => void, from = 4, d: [number, number] = [1, 2], opts: Partial = {}, n = 3) => { + let s = make(opts, n); + put(s, 'A', from); + topCard(s, card); + setup?.(s); + s = roll(s, d[0], d[1]); + expect(s.lastCard?.id).toBe(card); + return s; + }; + + test.each([ + ['L01', 20], + ['L02', 10], + ['L03', 15], + ['L05', 10], + ] as [CardId, number][])('%s: 은행에서 %i 받기, 카드는 맨 아래로', (card, n) => { + const s = draw(card); + expect([cash(s, 'A'), s.deck.at(-1), s.deck.length]).toEqual([300 + n, card, 20]); + }); + + test('L04: 다른 사람 모두에게서 5씩', () => { + const s = draw('L04'); + expect([cash(s, 'A'), cash(s, 'B'), cash(s, 'C')]).toEqual([310, 295, 295]); + }); + + test('L06/L08: 기금 적립, L07: 은행에 10', () => { + expect([cash(draw('L06'), 'A'), draw('L06').fund]).toEqual([295, 5]); + expect([cash(draw('L08'), 'A'), draw('L08').fund]).toEqual([290, 10]); + expect([cash(draw('L07'), 'A'), draw('L07').fund]).toEqual([290, 0]); + }); + + test('L09 건물 수리비: 호텔 도시 19, 별장 3, 땅만 0', () => { + const s = draw('L09', (s) => { + own(s, 1, 'A', 3); + own(s, 3, 'A', 1); + own(s, 4, 'A', 0); + own(s, 6, 'B', 3); + }); + expect(cash(s, 'A')).toBe(278); + expect(draw('L09', (s) => own(s, 39, 'A', 4), 4, [1, 2], { landmark: true }).players.A!.cash).toBe(266); + }); + + test('L10 폭풍 경보: 무인도로 바로(월급 없음), 더블 연속 끊김', () => { + const s = draw('L10', undefined, 5, [1, 1]); + expect([s.players.A!.pos, s.players.A!.islandLeft, cash(s, 'A'), curId(s), s.extraRoll]).toEqual([10, 3, 300, 'B', false]); + }); + + test('L11/L12: 보관(더미에서 빠짐)', () => { + const s = draw('L11'); + expect([s.players.A!.heldCards, s.deck.length, s.deck.includes('L11')]).toEqual([['L11'], 19, false]); + expect(marble.view(s, 'B').players.find((p) => p.id === 'A')!.heldCards).toEqual(['L11']); + }); + + test('L13 출발지로: 0으로 이동, 월급', () => { + const s = draw('L13'); + expect([s.players.A!.pos, cash(s, 'A')]).toEqual([0, 320]); + }); + + test('L14 우주 초대장: 33에서 우주여행(30)으로 앞으로 → 출발을 지나 월급, 우주여행 대기', () => { + const s = draw('L14', undefined, 31, [1, 1]); + expect([s.players.A!.pos, cash(s, 'A'), s.players.A!.spacePending, curId(s), s.extraRoll]).toEqual([30, 320, true, 'B', false]); + }); + + test('L15 서울 관광: 서울로 이동, 도착 효과(통행료)', () => { + const s = draw('L15', (s) => own(s, 39, 'B', 1)); + expect([s.players.A!.pos, cash(s, 'A'), cash(s, 'B')]).toEqual([39, 276, 324]); + }); + + test('L16 뒤로 3칸: 7 → 4 하노이 도착 효과', () => { + const s = draw('L16'); + expect([s.players.A!.pos, s.phase, s.decision?.pos]).toEqual([4, 'decide', 4]); + }); + + test('L17 기부 천사: 20으로 가서 적립금 받기', () => { + const s = draw('L17', (s) => (s.fund = 45)); + expect([s.players.A!.pos, cash(s, 'A'), s.fund]).toEqual([20, 345, 0]); + }); + + test('L18 관광 안내: 앞쪽 가장 가까운 관광지 (7 → 경주 15, 33 → 부산 35)', () => { + expect(draw('L18').players.A!.pos).toBe(15); + expect(draw('L18', undefined, 31, [1, 1]).players.A!.pos).toBe(35); + }); + + test('L19 여행 경비: 다른 사람 모두에게 5씩', () => { + const s = draw('L19'); + expect([cash(s, 'A'), cash(s, 'B'), cash(s, 'C')]).toEqual([290, 305, 305]); + }); + + test('L20 재산세: 땅 1곳당 2', () => { + const s = draw('L20', (s) => { + own(s, 1, 'A', 3); + own(s, 5, 'A'); + own(s, 6, 'B'); + }); + expect(cash(s, 'A')).toBe(296); + }); + + test('카드는 뽑기 전에는 보이지 않고, 같은 차례 5번째 카드부터 카드 칸 효과 무시', () => { + let s = make(); + s.cardsThisTurn = 5; + s = roll(s, 1, 1); + expect([s.lastCard, s.deck.length, s.log.some((e) => e.type === 'cardIgnored')]).toEqual([null, 20, true]); + }); +}); + +// ---------------------------------------------------------------- fund + +describe('사회복지기금', () => { + test('38 도착 15 적립, 20 도착 시 전액 수령 후 0', () => { + let s = make(); + put(s, 'A', 35); + s = roll(s, 1, 2); + expect([s.fund, cash(s, 'A')]).toEqual([15, 285]); + put(s, 'B', 17); + s = roll(s, 1, 2); + expect([s.fund, cash(s, 'B')]).toEqual([0, 315]); + // Nothing collected: no event noise beyond the receipt of 0. + put(s, 'A', 17); + s = roll(s, 1, 2); + expect([s.fund, cash(s, 'A')]).toEqual([0, 285]); + }); +}); + +// ---------------------------------------------------------------- space travel + +describe('우주여행 (§5.5)', () => { + const ready = () => { + const s = make(); + put(s, 'A', 30); + s.players.A!.spacePending = true; + return s; + }; + + test('우주센터 타인 소유 → 탑승료 20, 30 → 5 이동 시 출발 통과 월급', () => { + let s = ready(); + own(s, 25, 'B'); + const v = marble.view(s, 'A'); + expect(v.spacePreview!.find((p) => p.dest === 5)).toEqual({ dest: 5, salary: true, owner: null, toll: 0, fare: 20 }); + expect(v.spacePreview!.find((p) => p.dest === 25)).toMatchObject({ salary: true, owner: 'B', toll: 15 }); + expect(v.spacePreview!.find((p) => p.dest === 39)).toMatchObject({ salary: false, owner: null }); + expect(v.spacePreview).toHaveLength(39); + s = act(s, 'A', { type: 'SPACE_TRAVEL', dest: 5 }); + expect([cash(s, 'A'), cash(s, 'B'), s.players.A!.pos, s.phase]).toEqual([300, 320, 5, 'decide']); + }); + + test('우주센터가 내 것이거나 은행 것이면 무료; 0에 도착해도 월급', () => { + let s = ready(); + own(s, 25, 'A'); + s = act(s, 'A', { type: 'SPACE_TRAVEL', dest: 0 }); + expect([cash(s, 'A'), s.players.A!.pos, curId(s)]).toEqual([320, 0, 'B']); + }); + + test('탑승료가 모자라면 빚 갚기 후 이동', () => { + let s = ready(); + own(s, 25, 'B'); + own(s, 39, 'A'); + setCash(s, 'A', 15); + s = act(s, 'A', { type: 'SPACE_TRAVEL', dest: 3 }); + s = act(s, 'A', { type: 'SELL', pos: 39, what: 'all' }); + expect([cash(s, 'A'), cash(s, 'B'), s.players.A!.pos, s.phase]).toEqual([35 - 20 + 20, 320, 3, 'decide']); + }); +}); + +// ---------------------------------------------------------------- options + +describe('인수·독점·라운드 제한 (§5.6, §5.7)', () => { + test('인수: 통행료 지불 후 인수가 = 2 × 투자액; 랜드마크·관광지 거부; 옵션 꺼짐이면 거부', () => { + let s = make({ takeover: true }); + own(s, 14, 'B', 2); // 16 + 8 + 16 = 40 → 80 + put(s, 'A', 11); + s = roll(s, 1, 2); + expect([cash(s, 'A'), s.phase, s.decision?.canTakeover]).toEqual([276, 'decide', 80]); + s = act(s, 'A', { type: 'TAKEOVER' }); + expect([s.owners[14], cash(s, 'A'), cash(s, 'B'), curId(s)]).toEqual([{ owner: 'A', level: 2 }, 196, 404, 'B']); + + s = make({ takeover: true, landmark: true }); + own(s, 14, 'B', 4); + put(s, 'A', 11); + s = roll(s, 1, 2); + expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); + + s = make({ takeover: true }); + own(s, 15, 'B'); + put(s, 'A', 12); + s = roll(s, 1, 2); + expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); + + s = make(); + own(s, 14, 'B', 2); + put(s, 'A', 11); + s = roll(s, 1, 2); + expect(curId(s)).toBe('B'); + s.current = 0; + s.phase = 'decide'; + s.decision = { pos: 14, canBuy: false, canBuild: null, canTakeover: 80, boughtThisVisit: false }; + expect(reject(s, 'A', { type: 'TAKEOVER' })).toBe('인수 규칙을 쓰지 않는 방이에요.'); + }); + + test('인수가가 모자라면 제안하지 않음(빚 갚기로 만들 수 없음)', () => { + let s = make({ takeover: true }); + own(s, 14, 'B', 2); + setCash(s, 'A', 90); + put(s, 'A', 11); + s = roll(s, 1, 2); + expect([cash(s, 'A'), s.phase, curId(s)]).toEqual([66, 'turnStart', 'B']); + }); + + test('독점 승리(옵션): 관광지 4곳 소유 순간 즉시 종료', () => { + let s = make({ monopolyWin: true }); + for (const p of [5, 15, 25]) own(s, p, 'A'); + put(s, 'A', 32); + s = roll(s, 1, 2); + s = act(s, 'A', { type: 'BUY' }); + expect([s.phase, s.final?.reason, s.final?.monopolyType, s.final?.winners]).toEqual(['finished', 'monopoly', 'tour', ['A']]); + expect(marble.result(s)!.ranking).toEqual([['A'], ['B']]); + }); + + test('독점 판정: 트리플(그룹 3개)·라인(한 줄 도시 6곳); 옵션이 꺼져 있으면 계속', () => { + let s = make({ monopolyWin: true }); + for (const p of [...GROUPS.A, ...GROUPS.B, 11, 13]) own(s, p, 'A'); + put(s, 'A', 11); + s = roll(s, 1, 2); // → 14 로마, completes group C + s = act(s, 'A', { type: 'BUY' }); + expect(s.final?.monopolyType).toBe('triple'); + s = make({ monopolyWin: true }); + for (const p of [31, 32, 34, 36, 37]) own(s, p, 'A'); + put(s, 'A', 36); + s = roll(s, 1, 2); + s = act(s, 'A', { type: 'BUY' }); + expect(s.final?.monopolyType).toBe('line'); + s = make(); + for (const p of [5, 15, 25]) own(s, p, 'A'); + put(s, 'A', 32); + s = roll(s, 1, 2); + s = act(s, 'A', { type: 'BUY' }); + expect(s.phase).not.toBe('finished'); + }); + + test('라운드 제한 20: 20라운드 마지막 사람 차례 후 총자산 순위, 동점이면 현금 비교', () => { + let s = make(); + s.round = 20; + s.current = 1; // B is the last seat + own(s, 39, 'A'); // A: 100 + 40 = 140 + setCash(s, 'A', 100); + setCash(s, 'B', 140); // B: 140 points, no land + put(s, 'B', 0); + s = roll(s, 1, 2); // B → 3 마닐라 + s = act(s, 'B', PASS); + expect([s.phase, s.final?.reason]).toEqual(['finished', 'roundLimit']); + expect(s.final!.ranking.map((r) => [r.rank, r.player, r.totalAssets, r.cash])).toEqual([ + [1, 'B', 140, 140], + [2, 'A', 140, 100], + ]); + expect(s.final!.summary).toBe('20라운드 끝! 총자산 140P로 1등'); + }); + + test('완전히 같으면 공동 순위; 파산자는 생존자보다 아래(늦게 파산한 사람이 위)', () => { + let s = make({ maxRounds: 15 }, 4); + s.round = 15; + s.current = 3; + s.players.B!.alive = false; + s.players.B!.outSeq = 1; + s.players.B!.cash = 0; + s.players.C!.alive = false; + s.players.C!.outSeq = 2; + s.players.C!.cash = 0; + s = roll(s, 1, 2); + s = act(s, 'D', PASS); + expect(marble.result(s)!.ranking).toEqual([['A', 'D'], ['C'], ['B']]); + expect(s.final!.summary).toContain('공동 1등'); + }); + + test('라운드: 선이 파산하면 다음 생존자 차례 뒤에 라운드가 넘어감', () => { + let s = make({}, 3); + s.players.A!.alive = false; + s.players.A!.outSeq = 1; + s.current = 1; + s = roll(s, 1, 2); + s = act(s, 'B', PASS); + expect([curId(s), s.round]).toEqual(['C', 1]); + s = roll(s, 1, 2); + s = act(s, 'C', PASS); + expect([curId(s), s.round]).toEqual(['B', 2]); + }); +}); + +// ---------------------------------------------------------------- trade + +describe('교환 (§5.8, 옵션)', () => { + const offer = (to: string, give: number[], gc: number, take: number[], tc: number): MarbleAction => ({ + type: 'PROPOSE_TRADE', + to, + give: { lands: give, cash: gc }, + take: { lands: take, cash: tc }, + }); + + test('옵션 꺼짐이면 거부; 제안 → 상대 미응답 20초 → 거절', () => { + let s = make(); + own(s, 1, 'A'); + expect(reject(s, 'A', offer('B', [1], 0, [], 10))).toBe('교환 규칙을 쓰지 않는 방이에요.'); + s = make({ trade: true }); + own(s, 1, 'A'); + own(s, 3, 'B', 1); + expect(reject(s, 'A', offer('B', [3], 0, [], 0))).toBe('내 땅만 줄 수 있어요.'); + expect(reject(s, 'A', offer('B', [], 0, [1], 0))).toBe('그 사람의 땅만 받을 수 있어요.'); + expect(reject(s, 'A', offer('B', [], 400, [3], 0))).toBe('가진 포인트보다 많이 줄 수 없어요.'); + expect(reject(s, 'A', offer('B', [], 0, [], 0))).toBe('주거나 받을 것을 하나 이상 골라 주세요.'); + expect(reject(s, 'A', offer('A', [1], 0, [], 0))).toBe('그 사람에게는 제안할 수 없어요.'); + const { state, events } = actE(s, 'A', offer('B', [1], 10, [3], 0)); + s = state; + expect([s.phase, marble.activePlayers(s), s.deadlineAt! - clock]).toEqual(['tradeResponse', ['B'], 20_000]); + expect(events[0]).toMatchObject({ type: 'tradeProposed', from: 'A', to: 'B' }); + expect(marble.onTimeout(s, 'B')).toEqual({ type: 'TIMEOUT' }); + expect(autoAction(s, 'B')).toEqual({ type: 'TRADE_RESPOND', accept: false }); + s = act(s, 'B', { type: 'TIMEOUT' }); + expect([s.phase, curId(s), s.owners[1]!.owner, s.owners[3]!.owner]).toEqual(['turnStart', 'A', 'A', 'B']); + }); + + test('수락: 땅(건물째)과 포인트가 오감; 한 차례 2번까지', () => { + let s = make({ trade: true }); + own(s, 1, 'A'); + own(s, 3, 'B', 1); + s = act(s, 'A', offer('B', [1], 10, [3], 0)); + s = act(s, 'B', { type: 'TRADE_RESPOND', accept: true }); + expect([s.owners[1], s.owners[3], cash(s, 'A'), cash(s, 'B')]).toEqual([{ owner: 'B', level: 0 }, { owner: 'A', level: 1 }, 290, 310]); + s = act(s, 'A', offer('B', [], 5, [1], 0)); + s = act(s, 'B', { type: 'TRADE_RESPOND', accept: false }); + expect(reject(s, 'A', offer('B', [], 5, [1], 0))).toBe('이번 차례에는 더 제안할 수 없어요(2번까지).'); + expect(reject(s, 'B', { type: 'TRADE_RESPOND', accept: true })).toBe('답할 교환 제안이 없어요.'); + }); + + test('수락 시점에 현금이 모자라면 실패 처리', () => { + let s = make({ trade: true }); + own(s, 3, 'B'); + s = act(s, 'A', offer('B', [], 100, [3], 0)); + s.players.A!.cash = 50; // e.g. changed meanwhile + s = act(s, 'B', { type: 'TRADE_RESPOND', accept: true }); + expect([s.owners[3]!.owner, cash(s, 'A'), cash(s, 'B')]).toEqual(['B', 50, 300]); + expect(s.log.at(-1)).toMatchObject({ type: 'tradeResolved', accepted: false, failed: true }); + }); +}); + +// ---------------------------------------------------------------- timeout, leaving + +describe('시간 초과·연결 끊김 (§8.5)', () => { + test('turnStart → ROLL, 우주여행 → 출발로, decide → PASS(포인트를 쓰지 않음)', () => { + let s = make(); + expect(autoAction(s, 'A')).toEqual({ type: 'ROLL' }); + s = act(s, 'A', { type: 'TIMEOUT' }, dice(1, 2)); + expect([s.players.A!.pos, s.phase]).toEqual([3, 'decide']); + s = act(s, 'A', { type: 'TIMEOUT' }); + expect([cash(s, 'A'), s.owners[3], curId(s)]).toEqual([300, undefined, 'B']); + const t = make(); + put(t, 'A', 30); + t.players.A!.spacePending = true; + expect(autoAction(t, 'A')).toEqual({ type: 'SPACE_TRAVEL', dest: 0 }); + }); + + test('debt → 매각가가 작은 건물부터, 건물이 없으면 땅값 작은 땅부터', () => { + let s = make(); + own(s, 39, 'B', 3); + own(s, 36, 'A', 1); // villa sale 9 + own(s, 3, 'A', 2); // building sale 3 + own(s, 1, 'A'); // land 6 → 3 + own(s, 5, 'A'); // 20 → 10 + setCash(s, 'A', 90); + put(s, 'A', 36); + s = roll(s, 1, 2); + expect(s.phase).toBe('debt'); + const sold: string[] = []; + while (s.phase === 'debt') { + const a = autoAction(s, 'A') as { pos: number; what: string }; + sold.push(`${a.pos}:${a.what}`); + s = act(s, 'A', { type: 'TIMEOUT' }); + } + expect(sold).toEqual(['3:top', '3:top', '36:top']); + expect(cash(s, 'A')).toBe(90 + 3 + 1 + 9 - 100); + }); + + test('세 번 연속 시간 초과 → 자리 비움(5초); 끊긴 사람의 차례도 자동으로 진행되고 통행료는 정상 수령', () => { + let s = make(); + own(s, 3, 'B', 2); + expect(s.deadlineAt).toBe(30_000); + s.current = 1; + for (let i = 0; i < 3; i++) { + put(s, 'B', 20); + s = act(s, 'B', { type: 'TIMEOUT' }, dice(1, 2)); // B → 23, decide + if (s.phase === 'decide') s = act(s, 'B', { type: 'TIMEOUT' }); + s.current = 1; + } + expect(marble.view(s, 'A').players.find((p) => p.id === 'B')!.away).toBe(true); + s.current = 0; + s = roll(s, 1, 2); // A → 3, pays B + expect(cash(s, 'B')).toBe(309); + expect(curId(s)).toBe('B'); + expect(s.deadlineAt! - clock).toBe(5000); + s = act(s, 'B', { type: 'ROLL' }, dice(1, 2)); + expect(s.timeouts.B).toBe(0); + }); + + test('onLeave = CONCEDE: 땅은 은행으로, 차례 넘김; 빚 갚는 중이면 그 빚은 먼저 갚음', () => { + let s = make({}, 3); + own(s, 1, 'A'); + expect(marble.onLeave!(s, 'A')).toEqual({ type: 'CONCEDE' }); + s = act(s, 'A', { type: 'CONCEDE' }); + expect([s.players.A!.alive, s.owners[1], curId(s), s.ledger.burned]).toEqual([false, undefined, 'B', 303]); + expect(marble.onLeave!(s, 'A')).toBeNull(); + expect(reject(s, 'A', { type: 'ROLL' })).toBe('이미 게임에서 빠졌어요.'); + + s = make({}, 3); + own(s, 39, 'B', 3); + own(s, 36, 'A', 3); + setCash(s, 'A', 50); + put(s, 'A', 36); + s = roll(s, 1, 2); + expect(s.phase).toBe('debt'); + s = act(s, 'A', { type: 'CONCEDE' }); + expect([cash(s, 'B'), s.players.A!.alive, curId(s)]).toEqual([400, false, 'B']); + }); + + test('다른 사람이 기권해도 진행 중인 차례는 그대로; 마지막 1명이 남으면 끝(기권승)', () => { + let s = make({}, 3); + s = roll(s, 1, 2); + s = act(s, 'C', { type: 'CONCEDE' }); + expect([s.phase, curId(s)]).toEqual(['decide', 'A']); + s = act(s, 'B', { type: 'CONCEDE' }); + expect(s.phase).toBe('finished'); + const r = marble.result(s)!; + expect([r.ranking, r.reason]).toEqual([[['A'], ['B'], ['C']], 'resign']); + }); +}); + +// ---------------------------------------------------------------- view / leaks + +describe('정보 공개 (§8.3)', () => { + test('view에 카드 더미 순서와 RNG가 없음, 남은 장 수만; 뽑힌 카드만 ID 공개', () => { + let s = marble.setup({ players: ['A', 'B'], options: marble.defaultOptions, rng: SeededRng.fromSeed(seedN(9)), now: 0 }); + for (const viewer of ['A', 'B', null]) { + const v = marble.view(s, viewer); + const json = JSON.stringify(v); + expect(v.deckCount).toBe(20); + expect('deck' in v).toBe(false); + expect(json).not.toMatch(/"L\d\d"/); + expect(json).not.toMatch(/rng|seed/i); + } + s.order = ['A', 'B']; + s.current = 0; + s.deck = [...CARD_IDS]; + put(s, 'A', 5); + s = roll(s, 1, 1); // → 7 card L01 + const json = JSON.stringify(marble.view(s, 'B')); + expect(json).toContain('"L01"'); + expect(json).not.toContain('"L02"'); + expect(marble.secretsOf!(s, 'A')).not.toContain('L01'); + expect(marble.secretsOf!(s, 'A')).toHaveLength(19); + }); + + test('my: 지금 할 수 있는 일과 비용은 본인에게만', () => { + let s = make(); + s = roll(s, 1, 2); + const va = marble.view(s, 'A'); + expect(va.my!.decide).toEqual({ pos: 3, buy: 6, build: null, takeover: null, boughtThisVisit: false }); + expect(marble.view(s, 'B').my!.decide).toBeNull(); + expect(marble.view(s, null).my).toBeNull(); + expect(va.players.map((p) => p.seat)).toEqual([0, 1]); + }); +}); + +// ---------------------------------------------------------------- random playouts + +const CONFIGS: { n: number; opts: Partial }[] = [ + { n: 2, opts: {} }, + { n: 3, opts: { startMoney: 200, maxRounds: 15 } }, + { n: 4, opts: { takeover: true, landmark: true, monopolyWin: true } }, + { n: 2, opts: { buildOnPurchase: true, islandFee: 10, islandTurns: 1, salary: 10, startMoney: 200 } }, + { n: 3, opts: { trade: true, maxRounds: 30, turnSeconds: null } }, + { n: 4, opts: { startMoney: 200, takeover: true, trade: true, islandFee: 20, maxRounds: 15 } }, + { n: 2, opts: { maxRounds: 0, startMoney: 200, takeover: true, landmark: true } }, + { n: 2, opts: { monopolyWin: true, startMoney: 500, maxRounds: 30 } }, +]; + +function invariant(s: MarbleState): void { + const n = s.order.length; + const total = Object.values(s.players).reduce((a, p) => a + p.cash, 0) + s.fund; + if (total !== s.options.startMoney * n + s.ledger.minted - s.ledger.burned) throw new Error('points bookkeeping broken'); + for (const [id, p] of Object.entries(s.players)) { + if (!Number.isInteger(p.cash) || p.cash < 0) throw new Error(`bad cash for ${id}: ${p.cash}`); + if (!p.alive && (p.cash !== 0 || p.heldCards.length || p.islandLeft || p.spacePending)) throw new Error(`out player ${id} still holds something`); + if (p.pos < 0 || p.pos > 39) throw new Error('bad pos'); + if (p.islandLeft > 0 && p.pos !== 10) throw new Error('island state off the island'); + if (p.spacePending && p.pos !== 30) throw new Error('space state off the space square'); + } + for (const [k, o] of Object.entries(s.owners)) { + const pos = Number(k); + if (!PROPERTY_POSITIONS.includes(pos)) throw new Error(`owner on non-property ${pos}`); + if (!s.players[o.owner]?.alive) throw new Error(`land ${pos} owned by an out player`); + if (o.level < 0 || o.level > maxLevel(s.options)) throw new Error('bad level'); + if (SPACES[pos]!.kind === 'tour' && o.level !== 0) throw new Error('building on a tourist spot'); + } + const cards = [...s.deck, ...Object.values(s.players).flatMap((p) => p.heldCards)].sort(); + if (JSON.stringify(cards) !== JSON.stringify(CARD_IDS)) throw new Error('card count broken'); + if (s.phase === 'finished') { + if (!s.final || s.deadlineAt !== null) throw new Error('finished without result'); + return; + } + if (!s.players[curId(s)]!.alive) throw new Error('turn on an out player'); + if (s.options.maxRounds > 0 && s.round > s.options.maxRounds) throw new Error('round limit exceeded'); + if (s.phase === 'decide' && !s.decision) throw new Error('decide without decision'); + if (s.phase === 'debt' && (!s.debt || !s.players[s.debt.debtor]!.alive || s.players[s.debt.debtor]!.cash >= s.debt.amount)) throw new Error('bad debt phase'); + if (s.phase === 'tradeResponse' && !s.pendingTrade) throw new Error('trade phase without trade'); + if (s.phase !== 'debt' && s.debt) throw new Error('stale debt'); + for (const p of marble.timeoutPlayers!(s)) { + if (!marble.validate(s, p, marble.onTimeout(s, p)).ok || !marble.validate(s, p, autoAction(s, p)).ok) throw new Error(`onTimeout illegal in ${s.phase}`); + if (marble.legalActions!(s, p).length === 0) throw new Error(`no legal action in ${s.phase}`); + } +} + +describe('random playouts', () => { + test('1,000 seeds (fast loop): invariants, onTimeout legal everywhere, games end, results rank everyone', () => { + let steps = 0; + const reasons: Record = {}; + for (let i = 1; i <= 1000; i++) { + const cfg = CONFIGS[i % CONFIGS.length]!; + const options = marble.optionsSchema.parse(cfg.opts); + const players = PLAYERS.slice(0, cfg.n); + const rng = SeededRng.fromSeed(seedN(i)); + const chooser = SeededRng.fromSeed(seedN(i + 77_777)); + let s = marble.setup({ players, options, rng, now: 0 }); + let k = 0; + for (; s.phase !== 'finished' && k < 6000; k++) { + const actor = marble.activePlayers(s)[0]!; + const legal = marble.legalActions!(s, actor); + let a: MarbleAction; + const r = chooser.next(); + if (r < 0.0015) { + // Now and then somebody concedes (any player), or the timer fires. + const q = players[chooser.int(players.length)]!; + if (marble.validate(s, q, { type: 'CONCEDE' }).ok) { + s = marble.apply(s, q, { type: 'CONCEDE' }, { rng, now: k * 1000 }).state; + invariant(s); + continue; + } + a = { type: 'TIMEOUT' }; + } else if (r < 0.05) a = { type: 'TIMEOUT' }; + else a = legal[chooser.int(legal.length)]!; + const v = marble.validate(s, actor, a); + if (!v.ok) throw new Error(`seed ${i}: ${v.reason} ${JSON.stringify(a)}`); + s = marble.apply(s, actor, a, { rng, now: k * 1000 }).state; + invariant(s); + } + if (s.phase !== 'finished') throw new Error(`seed ${i} did not finish`); + steps += k; + reasons[s.final!.reason] = (reasons[s.final!.reason] ?? 0) + 1; + const res = marble.result(s)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + } + expect(steps).toBeGreaterThan(1000 * 50); + // Every way of ending shows up. + expect(Object.keys(reasons).sort()).toEqual(['lastSurvivor', 'monopoly', 'roundLimit']); + }, 60_000); + + test('engine randomPlayout (round trip, leak check, 5 ms apply), deterministic and replayable', () => { + for (let i = 1; i <= 40; i++) { + const cfg = CONFIGS[i % CONFIGS.length]!; + const options = marble.optionsSchema.parse(cfg.opts); + const players = PLAYERS.slice(0, cfg.n); + const a = randomPlayout(marble, { players, options, seed: seedN(i), maxSteps: 6000, invariant: (x) => invariant(x as MarbleState), maxApplyMs: 30 }); + if (!a.finished) throw new Error(`seed ${i} did not finish`); + if (i % 8 === 0) { + const b = randomPlayout(marble, { players, options, seed: seedN(i), maxSteps: 6000, maxApplyMs: 30 }); + expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState)); + const r = SeededRng.fromSeed(seedN(i)); + let now = 1_700_000_000_000; + let s = marble.setup({ players, options, rng: r, now }); + for (const { actor, action } of a.trace) { + now += 1000; + s = marble.apply(s, actor, action as MarbleAction, { rng: r, now }).state; + } + expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState)); + } + } + }, 60_000); + + test('timeout-only games finish (default 20 rounds) and apply is fast (p99 < 5 ms)', () => { + const times: number[] = []; + for (let i = 1; i <= 60; i++) { + const rng = SeededRng.fromSeed(seedN(i + 9000)); + let s = marble.setup({ players: PLAYERS, options: marble.defaultOptions, rng, now: 0 }); + let n = 0; + for (; s.phase !== 'finished' && n < 5000; n++) { + const p = marble.timeoutPlayers!(s)[0]!; + const t0 = performance.now(); + s = marble.apply(s, p, marble.onTimeout(s, p), { rng, now: n }).state; + times.push(performance.now() - t0); + } + expect(s.phase).toBe('finished'); + // Timeouts never buy: nobody owns land. + expect(Object.keys(s.owners)).toHaveLength(0); + } + times.sort((a, b) => a - b); + expect(times[Math.floor(times.length * 0.99)]!).toBeLessThan(5); + }, 60_000); +}); diff --git a/packages/games/src/marble/rules.ts b/packages/games/src/marble/rules.ts new file mode 100644 index 0000000..bb9d576 --- /dev/null +++ b/packages/games/src/marble/rules.ts @@ -0,0 +1,945 @@ +/** + * 주사위 땅따먹기 상태 기계 (docs/games/marble.md §5, §8). Functions here mutate a state that + * `apply` has already cloned. The RNG is only used for dice (ROLL) — the deck is shuffled once in + * setup and never again (cards go back to the bottom). + */ +import { z } from 'zod'; +import { nextSeat, type GameEvent, type PlayerId, type Rng } from '@bg/engine'; +import { + BIRTHDAY_GIFT, + CARD_CAP, + FUND_PAYMENT, + GROUPS, + ISLAND, + KEEP_CARDS, + LINES, + PROPERTY_POSITIONS, + PROPERTY_TAX, + REPAIR_PER_STAGE, + SEOUL, + SPACES, + SPACE_CENTER, + SPACE_FARE, + SPACE_TRAVEL, + START, + FUND_IN, + TOUR_POSITIONS, + TRAVEL_GIFT, + baseToll, + forwardSteps, + fullSale, + investment, + isCity, + stageCost, + stageSale, + takeoverPrice, + type CardId, + type Level, +} from './board'; + +// ---------------------------------------------------------------- options & actions + +export const marbleOptionsSchema = z.object({ + startMoney: z + .union([z.literal(200), z.literal(300), z.literal(500)]) + .default(300) + .meta({ title: '시작 포인트', labels: { 200: '200P', 300: '300P', 500: '500P' } }), + salary: z + .union([z.literal(10), z.literal(20), z.literal(30)]) + .default(20) + .meta({ title: '월급(출발 칸을 지나거나 도착할 때)', labels: { 10: '10P', 20: '20P', 30: '30P' } }), + maxRounds: z + .union([z.literal(0), z.literal(15), z.literal(20), z.literal(30)]) + .default(20) + .meta({ title: '라운드 제한', labels: { 0: '무제한(파산으로만 끝)', 15: '15라운드', 20: '20라운드', 30: '30라운드' } }), + buildOnPurchase: z.boolean().default(false).meta({ title: '땅을 산 그 자리에서 별장까지 바로 짓기' }), + islandTurns: z + .union([z.literal(1), z.literal(2), z.literal(3)]) + .default(3) + .meta({ title: '무인도에서 최대 쉬는 차례', labels: { 1: '1번', 2: '2번', 3: '3번' } }), + islandFee: z + .union([z.literal(0), z.literal(10), z.literal(20)]) + .default(0) + .meta({ title: '무인도 탈출비(내고 바로 나가기)', labels: { 0: '없음(낼 수 없음)', 10: '10P', 20: '20P' } }), + takeover: z.boolean().default(false).meta({ title: '인수(통행료를 낸 뒤 2배 값에 사들이기)' }), + landmark: z.boolean().default(false).meta({ title: '랜드마크(호텔 다음 단계)' }), + monopolyWin: z.boolean().default(false).meta({ title: '독점 즉시 승리(트리플·라인·관광지)' }), + trade: z.boolean().default(false).meta({ title: '플레이어끼리 땅·포인트 교환' }), + turnSeconds: z + .union([z.literal(15), z.literal(30), z.literal(60), z.literal(null)]) + .default(30) + .meta({ title: '결정 1번 제한 시간', labels: { 15: '15초', 30: '30초', 60: '60초', null: '무제한' } }), +}); +export type MarbleOptions = z.infer; + +const posSchema = z.number().int().min(0).max(39); +const offerSchema = z.object({ lands: z.array(posSchema).max(28), cash: z.number().int().min(0).max(1_000_000) }); +export const marbleActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('ROLL') }), + z.object({ type: z.literal('PAY_ISLAND_FEE') }), + z.object({ type: z.literal('USE_ESCAPE_CARD') }), + z.object({ type: z.literal('SPACE_TRAVEL'), dest: posSchema }), + z.object({ type: z.literal('BUY') }), + z.object({ type: z.literal('BUILD'), level: z.number().int().min(1).max(4) }), + z.object({ type: z.literal('TAKEOVER') }), + z.object({ type: z.literal('PASS') }), + z.object({ type: z.literal('SELL'), pos: posSchema, what: z.enum(['top', 'all']) }), + z.object({ type: z.literal('PROPOSE_TRADE'), to: z.string().min(1).max(64), give: offerSchema, take: offerSchema }), + z.object({ type: z.literal('TRADE_RESPOND'), accept: z.boolean() }), + z.object({ type: z.literal('CONCEDE') }), +]); +export type MarbleClientAction = z.infer; +/** Client actions plus the system-only timer action (not in the schema, docs/09 §2). */ +export type MarbleAction = MarbleClientAction | { type: 'TIMEOUT' }; +export type TradeOffer = { lands: number[]; cash: number }; + +// ---------------------------------------------------------------- state + +export type Creditor = { kind: 'player'; id: PlayerId } | { kind: 'bank' } | { kind: 'fund' }; +export type DebtReason = 'toll' | 'card' | 'fund' | 'fare'; +export interface Debt { + debtor: PlayerId; + amount: number; + to: Creditor; + reason: DebtReason; +} +/** What happens once every queued payment is settled. */ +export type Cont = { k: 'land' } | { k: 'toll'; pos: number } | { k: 'space'; dest: number }; + +export interface MarblePlayer { + pos: number; + cash: number; + alive: boolean; + bankruptAtRound: number | null; + /** 1 = first to go bankrupt / concede. */ + outSeq: number | null; + /** 0 = not on the island. */ + islandLeft: number; + spacePending: boolean; + heldCards: CardId[]; + /** Left by CONCEDE (not by bankruptcy). */ + conceded: boolean; +} + +export interface Owned { + owner: PlayerId; + level: Level; +} + +export interface Decision { + pos: number; + canBuy: boolean; + canBuild: Level | null; + /** 인수가, when a takeover is offered. */ + canTakeover: number | null; + boughtThisVisit: boolean; +} + +export interface DebtState extends Debt { + queue: Debt[]; + then: Cont; +} + +export interface Trade { + from: PlayerId; + to: PlayerId; + give: TradeOffer; + take: TradeOffer; +} + +export type MoveKind = 'roll' | 'card' | 'back' | 'space' | 'warp'; + +export interface MarbleRankEntry { + rank: number; + player: PlayerId; + totalAssets: number; + cash: number; + lands: number; + bankruptAtRound: number | null; +} + +export interface MarbleResult { + reason: 'lastSurvivor' | 'roundLimit' | 'monopoly'; + monopolyType?: 'triple' | 'line' | 'tour'; + ranking: MarbleRankEntry[]; + winners: PlayerId[]; + summary: string; +} + +export type Phase = 'turnStart' | 'decide' | 'debt' | 'tradeResponse' | 'finished'; + +export interface MarbleState { + options: MarbleOptions; + order: PlayerId[]; + current: number; + round: number; + phase: Phase; + players: Record; + /** Cities and tourist spots that have an owner, keyed by position. */ + owners: Record; + fund: number; + /** Hidden: index 0 = top. */ + deck: CardId[]; + /** Cards drawn at least once (their IDs are public from then on). */ + revealed: CardId[]; + doublesThisTurn: number; + cardsThisTurn: number; + decision: Decision | null; + debt: DebtState | null; + pendingTrade: Trade | null; + tradesThisTurn: number; + /** Doubles: the same player rolls again from turnStart. */ + extraRoll: boolean; + lastRoll: { seq: number; player: PlayerId; dice: [number, number]; double: boolean; island: boolean } | null; + lastMove: { seq: number; player: PlayerId; from: number; to: number; kind: MoveKind; passedStart: boolean } | null; + lastCard: { seq: number; player: PlayerId; id: CardId } | null; + /** Increases with every roll / move / card (animation keys). */ + seq: number; + turnSeq: number; + deadlineAt: number | null; + timeouts: Record; + log: GameEvent[]; + outCount: number; + /** Bank flows (points created / destroyed), for the bookkeeping invariant. */ + ledger: { minted: number; burned: number }; + final: MarbleResult | null; +} + +// ---------------------------------------------------------------- small helpers + +const LOG_MAX = 60; +export const AWAY_AFTER = 3; +export const AWAY_MS = 5000; +export const TRADE_MS = 20_000; +export const MAX_TRADES_PER_TURN = 2; + +export const curId = (s: MarbleState): PlayerId => s.order[s.current]!; +export const pl = (s: MarbleState, id: PlayerId): MarblePlayer => s.players[id]!; +export const aliveIds = (s: MarbleState): PlayerId[] => s.order.filter((p) => s.players[p]!.alive); +export const maxLevel = (o: MarbleOptions): Level => (o.landmark ? 4 : 3); + +export function pushLog(s: MarbleState, ev: GameEvent[], e: GameEvent): void { + ev.push(e); + s.log.push(e); + if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX); +} + +export const ownedBy = (s: MarbleState, id: PlayerId): number[] => + PROPERTY_POSITIONS.filter((p) => s.owners[p]?.owner === id); + +/** Toll to pay on `pos` right now (tourist spots double when the owner has 2+). */ +export function tollAt(s: MarbleState, pos: number): number { + const o = s.owners[pos]; + if (!o) return 0; + const base = baseToll(pos, o.level); + if (SPACES[pos]!.kind === 'tour') { + const n = TOUR_POSITIONS.filter((p) => s.owners[p]?.owner === o.owner).length; + return n >= 2 ? base * 2 : base; + } + return base; +} + +/** 현금 + 모든 건물·땅 매각가 합 (§5.3). */ +export const raisable = (s: MarbleState, id: PlayerId): number => + pl(s, id).cash + ownedBy(s, id).reduce((a, p) => a + fullSale(p, s.owners[p]!.level), 0); + +/** 총자산 = 현금 + Σ(땅값 + 건설비, 정가) (§6). */ +export const totalAssets = (s: MarbleState, id: PlayerId): number => + pl(s, id).cash + ownedBy(s, id).reduce((a, p) => a + investment(p, s.owners[p]!.level), 0); + +export function canTakeoverAt(s: MarbleState, pos: number, by: PlayerId): number | null { + const o = s.owners[pos]; + if (!s.options.takeover || !o || !isCity(pos) || o.level >= 4 || o.owner === by || !pl(s, o.owner).alive) return null; + return takeoverPrice(pos, o.level); +} + +/** Bank → player. */ +function mint(s: MarbleState, id: PlayerId, amount: number): void { + pl(s, id).cash += amount; + s.ledger.minted += amount; +} + +/** Player → creditor (a gone player creditor counts as the bank). */ +function transfer(s: MarbleState, from: PlayerId, to: Creditor, amount: number): void { + const p = pl(s, from); + if (amount > p.cash) throw new Error(`marble: ${from} cannot pay ${amount} with ${p.cash}`); + p.cash -= amount; + if (to.kind === 'player' && s.players[to.id]?.alive) s.players[to.id]!.cash += amount; + else if (to.kind === 'fund') s.fund += amount; + else s.ledger.burned += amount; +} + +const bankC: Creditor = { kind: 'bank' }; +const playerC = (id: PlayerId): Creditor => ({ kind: 'player', id }); + +function deckBottom(s: MarbleState, id: CardId): void { + s.deck.push(id); +} + +// ---------------------------------------------------------------- movement + +function salary(s: MarbleState, id: PlayerId, ev: GameEvent[]): void { + mint(s, id, s.options.salary); + pushLog(s, ev, { type: 'salary', player: id, amount: s.options.salary }); +} + +/** Clockwise move; passing or reaching 출발 pays the salary once. */ +function moveForward(s: MarbleState, id: PlayerId, steps: number, kind: MoveKind, ev: GameEvent[]): void { + const p = pl(s, id); + const from = p.pos; + const passedStart = steps > 0 && from + steps >= 40; + p.pos = (from + steps) % 40; + s.lastMove = { seq: ++s.seq, player: id, from, to: p.pos, kind, passedStart }; + pushLog(s, ev, { type: 'moved', player: id, from, to: p.pos, kind, passedStart }); + if (passedStart) salary(s, id, ev); +} + +function moveBack(s: MarbleState, id: PlayerId, steps: number, ev: GameEvent[]): void { + const p = pl(s, id); + const from = p.pos; + p.pos = (from - steps + 40) % 40; + s.lastMove = { seq: ++s.seq, player: id, from, to: p.pos, kind: 'back', passedStart: false }; + pushLog(s, ev, { type: 'moved', player: id, from, to: p.pos, kind: 'back', passedStart: false }); +} + +function sendToIsland(s: MarbleState, id: PlayerId, why: 'landed' | 'doubles' | 'card', ev: GameEvent[]): void { + const p = pl(s, id); + const from = p.pos; + p.pos = ISLAND; + p.islandLeft = s.options.islandTurns; + s.extraRoll = false; + s.doublesThisTurn = 0; + if (from !== ISLAND) s.lastMove = { seq: ++s.seq, player: id, from, to: ISLAND, kind: 'warp', passedStart: false }; + pushLog(s, ev, { type: 'toIsland', player: id, why, turns: p.islandLeft }); +} + +// ---------------------------------------------------------------- turn flow + +/** Landing resolved (and every decision taken): roll again on doubles, else the turn ends. */ +function afterLanding(s: MarbleState, ev: GameEvent[]): void { + s.decision = null; + if (s.phase === 'finished') return; + if (!pl(s, curId(s)).alive) return endTurn(s, ev); + if (s.extraRoll) { + s.phase = 'turnStart'; + pushLog(s, ev, { type: 'extraRoll', player: curId(s) }); + return; + } + endTurn(s, ev); +} + +export function endTurn(s: MarbleState, ev: GameEvent[]): void { + s.decision = null; + s.debt = null; + s.pendingTrade = null; + s.extraRoll = false; + s.doublesThisTurn = 0; + s.cardsThisTurn = 0; + s.tradesThisTurn = 0; + if (s.phase === 'finished') return; + if (aliveIds(s).length <= 1) return finish(s, 'lastSurvivor', ev); + const from = s.current; + const next = nextSeat(from, s.order.length, (i) => !s.players[s.order[i]!]!.alive); + if (next <= from) { + pushLog(s, ev, { type: 'roundEnd', round: s.round }); + if (s.options.maxRounds > 0 && s.round >= s.options.maxRounds) return finish(s, 'roundLimit', ev); + s.round += 1; + } + s.current = next; + s.phase = 'turnStart'; + pushLog(s, ev, { type: 'turn', player: curId(s), round: s.round }); +} + +/** Arrival on the current player's square (§5.2). */ +function land(s: MarbleState, ev: GameEvent[]): void { + const id = curId(s); + const p = pl(s, id); + const sp = SPACES[p.pos]!; + switch (sp.kind) { + case 'start': + return afterLanding(s, ev); + case 'city': + case 'tour': { + const o = s.owners[p.pos]; + if (!o) return offerBuy(s, ev); + if (o.owner === id) return offerBuild(s, ev, false); + return payToll(s, ev); + } + case 'card': + if (s.cardsThisTurn >= CARD_CAP) { + pushLog(s, ev, { type: 'cardIgnored', player: id }); + return afterLanding(s, ev); + } + return drawCard(s, ev); + case 'fundOut': + return pay(s, [{ debtor: id, amount: FUND_PAYMENT, to: { kind: 'fund' }, reason: 'fund' }], { k: 'land' }, ev); + case 'fundIn': { + const amount = s.fund; + if (amount > 0) { + s.fund = 0; + p.cash += amount; + } + pushLog(s, ev, { type: 'fundReceived', player: id, amount }); + return afterLanding(s, ev); + } + case 'island': + sendToIsland(s, id, 'landed', ev); + return endTurn(s, ev); + case 'space': + p.spacePending = true; + s.extraRoll = false; + pushLog(s, ev, { type: 'spaceWait', player: id }); + return endTurn(s, ev); + } +} + +function offerBuy(s: MarbleState, ev: GameEvent[]): void { + const id = curId(s); + const pos = pl(s, id).pos; + const price = SPACES[pos]!.price!; + if (pl(s, id).cash < price) { + pushLog(s, ev, { type: 'cannotAfford', player: id, pos, what: 'buy', cost: price }); + return afterLanding(s, ev); + } + s.decision = { pos, canBuy: true, canBuild: null, canTakeover: null, boughtThisVisit: false }; + s.phase = 'decide'; +} + +/** Own square: offer the next building stage (one per visit). */ +function offerBuild(s: MarbleState, ev: GameEvent[], justBought: boolean): void { + const id = curId(s); + const pos = pl(s, id).pos; + const o = s.owners[pos]!; + if (!isCity(pos) || o.level >= maxLevel(s.options) || (justBought && (!s.options.buildOnPurchase || o.level !== 0))) return afterLanding(s, ev); + const next = (o.level + 1) as Level; + const cost = stageCost(pos, next); + if (pl(s, id).cash < cost) { + if (!justBought) pushLog(s, ev, { type: 'cannotAfford', player: id, pos, what: 'build', cost }); + return afterLanding(s, ev); + } + s.decision = { pos, canBuy: false, canBuild: next, canTakeover: null, boughtThisVisit: justBought }; + s.phase = 'decide'; +} + +function payToll(s: MarbleState, ev: GameEvent[]): void { + const id = curId(s); + const p = pl(s, id); + const pos = p.pos; + const owner = s.owners[pos]!.owner; + const amount = tollAt(s, pos); + const waiver = p.heldCards.indexOf('L12'); + if (waiver >= 0) { + p.heldCards.splice(waiver, 1); + deckBottom(s, 'L12'); + pushLog(s, ev, { type: 'tollPaid', from: id, to: owner, pos, amount, waived: true }); + return contToll(s, pos, ev); + } + pay(s, [{ debtor: id, amount, to: playerC(owner), reason: 'toll' }], { k: 'toll', pos }, ev); +} + +/** After the toll: offer a takeover when the option allows it (§5.7). */ +function contToll(s: MarbleState, pos: number, ev: GameEvent[]): void { + const id = curId(s); + const price = canTakeoverAt(s, pos, id); + if (price !== null && pl(s, id).cash >= price) { + s.decision = { pos, canBuy: false, canBuild: null, canTakeover: price, boughtThisVisit: false }; + s.phase = 'decide'; + return; + } + afterLanding(s, ev); +} + +function runCont(s: MarbleState, then: Cont, ev: GameEvent[]): void { + if (s.phase === 'finished') return; + if (!pl(s, curId(s)).alive) return endTurn(s, ev); + if (then.k === 'land') return afterLanding(s, ev); + if (then.k === 'toll') return contToll(s, then.pos, ev); + return fly(s, then.dest, ev); +} + +/** Settles payments in order; stops in the `debt` phase when someone has to sell first (§5.3). */ +function pay(s: MarbleState, debts: Debt[], then: Cont, ev: GameEvent[]): void { + s.debt = null; + runQueue(s, [...debts], then, ev); +} + +function runQueue(s: MarbleState, queue: Debt[], then: Cont, ev: GameEvent[]): void { + while (queue.length > 0) { + const d = queue[0]!; + const p = pl(s, d.debtor); + if (!p.alive || d.amount <= 0) { + queue.shift(); + continue; + } + if (p.cash >= d.amount) { + transfer(s, d.debtor, d.to, d.amount); + logPaid(s, d, ev); + queue.shift(); + continue; + } + if (raisable(s, d.debtor) < d.amount) { + queue.shift(); + bankrupt(s, d.debtor, d.to, ev); + if (s.phase === 'finished') return; + if (d.debtor === curId(s)) return endTurn(s, ev); + continue; + } + s.phase = 'debt'; + s.debt = { ...d, queue: queue.slice(1), then }; + pushLog(s, ev, { type: 'debt', player: d.debtor, amount: d.amount, short: d.amount - p.cash, to: d.to, reason: d.reason }); + return; + } + s.debt = null; + runCont(s, then, ev); +} + +function logPaid(s: MarbleState, d: Debt, ev: GameEvent[]): void { + if (d.reason === 'toll' && d.to.kind === 'player') { + pushLog(s, ev, { type: 'tollPaid', from: d.debtor, to: d.to.id, pos: pl(s, d.debtor).pos, amount: d.amount, waived: false }); + } else if (d.reason === 'fund') { + pushLog(s, ev, { type: 'fundPaid', player: d.debtor, amount: d.amount }); + } else { + pushLog(s, ev, { type: 'paid', from: d.debtor, to: d.to, amount: d.amount, reason: d.reason }); + } +} + +/** Sells everything to the bank (sale value), returns the lands to the bank. */ +function liquidate(s: MarbleState, id: PlayerId): number { + let got = 0; + for (const pos of ownedBy(s, id)) { + got += fullSale(pos, s.owners[pos]!.level); + delete s.owners[pos]; + } + if (got > 0) mint(s, id, got); + return got; +} + +/** 파산 (§5.3): sell everything, all points go to the creditor, the player is out. */ +function bankrupt(s: MarbleState, id: PlayerId, to: Creditor, ev: GameEvent[]): void { + liquidate(s, id); + const p = pl(s, id); + const amount = p.cash; + if (amount > 0) transfer(s, id, to, amount); + retire(s, id); + pushLog(s, ev, { type: 'bankrupt', player: id, creditor: to, amount }); + if (aliveIds(s).length <= 1) finish(s, 'lastSurvivor', ev); +} + +function retire(s: MarbleState, id: PlayerId): void { + const p = pl(s, id); + for (const c of p.heldCards) deckBottom(s, c); + p.heldCards = []; + p.alive = false; + p.bankruptAtRound = s.round; + p.outSeq = ++s.outCount; + p.islandLeft = 0; + p.spacePending = false; + s.timeouts[id] = 0; +} + +// ---------------------------------------------------------------- cards + +function drawCard(s: MarbleState, ev: GameEvent[]): void { + const id = curId(s); + const p = pl(s, id); + const card = s.deck.shift(); + if (!card) throw new Error('marble: empty deck'); + s.cardsThisTurn += 1; + s.lastCard = { seq: ++s.seq, player: id, id: card }; + if (!s.revealed.includes(card)) s.revealed.push(card); + pushLog(s, ev, { type: 'cardDrawn', player: id, cardId: card }); + if (KEEP_CARDS.includes(card)) p.heldCards.push(card); + else deckBottom(s, card); + const others = () => { + // Seat order starting after the current player (§5.3). + const out: PlayerId[] = []; + for (let k = 1; k < s.order.length; k++) { + const q = s.order[(s.current + k) % s.order.length]!; + if (s.players[q]!.alive) out.push(q); + } + return out; + }; + const gain = (n: number) => { + mint(s, id, n); + pushLog(s, ev, { type: 'cardGain', player: id, amount: n }); + afterLanding(s, ev); + }; + const owe = (amount: number, to: Creditor) => (amount > 0 ? pay(s, [{ debtor: id, amount, to, reason: 'card' }], { k: 'land' }, ev) : afterLanding(s, ev)); + switch (card) { + case 'L01': + return gain(20); + case 'L02': + return gain(10); + case 'L03': + return gain(15); + case 'L05': + return gain(10); + case 'L04': + return pay(s, others().map((q) => ({ debtor: q, amount: BIRTHDAY_GIFT, to: playerC(id), reason: 'card' as const })), { k: 'land' }, ev); + case 'L06': + return owe(5, { kind: 'fund' }); + case 'L07': + return owe(10, bankC); + case 'L08': + return owe(10, { kind: 'fund' }); + case 'L09': + return owe(repairCost(s, id), bankC); + case 'L20': + return owe(ownedBy(s, id).length * PROPERTY_TAX, bankC); + case 'L19': + return pay(s, others().map((q) => ({ debtor: id, amount: TRAVEL_GIFT, to: playerC(q), reason: 'card' as const })), { k: 'land' }, ev); + case 'L10': + sendToIsland(s, id, 'card', ev); + return endTurn(s, ev); + case 'L11': + case 'L12': + return afterLanding(s, ev); + case 'L13': + moveForward(s, id, forwardSteps(p.pos, START) || 40, 'card', ev); + return land(s, ev); + case 'L14': + moveForward(s, id, forwardSteps(p.pos, SPACE_TRAVEL), 'card', ev); + return land(s, ev); + case 'L15': + moveForward(s, id, forwardSteps(p.pos, SEOUL), 'card', ev); + return land(s, ev); + case 'L16': + moveBack(s, id, 3, ev); + return land(s, ev); + case 'L17': + moveForward(s, id, forwardSteps(p.pos, FUND_IN), 'card', ev); + return land(s, ev); + case 'L18': { + const dest = TOUR_POSITIONS.map((t) => ({ t, d: forwardSteps(p.pos, t) || 40 })).sort((a, b) => a.d - b.d)[0]!; + moveForward(s, id, dest.d, 'card', ev); + return land(s, ev); + } + } +} + +/** L09: 별장 3, 빌딩 6, 호텔 10, 랜드마크 15 per built stage. */ +export function repairCost(s: MarbleState, id: PlayerId): number { + let t = 0; + for (const pos of ownedBy(s, id)) for (let l = 1; l <= s.owners[pos]!.level; l++) t += REPAIR_PER_STAGE[l - 1]!; + return t; +} + +// ---------------------------------------------------------------- monopoly & finish + +export function monopolyOf(s: MarbleState, id: PlayerId): 'triple' | 'line' | 'tour' | null { + const mine = (p: number) => s.owners[p]?.owner === id; + const groups = Object.values(GROUPS).filter((g) => g.every(mine)).length; + if (groups >= 3) return 'triple'; + if (LINES.some((l) => l.every(mine))) return 'line'; + if (TOUR_POSITIONS.every(mine)) return 'tour'; + return null; +} + +function checkMonopoly(s: MarbleState, id: PlayerId, ev: GameEvent[]): boolean { + if (!s.options.monopolyWin || s.phase === 'finished' || !pl(s, id).alive) return false; + const m = monopolyOf(s, id); + if (!m) return false; + pushLog(s, ev, { type: 'monopoly', player: id, kind: m }); + finish(s, 'monopoly', ev, { player: id, kind: m }); + return true; +} + +const MONO_KO = { triple: '트리플 독점', line: '라인 독점', tour: '관광지 독점' } as const; + +export function finish(s: MarbleState, reason: MarbleResult['reason'], ev: GameEvent[], mono?: { player: PlayerId; kind: 'triple' | 'line' | 'tour' }): void { + const entry = (p: PlayerId): Omit => ({ + player: p, + totalAssets: totalAssets(s, p), + cash: pl(s, p).cash, + lands: ownedBy(s, p).length, + bankruptAtRound: pl(s, p).bankruptAtRound, + }); + const ranking: MarbleRankEntry[] = []; + let alive = aliveIds(s).map(entry); + if (mono) { + ranking.push({ rank: 1, ...entry(mono.player) }); + alive = alive.filter((e) => e.player !== mono.player); + } + alive.sort((a, b) => b.totalAssets - a.totalAssets || b.cash - a.cash); + for (let i = 0; i < alive.length; i++) { + const e = alive[i]!; + const prev = alive[i - 1]; + const rank = prev && prev.totalAssets === e.totalAssets && prev.cash === e.cash ? ranking[ranking.length - 1]!.rank : ranking.length + 1; + ranking.push({ rank, ...e }); + } + const out = s.order.filter((p) => !pl(s, p).alive).sort((a, b) => pl(s, b).outSeq! - pl(s, a).outSeq!); + for (const p of out) ranking.push({ rank: ranking.length + 1, ...entry(p) }); + const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player); + const top = ranking[0]!; + let summary: string; + if (reason === 'monopoly') summary = `${MONO_KO[mono!.kind]} 달성으로 즉시 승리! (총자산 ${top.totalAssets}P)`; + else if (reason === 'lastSurvivor') summary = out.length > 0 ? `다른 사람이 모두 파산해서 끝까지 남은 사람이 이겼어요 (총자산 ${top.totalAssets}P)` : '게임이 끝났어요'; + else if (winners.length > 1) summary = `${s.round}라운드 끝! 총자산 ${top.totalAssets}P로 ${winners.length}명 공동 1등`; + else summary = `${s.round}라운드 끝! 총자산 ${top.totalAssets}P로 1등`; + s.phase = 'finished'; + s.decision = null; + s.debt = null; + s.pendingTrade = null; + s.extraRoll = false; + s.deadlineAt = null; + s.final = { reason, ...(mono ? { monopolyType: mono.kind } : {}), ranking, winners, summary }; + pushLog(s, ev, { type: 'gameOver', reason, winners, summary }); +} + +// ---------------------------------------------------------------- actions + +function rollDice(rng: Rng): [number, number] { + return [1 + rng.int(6), 1 + rng.int(6)]; +} + +export function doRoll(s: MarbleState, rng: Rng, ev: GameEvent[]): void { + const id = curId(s); + const p = pl(s, id); + const dice = rollDice(rng); + const double = dice[0] === dice[1]; + const sum = dice[0] + dice[1]; + const onIsland = p.islandLeft > 0; + s.lastRoll = { seq: ++s.seq, player: id, dice, double, island: onIsland }; + pushLog(s, ev, { type: 'rolled', player: id, dice, double, island: onIsland }); + if (onIsland) { + s.extraRoll = false; + if (double) { + p.islandLeft = 0; + pushLog(s, ev, { type: 'escaped', player: id, how: 'double' }); + moveForward(s, id, sum, 'roll', ev); + return land(s, ev); + } + p.islandLeft -= 1; + pushLog(s, ev, { type: 'islandStay', player: id, left: p.islandLeft }); + return endTurn(s, ev); + } + s.extraRoll = false; + if (double) { + s.doublesThisTurn += 1; + if (s.doublesThisTurn >= 3) { + sendToIsland(s, id, 'doubles', ev); + return endTurn(s, ev); + } + s.extraRoll = true; + } + moveForward(s, id, sum, 'roll', ev); + land(s, ev); +} + +export function doEscape(s: MarbleState, how: 'fee' | 'card', ev: GameEvent[]): void { + const id = curId(s); + const p = pl(s, id); + if (how === 'fee') transfer(s, id, bankC, s.options.islandFee); + else { + p.heldCards.splice(p.heldCards.indexOf('L11'), 1); + deckBottom(s, 'L11'); + } + p.islandLeft = 0; + pushLog(s, ev, { type: 'escaped', player: id, how, amount: how === 'fee' ? s.options.islandFee : 0 }); +} + +export function doSpaceTravel(s: MarbleState, dest: number, ev: GameEvent[]): void { + const id = curId(s); + pl(s, id).spacePending = false; + s.extraRoll = false; + pushLog(s, ev, { type: 'spaceTravel', player: id, dest }); + const center = s.owners[SPACE_CENTER]; + if (center && center.owner !== id && pl(s, center.owner).alive) { + return pay(s, [{ debtor: id, amount: SPACE_FARE, to: playerC(center.owner), reason: 'fare' }], { k: 'space', dest }, ev); + } + fly(s, dest, ev); +} + +function fly(s: MarbleState, dest: number, ev: GameEvent[]): void { + const id = curId(s); + moveForward(s, id, forwardSteps(pl(s, id).pos, dest), 'space', ev); + land(s, ev); +} + +export function doBuy(s: MarbleState, ev: GameEvent[]): void { + const id = curId(s); + const pos = s.decision!.pos; + const price = SPACES[pos]!.price!; + transfer(s, id, bankC, price); + s.owners[pos] = { owner: id, level: 0 }; + pushLog(s, ev, { type: 'bought', player: id, pos, price }); + if (checkMonopoly(s, id, ev)) return; + s.decision = null; + offerBuild(s, ev, true); +} + +export function doBuild(s: MarbleState, level: Level, ev: GameEvent[]): void { + const id = curId(s); + const pos = s.decision!.pos; + const cost = stageCost(pos, level); + transfer(s, id, bankC, cost); + s.owners[pos]!.level = level; + pushLog(s, ev, { type: 'built', player: id, pos, level, cost }); + afterLanding(s, ev); +} + +export function doTakeover(s: MarbleState, ev: GameEvent[]): void { + const id = curId(s); + const pos = s.decision!.pos; + const o = s.owners[pos]!; + const price = takeoverPrice(pos, o.level); + const prev = o.owner; + transfer(s, id, playerC(prev), price); + o.owner = id; + pushLog(s, ev, { type: 'takeover', player: id, from: prev, pos, price }); + if (checkMonopoly(s, id, ev)) return; + afterLanding(s, ev); +} + +export function doPass(s: MarbleState, ev: GameEvent[]): void { + const d = s.decision; + if (d) pushLog(s, ev, { type: 'passed', player: curId(s), pos: d.pos }); + afterLanding(s, ev); +} + +export function doSell(s: MarbleState, id: PlayerId, pos: number, what: 'top' | 'all', ev: GameEvent[]): void { + const o = s.owners[pos]!; + let got: number; + if (what === 'top') { + got = stageSale(pos, o.level); + o.level = (o.level - 1) as Level; + } else { + got = fullSale(pos, o.level); + delete s.owners[pos]; + } + mint(s, id, got); + pushLog(s, ev, { type: 'sold', player: id, pos, what, amount: got }); + const d = s.debt; + if (d && d.debtor === id && pl(s, id).cash >= d.amount) { + transfer(s, id, d.to, d.amount); + logPaid(s, d, ev); + s.debt = null; + runQueue(s, [...d.queue], d.then, ev); + } +} + +/** Cheapest sale first (§8.5): a top stage with the smallest sale value, else the cheapest land; ties → lower square. */ +export function autoSell(s: MarbleState, id: PlayerId): { pos: number; what: 'top' | 'all' } | null { + const mine = ownedBy(s, id); + const built = mine.filter((p) => s.owners[p]!.level >= 1); + if (built.length) { + const best = [...built].sort((a, b) => stageSale(a, s.owners[a]!.level) - stageSale(b, s.owners[b]!.level) || a - b)[0]!; + return { pos: best, what: 'top' }; + } + if (!mine.length) return null; + const best = [...mine].sort((a, b) => SPACES[a]!.price! - SPACES[b]!.price! || a - b)[0]!; + return { pos: best, what: 'all' }; +} + +export function doPropose(s: MarbleState, t: Trade, ev: GameEvent[]): void { + s.pendingTrade = { from: t.from, to: t.to, give: { lands: [...t.give.lands].sort((a, b) => a - b), cash: t.give.cash }, take: { lands: [...t.take.lands].sort((a, b) => a - b), cash: t.take.cash } }; + s.tradesThisTurn += 1; + s.phase = 'tradeResponse'; + pushLog(s, ev, { type: 'tradeProposed', ...structuredClone(s.pendingTrade) }); +} + +/** Ownership / points still match the offer? */ +export function tradeStillValid(s: MarbleState, t: Trade): boolean { + return ( + pl(s, t.from).alive && + pl(s, t.to).alive && + t.give.lands.every((p) => s.owners[p]?.owner === t.from) && + t.take.lands.every((p) => s.owners[p]?.owner === t.to) && + pl(s, t.from).cash >= t.give.cash && + pl(s, t.to).cash >= t.take.cash + ); +} + +export function doRespond(s: MarbleState, accept: boolean, ev: GameEvent[]): void { + const t = s.pendingTrade!; + s.pendingTrade = null; + s.phase = 'turnStart'; + if (!accept) { + pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: false }); + return; + } + if (!tradeStillValid(s, t)) { + pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: false, failed: true }); + return; + } + for (const p of t.give.lands) s.owners[p]!.owner = t.to; + for (const p of t.take.lands) s.owners[p]!.owner = t.from; + if (t.give.cash) transfer(s, t.from, playerC(t.to), t.give.cash); + if (t.take.cash) transfer(s, t.to, playerC(t.from), t.take.cash); + pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: true }); + if (checkMonopoly(s, t.from, ev)) return; + checkMonopoly(s, t.to, ev); +} + +/** 기권 (§8.2 CONCEDE): like bankruptcy to the bank. A debtor in the middle of paying still settles that debt first. */ +export function doConcede(s: MarbleState, id: PlayerId, ev: GameEvent[]): void { + const wasCurrent = id === curId(s); + if (s.phase === 'tradeResponse' && s.pendingTrade && (s.pendingTrade.from === id || s.pendingTrade.to === id)) { + pushLog(s, ev, { type: 'tradeResolved', from: s.pendingTrade.from, to: s.pendingTrade.to, accepted: false, cancelled: true }); + s.pendingTrade = null; + s.phase = 'turnStart'; + } + const d = s.debt && s.phase === 'debt' && s.debt.debtor === id ? s.debt : null; + liquidate(s, id); + let toCreditor = 0; + if (d) { + toCreditor = Math.min(pl(s, id).cash, d.amount); + if (toCreditor > 0) transfer(s, id, d.to, toCreditor); + } + const rest = pl(s, id).cash; + if (rest > 0) transfer(s, id, bankC, rest); + retire(s, id); + pl(s, id).conceded = true; + pushLog(s, ev, { type: 'conceded', player: id, ...(d ? { creditor: d.to, paid: toCreditor } : {}) }); + if (aliveIds(s).length <= 1) return finish(s, 'lastSurvivor', ev); + if (wasCurrent) return endTurn(s, ev); + if (d) { + s.debt = null; + return runQueue(s, [...d.queue], d.then, ev); + } + // A takeover offer on the leaver's land is gone with the land. + if (s.phase === 'decide' && s.decision && s.decision.canTakeover !== null && !s.owners[s.decision.pos]) afterLanding(s, ev); +} + +// ---------------------------------------------------------------- setup + +export function createState(players: PlayerId[], options: MarbleOptions, rng: Rng, deck: CardId[]): MarbleState { + const order = rng.shuffle(players); + const ps: Record = {}; + const timeouts: Record = {}; + for (const p of order) { + ps[p] = { pos: START, cash: options.startMoney, alive: true, bankruptAtRound: null, outSeq: null, islandLeft: 0, spacePending: false, heldCards: [], conceded: false }; + timeouts[p] = 0; + } + return { + options, + order, + current: 0, + round: 1, + phase: 'turnStart', + players: ps, + owners: {}, + fund: 0, + deck: rng.shuffle(deck), + revealed: [], + doublesThisTurn: 0, + cardsThisTurn: 0, + decision: null, + debt: null, + pendingTrade: null, + tradesThisTurn: 0, + extraRoll: false, + lastRoll: null, + lastMove: null, + lastCard: null, + seq: 0, + turnSeq: 0, + deadlineAt: null, + timeouts, + log: [], + outCount: 0, + ledger: { minted: 0, burned: 0 }, + final: null, + }; +} + diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index b3bd701..a35e4fc 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -23,6 +23,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true }, { id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true }, { id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true }, + { id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);