From fa7ceea668ba847f3e62c8d773c6b7ee1002433a Mon Sep 17 00:00:00 2001 From: EJClaw Date: Mon, 5 Oct 2026 14:53:47 +0900 Subject: [PATCH] =?UTF-8?q?M7:=20=EB=9D=BC=EC=8A=A4=EB=B2=A0=EA=B0=80?= =?UTF-8?q?=EC=8A=A4=20=EA=B7=9C=EC=B9=99=C2=B7=ED=99=94=EB=A9=B4=C2=B7?= =?UTF-8?q?=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/las-vegas: 지폐 54장 더미·카지노 채우기(합 5 이상), 굴리기/놓기(같은 눈 전부), 중립(흰) 주사위 변형(auto/on/off, 3명은 라운드 시작 하우스 주사위 2개 자동 배치), 동점 무효 정산, 하우스·남은 지폐 더미 아래로(카지노 1→6, 큰 금액부터), 라운드마다 선 이동, 장 수 동점 처리, 시간 초과(굴리기 자동 / 개수→지폐→작은 수 순 자동 놓기)와 3회 연속 자리 비움, 기권, hideMoney 시 남의 금액은 장 수만 보이는 view, 놓기 미리 보기(1등/동점 주의) 계산 - 테스트: 문서 10절 시나리오 전부, 1,000시드 무작위 대국(타임아웃·기권 포함, 지폐·주사위 보존, 정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 100시드 - 화면 apps/web/src/games/las-vegas: 카지노 6곳(PC 한 줄, 폰 3열×2행), 색깔 주사위 쌓기, 놀이용 지폐, 굴림 애니메이션, 숫자별 묶음 버튼 → 카지노 강조와 예상 순위 → 확정, 라운드 정산(동점 무효) 표시, 최종 결과표, 폰에서는 굴리기/놓기 영역이 화면 아래에 고정 - 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘, 놀이용 점수 문구 검사 대상에 추가 - E2E: PC 방장 + 폰 친구, 3라운드로 바꿔 결과까지 실제 플레이(키보드 선택, 카지노 카드 선택, 동점 무효 확인) Co-Authored-By: Claude Opus 5.5 --- apps/web/src/games/las-vegas/index.tsx | 816 ++++++++++++++++++ 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 | 114 +++ packages/games/src/index.ts | 2 + packages/games/src/las-vegas/index.ts | 492 +++++++++++ .../games/src/las-vegas/las-vegas.test.ts | 712 +++++++++++++++ packages/games/src/las-vegas/rules.ts | 199 +++++ packages/shared/src/catalog.ts | 1 + 10 files changed, 2339 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/games/las-vegas/index.tsx create mode 100644 packages/games/src/las-vegas/index.ts create mode 100644 packages/games/src/las-vegas/las-vegas.test.ts create mode 100644 packages/games/src/las-vegas/rules.ts diff --git a/apps/web/src/games/las-vegas/index.tsx b/apps/web/src/games/las-vegas/index.tsx new file mode 100644 index 0000000..2bceb2f --- /dev/null +++ b/apps/web/src/games/las-vegas/index.tsx @@ -0,0 +1,816 @@ +/** 라스베가스 화면 (docs/games/las-vegas.md §9). Bills are play points of this room only (docs/04 §6). */ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import type { LasVegasChoice, LasVegasOptions, LasVegasPayoutView, LasVegasView } from '@bg/games/las-vegas'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +const ROLL_MS = 650; +const HOUSE = 'house'; +/** Seat colours (dice), plus white for the neutral "house" dice. */ +const COLORS = ['#e5484d', '#2f7cf6', '#1f9d55', '#f2a10c', '#8e5cf0']; +const COLOR_NAMES = ['빨강', '파랑', '초록', '노랑', '보라']; +/** Play-bill colours per face value (deliberately toy-like). */ +const BILL: Record = { + 1: ['#fde68a', '#78350f'], + 2: ['#bbf7d0', '#14532d'], + 3: ['#bae6fd', '#0c4a6e'], + 4: ['#fbcfe8', '#831843'], + 5: ['#ddd6fe', '#3b0764'], + 6: ['#fed7aa', '#7c2d12'], + 7: ['#a7f3d0', '#064e3b'], + 8: ['#c7d2fe', '#1e1b4b'], + 9: ['#fecaca', '#7f1d1d'], +}; + +const nick = (players: Record, id: string) => (id === HOUSE ? '하우스(흰 주사위)' : (players[id]?.nickname ?? '알 수 없음')); +const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; +const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches; +const seatColor = (v: LasVegasView, owner: string) => (owner === HOUSE ? '#ffffff' : (COLORS[v.seats.indexOf(owner)] ?? '#888')); +const initial = (players: Record, id: string) => (id === HOUSE ? '흰' : ([...nick(players, id)][0] ?? '?')); + +const CSS = ` +.lv-wrap { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; } +.lv-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; padding: 10px 14px; } +.lv-help { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } +.lv-help span { background: var(--warning-soft); color: var(--text); border: 2px solid var(--warning); border-radius: 999px; padding: 4px 14px; font-weight: 800; font-size: 1.02rem; } +.lv-casinos { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; } +.lv-casino { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 8px; min-height: 196px; border-radius: var(--radius); border: 3px solid var(--border); + background: var(--surface); box-shadow: var(--shadow); text-align: left; color: var(--text); font: inherit; transition: border-color .15s, transform .15s; min-width: 0; } +button.lv-casino { cursor: pointer; } +button.lv-casino:hover { border-color: var(--primary); } +.lv-casino:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.lv-casino.can { border-style: dashed; border-color: var(--primary); } +.lv-casino.sel { border-style: solid; border-color: var(--primary); transform: translateY(-3px); box-shadow: 0 0 0 4px var(--primary-soft), var(--shadow); } +.lv-casino.first { border-color: var(--success); box-shadow: 0 0 0 4px var(--success-soft), var(--shadow); } +.lv-casino.tie { border-color: var(--warning); box-shadow: 0 0 0 4px var(--warning-soft), var(--shadow); } +.lv-ctop { display: flex; align-items: center; gap: 6px; } +.lv-cnum { font-size: 1.5rem; font-weight: 900; line-height: 1; } +.lv-cname { font-size: .8rem; color: var(--text-sub); font-weight: 700; white-space: nowrap; } +.lv-bills { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; } +.lv-bill { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 28px; padding: 0 6px; border-radius: 6px; + font-weight: 900; font-size: 1.05rem; border: 2px dashed rgba(0,0,0,.35); box-shadow: inset 0 0 0 2px rgba(255,255,255,.55); font-variant-numeric: tabular-nums; } +.lv-bill::before { content: '★'; font-size: .55rem; position: absolute; left: 3px; top: 1px; opacity: .6; } +.lv-bill.sm { min-width: 26px; height: 22px; font-size: .85rem; padding: 0 4px; } +.lv-bill.hidden { background: repeating-linear-gradient(45deg, var(--surface-2), var(--surface-2) 4px, var(--border) 4px, var(--border) 8px); color: var(--text-sub); } +.lv-nobill { color: var(--text-sub); font-size: .8rem; } +.lv-stacks { display: flex; flex-direction: column; gap: 3px; margin-top: auto; } +.lv-stack { display: flex; align-items: center; gap: 4px; font-size: .82rem; font-weight: 800; min-width: 0; } +.lv-pips { display: flex; flex-wrap: wrap; gap: 2px; min-width: 0; } +.lv-dot { width: 13px; height: 13px; border-radius: 3px; border: 1.5px solid rgba(0,0,0,.45); flex: none; } +.lv-tag { position: absolute; top: -11px; right: 6px; font-size: .78rem; font-weight: 900; border-radius: 999px; padding: 1px 8px; white-space: nowrap; } +.lv-tag.first { background: var(--success); color: #fff; } +.lv-tag.tie { background: #facc15; color: #422006; } +.lv-tag.award { background: var(--primary); color: var(--primary-text); } +.lv-tag.none { background: var(--surface-2); color: var(--text-sub); border: 1px solid var(--border); } +@keyframes lv-hit { 0% { transform: scale(1); } 40% { transform: scale(1.05); box-shadow: 0 0 0 6px var(--primary-soft); } 100% { transform: scale(1); } } +.lv-casino.hit { animation: lv-hit .55s ease-out; } +.lv-lower { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 10px; align-items: start; } +.lv-tray { display: flex; flex-direction: column; gap: 10px; padding: 12px; } +.lv-groups { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } +.lv-group { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 92px; min-height: 64px; padding: 6px 10px; border-radius: 12px; + border: 3px solid var(--border); background: var(--surface); color: var(--text); font: inherit; cursor: pointer; } +.lv-group:disabled { cursor: default; opacity: 1; } +.lv-group[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); } +.lv-group:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.lv-group b { font-size: 1.15rem; } +.lv-group small { color: var(--text-sub); font-weight: 700; } +.lv-mdice { display: flex; gap: 3px; flex-wrap: wrap; justify-content: center; } +.lv-die { width: 28px; height: 28px; border-radius: 7px; border: 2px solid rgba(0,0,0,.4); flex: none; display: inline-block; } +.lv-die svg { display: block; width: 100%; height: 100%; } +.lv-die.lg { width: 40px; height: 40px; border-radius: 9px; } +@keyframes lv-tumble { 0% { transform: translateY(-16px) rotate(-40deg); } 50% { transform: translateY(3px) rotate(190deg); } 100% { transform: translateY(0) rotate(360deg); } } +.lv-die.rolling { animation: lv-tumble ${ROLL_MS}ms ease-out both; } +.lv-preview { border-radius: 10px; padding: 8px 12px; font-weight: 700; text-align: center; } +.lv-preview.first { background: var(--success-soft); color: var(--success); } +.lv-preview.tie { background: var(--warning-soft); color: var(--warning); } +.lv-preview.award, .lv-preview.none { background: var(--surface-2); } +.lv-roll { min-height: 60px; font-size: 1.15rem; } +.lv-settle { padding: 12px; display: flex; flex-direction: column; gap: 6px; } +.lv-settle h3 { margin: 0; font-size: 1rem; } +.lv-prow { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 6px; align-items: center; font-size: .85rem; padding: 3px 0; border-bottom: 1px solid var(--border); } +.lv-prow:last-child { border-bottom: 0; } +.lv-pbody { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-width: 0; } +.lv-chip { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; font-weight: 700; } +.lv-tieout { display: inline-flex; align-items: center; gap: 3px; color: var(--warning); font-weight: 800; } +.lv-tieout .lv-dot { opacity: .55; } +@keyframes lv-poof { 0% { transform: scale(1.4); opacity: 0; } 50% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } } +.lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: lv-poof .6s ease-out both; } +.lv-pname { max-width: 84px; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: bottom; } +.lv-final { padding: 12px; } +.lv-final table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } +.lv-final th, .lv-final td { padding: 4px 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; } +.lv-final td.notes { white-space: normal; text-align: left; } +@media (max-width: 1099px) { + .lv-lower { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 719px) { + .lv-casinos { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } + .lv-casino { min-height: 132px; padding: 6px; gap: 4px; } + .lv-wide { display: none; } + .lv-cnum { font-size: 1.25rem; } + .lv-cname { display: none; } + .lv-bill { min-width: 26px; height: 24px; font-size: .9rem; padding: 0 4px; } + .lv-dot { width: 11px; height: 11px; } + .lv-help { gap: 6px; flex-wrap: nowrap; } + .lv-help span { font-size: .8rem; padding: 2px 8px; white-space: nowrap; } + .lv-group { min-width: 76px; padding: 6px 8px; } + .lv-die { width: 22px; height: 22px; border-radius: 6px; } + .lv-die.lg { width: 32px; height: 32px; } + /* The tray sticks to the bottom of the screen so rolling / placing is always in reach. */ + .lv-lower { display: contents; } + .lv-tray { position: sticky; bottom: 0; z-index: 4; box-shadow: 0 -4px 14px rgba(0,0,0,.12); } +} +@media (prefers-reduced-motion: reduce) { + .lv-casino, .lv-casino.sel { transition: none; transform: none; } + .lv-casino.hit, .lv-die.rolling, .lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: none; } +} +`; + +// ---------------------------------------------------------------- small pieces + +// prettier-ignore +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]], +}; + +/** Pip colour that reads on the die colour (white dice → dark pips). */ +const pipOn = (bg: string) => (['#ffffff', '#fffdf8', '#f2a10c'].includes(bg) ? '#1c1e21' : '#ffffff'); + +function Die({ value, color, size = '', rolling = false }: { value: number; color: string; size?: '' | 'lg'; rolling?: boolean }) { + return ( + + + {PIPS[value]!.map(([x, y], i) => ( + + ))} + + + ); +} + +function Bill({ note, small }: { note: number | null; small?: boolean }) { + if (note === null) + return ( + + ? + + ); + const [bg, fg] = BILL[note] ?? ['#eee', '#111']; + return ( + + {note} + + ); +} + +function Dot({ color }: { color: string }) { + return ; +} + +function OwnerChip({ v, players, owner, me }: { v: LasVegasView; players: Record; owner: string; me: string | null }) { + return ( + + + {owner === me ? '나' : owner === HOUSE ? '하우스' : nick(players, owner)} + + ); +} + +const STANDING_TAG: Record = { first: '1등!', tie: '동점 주의', award: '지폐 받음', none: '못 받아요' }; + +function previewText(c: LasVegasChoice): string { + const head = `${c.value}번 카지노에 ${c.own + c.neutral}개를 놓으면`; + switch (c.standing) { + case 'first': + return `${head} 지금은 1등이에요! (${c.note}점 지폐)`; + case 'award': + return `${head} 지금은 ${c.note}점 지폐를 받을 수 있어요.`; + case 'tie': + return `${head} 개수가 같아져서 지금은 둘 다 꽝이에요. 동점 주의!`; + default: + return c.own === 0 ? `${head} 흰 주사위만 놓여요. 내 주사위는 그대로예요.` : `${head} 지금은 받을 지폐가 없어요.`; + } +} + +// ---------------------------------------------------------------- board + +function LasVegasBoard({ view: v, me, players, act, pending }: BoardProps) { + const myTurn = v.phase !== 'finished' && !!me && v.current === me; + const [sel, setSel] = useState(null); + const [rolling, setRolling] = useState(false); + const [flick, setFlick] = useState(null); + const [hit, setHit] = useState<{ value: number; key: number } | null>(null); + const [announce, setAnnounce] = useState(''); + const prevRoll = useRef(v.rollSeq); + const prevTurn = useRef(v.turnSeq); + const prevRounds = useRef(v.roundResults.length); + + // New roll: tumble animation + screen-reader text. + useLayoutEffect(() => { + if (v.rollSeq <= prevRoll.current || !v.lastRoll) { + prevRoll.current = v.rollSeq; + return; + } + prevRoll.current = v.rollSeq; + setSel(null); + const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`; + const all = [...v.lastRoll.own, ...v.lastRoll.neutral].sort((a, b) => a - b); + setAnnounce(`${who} 주사위 ${all.length}개를 굴렸어요: ${all.join(', ')}`); + if (reducedMotion()) { + sounds.stone(); + return; + } + setRolling(true); + }, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!rolling || !v.lastRoll) return; + const n = v.lastRoll.own.length + v.lastRoll.neutral.length; + const t = setInterval(() => setFlick(Array.from({ length: n }, () => 1 + Math.floor(Math.random() * 6))), 70); + const end = setTimeout(() => { + setRolling(false); + setFlick(null); + sounds.stone(); + }, ROLL_MS); + return () => { + clearInterval(t); + clearTimeout(end); + }; + }, [rolling]); // eslint-disable-line react-hooks/exhaustive-deps + + // Turn changes: placement flash, round settlement and my-turn cue. + useEffect(() => { + if (v.turnSeq === prevTurn.current) return; + prevTurn.current = v.turnSeq; + setSel(null); + const parts: string[] = []; + const lp = v.lastPlace; + if (lp) { + setHit({ value: lp.value, key: v.turnSeq }); + const who = lp.player === me ? '내가' : `${nick(players, lp.player)}님이`; + parts.push( + `${who} ${lp.value}번 카지노에 주사위 ${lp.own + lp.neutral}개를 놓았어요${lp.neutral ? `(흰 ${lp.neutral}개 포함)` : ''}${lp.auto ? ' (시간 초과로 자동)' : ''}.`, + ); + } + if (v.roundResults.length > prevRounds.current) { + prevRounds.current = v.roundResults.length; + const rr = v.roundResults[v.roundResults.length - 1]!; + const ties = rr.payouts.filter((p) => p.removedTies.length > 0).length; + parts.push(`${rr.round}라운드 정산이 끝났어요.${ties ? ` 동점 무효가 ${ties}곳에서 나왔어요.` : ''}`); + haptic([50, 30, 50]); + } + if (myTurn) { + haptic(40); + if (document.visibilityState === 'hidden') sounds.myTurn(); + } + if (parts.length) setAnnounce(parts.join(' ')); + }, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps + + const choices = v.phase === 'place' && !rolling ? v.choices : []; + const selChoice = choices.find((c) => c.value === sel) ?? null; + const canPick = myTurn && v.phase === 'place' && !pending && !rolling; + const pick = (value: number) => { + if (!canPick || !choices.some((c) => c.value === value)) return; + setSel(sel === value ? null : value); + }; + const place = () => { + if (!canPick || sel === null) return; + act({ type: 'PLACE', value: sel }); + setSel(null); + }; + const roll = () => { + if (myTurn && v.phase === 'roll' && !pending) act({ type: 'ROLL' }); + }; + + const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : ''; + const hand = me ? v.hand[me] : undefined; + const lastRR = v.roundResults[v.roundResults.length - 1]; + + return ( +
+ +
+ {v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.rounds} 라운드`} + {v.current && ( + + + {curName} 차례 + + )} + + {v.phase !== 'finished' && `선 ${v.startPlayer === me ? '나' : nick(players, v.startPlayer)} · `} + 남은 지폐 더미 {v.pileCount}장 + +
+ {v.round === 1 && v.phase !== 'finished' && ( +
+ 🎲 같은 숫자는 한꺼번에 + ⚠️ 개수가 같으면 둘 다 꽝 +
+ )} + {v.result && lastRR && ( +
+ + +
+ )} + {!v.result && ( +
+ {v.casinos.map((c) => { + const ch = choices.find((x) => x.value === c.value); + const isSel = sel === c.value; + const owners = Object.entries(c.dice) + .filter(([, n]) => (n ?? 0) > 0) + .sort((a, b) => b[1]! - a[1]! || v.seats.indexOf(a[0]) - v.seats.indexOf(b[0])); + const label = `${c.value}번 카지노. 지폐 ${c.notes.length ? c.notes.map((n) => `${n}점`).join(', ') : '없음'}. 주사위 ${ + owners.length ? owners.map(([o, n]) => `${o === me ? '나' : nick(players, o)} ${n}개`).join(', ') : '없음' + }${ch && isSel ? `. ${STANDING_TAG[ch.standing]}` : ''}`; + const cls = `lv-casino${ch && canPick ? ' can' : ''}${isSel ? ` sel ${ch?.standing === 'first' ? 'first' : ch?.standing === 'tie' ? 'tie' : ''}` : ''}${hit?.value === c.value ? ' hit' : ''}`; + const body = ( + <> +
+ + {c.value} + 번 카지노 +
+
+ {c.notes.length ? c.notes.map((n, i) => ) : 지폐 없음} +
+
+ {owners.map(([o, n]) => ( +
+ + {Array.from({ length: Math.min(n!, 8) }, (_, i) => ( + + ))} + + {n! > 8 ? `${n}` : ''} + + {initial(players, o)} + +
+ ))} + {ch && isSel && ( +
+ + {Array.from({ length: ch.own }, (_, i) => ( + + ))} + {Array.from({ length: ch.neutral }, (_, i) => ( + + ))} + + +{ch.own + ch.neutral} +
+ )} +
+ {ch && isSel && {STANDING_TAG[ch.standing]}} + + ); + return ch && canPick ? ( + + ) : ( +
+ {body} +
+ ); + })} +
+ )} + {!v.result && ( +
+
+ {v.phase === 'finished' ? ( +

+ 게임이 끝났어요 +

+ ) : rolling && v.lastRoll ? ( +
+ {[...v.lastRoll.own.map(() => seatColor(v, v.current!)), ...v.lastRoll.neutral.map(() => '#ffffff')].map((col, i) => ( + + ))} +
+ ) : v.phase === 'place' && v.current ? ( + <> +
+ {choices.map((c) => ( + + ))} +
+ {canPick && selChoice &&
{previewText(selChoice)}
} + {canPick ? ( + + ) : ( +

+ {myTurn ? '잠시만요…' : `${nick(players, v.current)}님이 놓을 숫자를 고르는 중이에요`} +

+ )} + + ) : myTurn ? ( + + ) : ( +

+ {v.current ? `${nick(players, v.current)}님이 굴릴 차례예요` : ''} +

+ )} + {hand && v.phase !== 'finished' && !v.conceded.includes(me!) && ( +

+ 내 주사위 {hand.own}개{v.neutralPer > 0 ? ` · 흰 주사위 ${hand.neutral}개` : ''} 남음 +

+ )} + {v.houseRoll && v.houseRoll.round === v.round && v.phase !== 'finished' && ( +

+ 라운드 시작: 남는 흰 주사위 {v.houseRoll.values.length}개가 {v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요 +

+ )} +
+ {lastRR ? ( + + ) : ( +
+

라운드가 끝나면

+

+ 카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 무효예요. +

+
+ )} +
+ )} +

+ {announce} +

+
+ ); +} + +function Settlement({ + round, + payouts, + v, + me, + players, +}: { + round: number; + payouts: LasVegasPayoutView[]; + v: LasVegasView; + me: string | null; + players: Record; +}) { + const fresh = v.phase !== 'finished' && round === v.round - 1; + return ( +
+

{round}라운드 정산

+ {payouts.map((p) => ( +
+ {p.casino}번 +
+ {p.removedTies.length > 0 && ( + (o === me ? '나' : nick(players, o))).join(', ')}`}> + 💥 동점 무효 + {p.removedTies.map((o) => ( + + ))} + + )} + {p.awards.map((a, i) => ( + + + + {a.note === null && 1장} + + ))} + {p.returned.length > 0 && ↩ 더미 아래로 {p.returned.map((n) => n).join('·')}} + {p.awards.length === 0 && p.removedTies.length === 0 && p.returned.length === 0 && 아무도 없었어요} +
+
+ ))} +
+ ); +} + +function FinalTable({ v, me, players }: { v: LasVegasView; me: string | null; players: Record }) { + const r = v.result!; + return ( +
+

최종 결과

+ + + + + + {v.roundResults.map((rr) => ( + + ))} + + + + + {r.ranking.map((row) => ( + + + + {(r.roundTotals[row.player] ?? []).map((t, i) => ( + + ))} + + + ))} + +
순위이름 + {rr.round}R + 합계
{row.rank}등 + + {row.conceded && (기권)} + {t} + {row.total}점 +
{row.notes.length}장
+
+
+ ); +} + +// ---------------------------------------------------------------- side panel + +function LasVegasSide({ view: v, me, players, act, deadline }: BoardProps) { + const [ask, setAsk] = useState(false); + const total = v.options.turnSeconds ? v.options.turnSeconds * 1000 : 30_000; + const mine = me ? v.money[me] : undefined; + const playing = v.phase !== 'finished'; + return ( + <> +
+ {v.seats.map((p, i) => { + const turn = v.current === p; + const m = v.money[p]!; + const h = v.hand[p]!; + const out = v.conceded.includes(p); + return ( +
+ + + + +
+
+ {nick(players, p)} + {p === me && (나)} + {v.startPlayer === p && playing && ( + + 선 + + )} +
+
+ {out ? '기권' : v.away.includes(p) ? '자리 비움' : playing ? `주사위 ${h.own}${v.neutralPer ? ` · 흰 ${h.neutral}` : ''}` : '끝'} +
+
+ + {m.total !== null ? `${m.total}점` : '?'} +
{m.count}장
+
+ {turn && playing && } +
+ ); + })} +
+ {mine && ( +
+
+ 내 지폐 + + {mine.count}장 · {mine.total ?? 0}점 + +
+
+ {mine.notes && mine.notes.length ? mine.notes.map((n, i) => ) : 아직 없어요} +
+ {v.options.hideMoney && playing && ( +

+ 다른 사람에게는 장 수만 보여요. +

+ )} +
+ )} + {playing && me && v.money[me] && !v.conceded.includes(me) && ( + + )} + {ask && ( + act({ type: 'CONCEDE' })} + onClose={() => setAsk(false)} + /> + )} + + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: LasVegasView, me: string | null): boolean { + return v.phase !== 'finished' && !!me && v.current === me; +} + +function statusText(v: LasVegasView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + if (me && v.conceded.includes(me)) return '기권했어요. 구경하는 중이에요'; + if (isMyTurn(v, me)) return v.phase === 'roll' ? '내 차례예요. 주사위를 굴려 주세요' : '내 차례예요. 놓을 숫자를 골라 주세요'; + return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.rounds}라운드` : '잠시만 기다려 주세요'; +} + +// ---------------------------------------------------------------- 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 set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ rounds })} + /> + + + set({ neutralDice })} + /> + + + set({ hideMoney: k === 'hide' })} + /> + + + set({ turnSeconds: k === 'none' ? null : (Number(k) as 20 | 30 | 60) })} + /> + +

+ 흰 주사위 "켜기"는 5명일 때 쓸 수 없어요(5색을 모두 쓰기 때문이에요). +

+
+ ); +} + +function optionsSummary(o: LasVegasOptions): string { + return [ + `${o.rounds}라운드`, + o.neutralDice === 'auto' ? '흰 주사위 자동' : o.neutralDice === 'on' ? '흰 주사위 켜기' : '흰 주사위 끄기', + o.hideMoney ? '금액 숨김' : '금액 공개', + o.turnSeconds === null ? '시간 제한 없음' : `${o.turnSeconds}초`, + ].join(' · '); +} + +// ---------------------------------------------------------------- rules + +function Rules({ options }: { options?: LasVegasOptions }) { + const rounds = options?.rounds ?? 4; + return ( +
+ +

+ 1번부터 6번까지 카지노 6곳에 놀이용 지폐가 놓여 있어요(카지노마다 합이 5점 이상이 될 때까지). 각자 자기 색 주사위 8개를 가지고 + 시작해요. +

+

+ 내 차례에는 남은 주사위를 모두 굴린 뒤, 나온 숫자 하나를 골라 그 숫자가 나온 주사위를 한꺼번에 같은 번호 카지노에 + 놓아요. 일부만 놓을 수는 없어요. 주사위를 다 쓴 사람은 차례를 건너뛰어요. +

+

+ 모두 주사위를 다 놓으면 정산해요. 카지노마다 주사위가 가장 많은 사람이 가장 큰 지폐, 다음 사람이 그다음 지폐를 가져가요. 단,{' '} + 개수가 같은 사람끼리는 모두 무효(꽝)예요. +

+
+ + + + 가 놓인 카지노에 A 5개 · B 3개 · C 3개 · D 1개 → B와 C는 꽝, A가 8, D가 3을 받아요. +
+

+ 흰 주사위(중립): 2명일 때 기본으로 각자 4개씩 더 받아요. 같은 숫자가 나오면 내 주사위와 함께 놓이고, 흰 주사위는 "하우스"라는 + 가상의 한 사람으로 쳐요. 하우스와 개수가 같아도 꽝이에요. 3명이 켜고 하면 남는 흰 주사위 2개를 라운드마다 선 플레이어가 굴려 바로 놓아요. +

+

{rounds}라운드가 끝나면 받은 지폐 합이 가장 큰 사람이 이겨요. 같으면 지폐 장 수가 많은 사람이 이겨요. 라운드마다 선은 다음 사람에게 넘어가요.

+

+ 시간 안에 굴리지 않으면 자동으로 굴리고, 시간 안에 고르지 않으면 개수가 가장 많은 숫자(같으면 지폐가 큰 카지노, 그래도 같으면 작은 숫자)에 자동으로 + 놓아요. +

+

지폐는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 실제 화폐나 상품과 바꿀 수 없어요.

+
+ ); +} + +const ui: GameUI = { + Board: LasVegasBoard, + Side: LasVegasSide, + 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 2a1cc79..67bc746 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -19,6 +19,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { bang: () => import('./bang'), 'halli-galli': () => import('./halli-galli'), marble: () => import('./marble'), + 'las-vegas': () => import('./las-vegas'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index a88513f..2d82596 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -16,6 +16,7 @@ export const GAME_ICON: Record = { marble: '🏙️', bang: '🤠', 'halli-galli': '🔔', + 'las-vegas': '⚅', }; /** 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 ae36b91..c570c0a 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', 'games/marble', 'components/cards'].map((p) => join(import.meta.dir, p)); +const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'games/las-vegas', '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 d1d9937..90b8636 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -260,6 +260,68 @@ async function marbleStep(p: Page, stats: Record): Promise }, +): Promise { + const press = (l: ReturnType) => (phone ? l.tap({ timeout: 1500 }) : l.click({ timeout: 1500 })); + const roll = p.getByRole('button', { name: /^🎲 주사위 굴리기/ }); + if ((await roll.count()) && (await roll.isEnabled().catch(() => false))) { + if (!(await press(roll).then(() => true, () => false))) return false; + st.rolls++; + return true; + } + const groups = p.locator('.lv-group:not([disabled])'); + if (!(await groups.count())) return false; + try { + const tie = p.locator('.lv-group:not([disabled])[data-standing="tie"]'); + const target = st.places % 2 === 1 && (await tie.count()) ? tie.first() : groups.first(); + const confirm = p.getByRole('button', { name: /번 카지노에 \d+개 놓기$/ }); + if (!phone && !st.keyboard) { + // Keyboard only: Enter on a number group, then Enter on the confirm button. + await target.focus(); + await p.keyboard.press('Enter'); + await confirm.waitFor({ timeout: 1500 }); + await confirm.focus(); + await p.keyboard.press('Enter'); + st.keyboard = true; + } else if (!phone && !st.casinoPick) { + // Choose by tapping the highlighted casino card instead of the group button. + await p.locator('button.lv-casino').first().click({ timeout: 1500 }); + await confirm.waitFor({ timeout: 1500 }); + st.casinoPick = true; + await confirm.click({ timeout: 1500 }); + } else { + await press(target); + await confirm.waitFor({ timeout: 1500 }); + const tag = phone ? 'phone' : 'pc'; + if (st.places >= 6 && !st.shots.has(tag)) { + // Mid-game: a number group chosen, casino highlighted with its preview tag, light + dark. + st.shots.add(tag); + if (phone) await p.evaluate(() => window.scrollTo(0, 0)); + await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}.png`) }); + if (phone) await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-full.png`), fullPage: true }); + await p.emulateMedia({ colorScheme: 'dark' }); + await p.waitForTimeout(250); + await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-dark.png`) }); + await p.emulateMedia({ colorScheme: 'light' }); + } + await press(confirm); + } + st.places++; + await p.waitForFunction(() => !document.querySelector('.lv-group[aria-pressed="true"]'), null, { timeout: 5000 }); + return true; + } catch { + return false; + } +} + const PLANS: Record = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -879,6 +941,58 @@ const PLANS: Record = { console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); }, }, + 'las-vegas': { + // PC host + phone guest (2 players → 4 white dice each), 3 rounds via the lobby: real rolls and placements + // through the UI until round settlements (with a duplicate-tie cancellation) and the final result appear. + lobby: async (host) => { + await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '3라운드' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/^3라운드 · 흰 주사위 자동/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + const st = { rolls: 0, places: 0, keyboard: false, casinoPick: false, shots: new Set() }; + let tieSeen = false; + let settleShot = false; + const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + const until = Date.now() + 300_000; + while (Date.now() < until && !(await over())) { + let acted = false; + for (const [i, p] of pages.entries()) { + if (await lasVegasStep(p, i > 0, st)) { + acted = true; + break; + } + } + if (!settleShot && (await host.locator('section[aria-label="1라운드 정산"]').count())) { + settleShot = true; + await host.waitForTimeout(700); + await host.screenshot({ path: join(SHOTS, 'las-vegas-settle-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'las-vegas-settle-phone.png'), fullPage: true }); + } + if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true; + if (!acted) await host.waitForTimeout(150); + } + if (!(await over())) throw new Error(`game did not finish (${JSON.stringify({ ...st, shots: [...st.shots] })})`); + await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); + if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true; + await host.waitForTimeout(600); + await host.screenshot({ path: join(SHOTS, 'las-vegas-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'las-vegas-result-phone.png') }); + // Close the result sheet to look at the final table on the board. + await host.keyboard.press('Escape'); + await host.locator('section[aria-label="최종 결과"]').waitFor(); + await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc.png') }); + await host.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(250); + await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc-dark.png') }); + await host.emulateMedia({ colorScheme: 'light' }); + if (!(await host.locator('section[aria-label="3라운드 정산"]').count())) throw new Error('no settlement for round 3'); + if (!tieSeen) throw new Error('no duplicate-tie cancellation appeared in any settlement'); + if (st.rolls < 10 || st.places < 10 || !st.keyboard || !st.casinoPick) throw new Error(`too few real turns: ${JSON.stringify({ ...st, shots: [...st.shots] })}`); + console.log(` las-vegas: ${st.rolls} rolls, ${st.places} placements, tie cancellation seen`); + }, + }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 180c602..1e4e2e1 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -16,6 +16,7 @@ import { gostop } from './gostop'; import { bang } from './bang'; import { halliGalli } from './halli-galli'; import { marble } from './marble'; +import { lasVegas } from './las-vegas'; export const GAMES: Record = { [omok.id]: omok, @@ -35,4 +36,5 @@ export const GAMES: Record = { [bang.id]: bang, [halliGalli.id]: halliGalli, [marble.id]: marble, + [lasVegas.id]: lasVegas, }; diff --git a/packages/games/src/las-vegas/index.ts b/packages/games/src/las-vegas/index.ts new file mode 100644 index 0000000..014d838 --- /dev/null +++ b/packages/games/src/las-vegas/index.ts @@ -0,0 +1,492 @@ +/** + * 라스베가스 (docs/games/las-vegas.md). Hidden info: the bill pile (only its size is public) and, with + * `hideMoney`, the amounts other players won (only the number of bills is public until the end). + */ +import type { ZodType } from 'zod'; +import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine'; +import { + ALL_NOTES, + AWAY_AFTER, + AWAY_MS, + CASINO_VALUES, + HOUSE, + OWN_DICE, + autoPlaceValue, + diceLeft, + houseExtraFor, + lasVegasActionSchema, + lasVegasOptionsSchema, + neutralPerFor, + ownerOrder, + placementPreview, + refillCasinos, + settleCasino, + sum, + type CasinoValue, + type LasVegasAction, + type LasVegasOptions, + type LasVegasState, + type Note, + type Owner, + type Payout, +} from './rules'; + +export * from './rules'; + +// ---------------------------------------------------------------- result & view types + +export interface LasVegasRankRow { + rank: number; + player: PlayerId; + total: number; + notes: Note[]; + conceded: boolean; +} + +export interface LasVegasResult { + ranking: LasVegasRankRow[]; + winners: PlayerId[]; + /** Points won per round. */ + roundTotals: Record; + summary: string; + reason: GameResult['reason']; +} + +export interface LasVegasMoneyView { + count: number; + /** null when hidden from this viewer (`hideMoney`). */ + total: number | null; + notes: Note[] | null; +} + +export interface LasVegasPayoutView { + casino: number; + removedTies: Owner[]; + /** `note`/`key` are null for another player's bill while `hideMoney` hides amounts. */ + awards: { owner: Owner; note: Note | null; key: string | null }[]; + returned: Note[]; +} + +export interface LasVegasChoice { + value: number; + own: number; + neutral: number; + /** For the player to move, after placing these dice (§9 미리 보기). */ + standing: 'first' | 'award' | 'tie' | 'none'; + note: Note | null; + countAfter: number; +} + +export interface LasVegasView { + options: LasVegasOptions; + me: PlayerId | null; + seats: PlayerId[]; + round: number; + rounds: number; + startPlayer: PlayerId; + /** Player to act, null once finished. */ + current: PlayerId | null; + phase: LasVegasState['phase']; + pileCount: number; + casinos: { value: CasinoValue; notes: Note[]; dice: Partial> }[]; + hand: Record; + neutralPer: number; + houseExtra: number; + houseRoll: LasVegasState['houseRoll']; + lastRoll: LasVegasState['lastRoll']; + /** Number groups of the current roll with previews (phase `place`). */ + choices: LasVegasChoice[]; + /** What a timeout would pick now (phase `place`). */ + autoValue: number | null; + lastPlace: LasVegasState['lastPlace']; + money: Record; + roundResults: { round: number; payouts: LasVegasPayoutView[] }[]; + conceded: PlayerId[]; + away: PlayerId[]; + rollSeq: number; + turnSeq: number; + deadlineAt: number | null; + result: LasVegasResult | null; +} + +// ---------------------------------------------------------------- state machine + +type S = LasVegasState; + +const curId = (s: S): PlayerId => s.seats[s.current]!; +const isIn = (s: S, p: PlayerId) => !s.conceded.includes(p); +const activeIds = (s: S) => s.seats.filter((p) => isIn(s, p)); +/** Stable React key / leak-check token of one received bill. */ +export const awardKey = (round: number, casino: number, owner: Owner, note: Note): string => `lv-r${round}c${casino}-${owner}-${note}`; + +function activeOf(s: S): PlayerId[] { + return s.phase === 'finished' ? [] : [curId(s)]; +} + +function refreshDeadline(s: S, now: number): void { + if (s.phase === 'finished') { + s.deadlineAt = null; + return; + } + const away = (s.timeouts[curId(s)] ?? 0) >= AWAY_AFTER; + if (away) s.deadlineAt = now + AWAY_MS; + else s.deadlineAt = s.options.turnSeconds === null ? null : now + s.options.turnSeconds * 1000; +} + +function finish(s: S, ev: GameEvent[]): void { + s.phase = 'finished'; + s.lastRoll = null; + s.deadlineAt = null; + ev.push({ type: 'gameOver' }); +} + +function startRound(s: S, rng: Rng, ev: GameEvent[]): void { + for (const p of s.seats) s.hand[p] = isIn(s, p) ? { own: OWN_DICE, neutral: s.neutralPer } : { own: 0, neutral: 0 }; + refillCasinos(s); + ev.push({ type: 'casinosRefilled', round: s.round, casinos: s.casinos.map((c) => [...c.notes]) }); + s.houseRoll = null; + if (s.houseExtra > 0) { + const values: number[] = []; + for (let i = 0; i < s.houseExtra; i++) values.push(1 + rng.int(6)); + for (const v of values) { + const c = s.casinos[v - 1]!; + c.dice[HOUSE] = (c.dice[HOUSE] ?? 0) + 1; + } + const player = s.seats[s.startSeat]!; + s.houseRoll = { round: s.round, player, values }; + ev.push({ type: 'houseDicePlaced', player, values: [...values] }); + } + s.current = diceLeft(s, s.seats[s.startSeat]!) > 0 ? s.startSeat : nextSeat(s.startSeat, s.seats.length, (i) => diceLeft(s, s.seats[i]!) === 0); + s.phase = 'roll'; + s.lastRoll = null; + s.turnSeq++; + ev.push({ type: 'roundStart', round: s.round, player: curId(s) }); +} + +function settleRound(s: S, ev: GameEvent[]): void { + const payouts: Payout[] = []; + const back: Note[] = []; + const order = ownerOrder(s); + for (const c of s.casinos) { + const { removedTies, awards, returned } = settleCasino(c.notes, c.dice, order); + if (removedTies.length) ev.push({ type: 'tiesRemoved', casino: c.value, owners: [...removedTies] }); + for (const a of awards) { + if (a.owner !== HOUSE) s.money[a.owner]!.push(a.note); + ev.push({ type: 'paid', casino: c.value, owner: a.owner, ...(a.owner === HOUSE || !s.options.hideMoney ? { note: a.note } : {}) }); + } + back.push(...returned); + payouts.push({ casino: c.value, removedTies, awards, returned }); + c.notes = []; + c.dice = {}; + } + s.pile.push(...back); + if (back.length) ev.push({ type: 'notesReturned', notes: back }); + s.roundResults.push({ round: s.round, payouts }); + ev.push({ type: 'roundEnd', round: s.round }); +} + +/** Next seat with dice left (the same player again if only they have dice); settles the round when nobody has. */ +function advance(s: S, rng: Rng, ev: GameEvent[]): void { + s.lastRoll = null; + const n = s.seats.length; + const hasDice = (i: number) => diceLeft(s, s.seats[i]!) > 0; + const next = nextSeat(s.current, n, (i) => !hasDice(i)); + if (hasDice(next)) { + s.current = next; + s.phase = 'roll'; + s.turnSeq++; + ev.push({ type: 'turnChanged', player: curId(s) }); + return; + } + settleRound(s, ev); + if (s.round >= s.options.rounds) { + finish(s, ev); + return; + } + s.round++; + s.startSeat = nextSeat(s.startSeat, n, (i) => !isIn(s, s.seats[i]!)); + startRound(s, rng, ev); +} + +function doRoll(s: S, rng: Rng, ev: GameEvent[]): void { + const p = curId(s); + const h = s.hand[p]!; + const own: number[] = []; + const neutral: number[] = []; + for (let i = 0; i < h.own; i++) own.push(1 + rng.int(6)); + for (let i = 0; i < h.neutral; i++) neutral.push(1 + rng.int(6)); + s.lastRoll = { own, neutral }; + s.phase = 'place'; + s.rollSeq++; + ev.push({ type: 'rolled', player: p, own: [...own], neutral: [...neutral] }); +} + +function doPlace(s: S, value: number, auto: boolean, rng: Rng, ev: GameEvent[]): void { + const p = curId(s); + const roll = s.lastRoll; + if (!roll) throw new Error('las-vegas: place without a roll'); + const ownCount = roll.own.filter((d) => d === value).length; + const neutralCount = roll.neutral.filter((d) => d === value).length; + if (ownCount + neutralCount === 0) throw new Error('las-vegas: value not rolled'); + const c = s.casinos[value - 1]!; + if (ownCount) c.dice[p] = (c.dice[p] ?? 0) + ownCount; + if (neutralCount) c.dice[HOUSE] = (c.dice[HOUSE] ?? 0) + neutralCount; + s.hand[p]!.own -= ownCount; + s.hand[p]!.neutral -= neutralCount; + s.lastPlace = { player: p, value, own: ownCount, neutral: neutralCount, auto }; + ev.push({ type: 'placed', player: p, value, ownCount, neutralCount, auto }); + advance(s, rng, ev); +} + +function doConcede(s: S, p: PlayerId, rng: Rng, ev: GameEvent[]): void { + const wasCurrent = curId(s) === p; + s.conceded.push(p); + s.hand[p] = { own: 0, neutral: 0 }; + for (const c of s.casinos) delete c.dice[p]; + ev.push({ type: 'conceded', player: p }); + if (activeIds(s).length <= 1) { + finish(s, ev); + return; + } + if (wasCurrent) advance(s, rng, ev); +} + +// ---------------------------------------------------------------- result + +function rankRows(s: S): LasVegasRankRow[] { + const rows = s.seats.map((p) => ({ player: p, total: sum(s.money[p]!), notes: [...s.money[p]!].sort((a, b) => b - a), conceded: !isIn(s, p) })); + rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.total - a.total || b.notes.length - a.notes.length); + const out: LasVegasRankRow[] = []; + rows.forEach((r, i) => { + const prev = out[i - 1]; + const tied = prev && prev.conceded === r.conceded && prev.total === r.total && prev.notes.length === r.notes.length; + out.push({ ...r, rank: tied ? prev.rank : i + 1 }); + }); + return out; +} + +function buildResult(s: S): LasVegasResult { + const ranking = rankRows(s); + const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player); + const roundTotals: Record = {}; + for (const p of s.seats) + roundTotals[p] = s.roundResults.map((rr) => sum(rr.payouts.flatMap((po) => po.awards.filter((a) => a.owner === p).map((a) => a.note)))); + const top = ranking[0]!; + const early = s.conceded.length > 0 && activeIds(s).length <= 1; + let summary: string; + if (winners.length > 1) summary = `공동 1위 ${winners.length}명, ${top.total}점 (지폐 ${top.notes.length}장)`; + else { + const second = ranking[1]; + summary = `1위 ${top.total}점 (지폐 ${top.notes.length}장)${second ? ` · 2위 ${second.total}점` : ''}`; + } + if (early) summary = `다른 사람이 모두 기권했어요. ${summary}`; + return { ranking, winners, roundTotals, summary, reason: early ? 'resign' : 'normal' }; +} + +// ---------------------------------------------------------------- definition + +export const lasVegas: GameDefinition = { + id: 'las-vegas', + nameKo: '라스베가스', + minPlayers: 2, + maxPlayers: 5, + stateVersion: 1, + defaultOptions: lasVegasOptionsSchema.parse({}), + optionsSchema: lasVegasOptionsSchema as unknown as ZodType, + actionSchema: lasVegasActionSchema as unknown as ZodType, + + playerCountError(options, count) { + return neutralPerFor(options.neutralDice, count) === null ? '5명일 때는 중립(흰) 주사위를 켤 수 없어요. "자동"이나 "끄기"로 바꿔 주세요.' : null; + }, + + setup({ players, options, rng, now }) { + if (players.length < 2 || players.length > 5) throw new Error('las-vegas: 2~5명이 할 수 있어요.'); + if (players.includes(HOUSE)) throw new Error('las-vegas: reserved player id'); + const neutralPer = neutralPerFor(options.neutralDice, players.length); + if (neutralPer === null) throw new Error('las-vegas: 5명일 때는 중립 주사위를 켤 수 없어요.'); + const pile = rng.shuffle(ALL_NOTES); + const startSeat = rng.int(players.length); + const s: S = { + options, + seats: [...players], + round: 1, + startSeat, + current: startSeat, + phase: 'roll', + pile, + casinos: CASINO_VALUES.map((value) => ({ value, notes: [], dice: {} })), + hand: {}, + lastRoll: null, + money: Object.fromEntries(players.map((p) => [p, []])), + roundResults: [], + neutralPer, + houseExtra: houseExtraFor(neutralPer, players.length), + houseRoll: null, + lastPlace: null, + conceded: [], + timeouts: Object.fromEntries(players.map((p) => [p, 0])), + rollSeq: 0, + turnSeq: 0, + deadlineAt: null, + }; + startRound(s, rng, []); + refreshDeadline(s, now); + return s; + }, + + validate(s, actor, a) { + if (!s.money[actor]) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); + if (!isIn(s, actor)) return fail('이미 기권했어요.'); + if (a.type === 'CONCEDE') return ok; + if (a.type === 'TIMEOUT') return activeOf(s).includes(actor) ? ok : fail('지금은 시간 초과를 처리할 수 없어요.'); + if (curId(s) !== actor) return fail('지금은 내 차례가 아니에요.'); + switch (a.type) { + case 'ROLL': + if (s.phase !== 'roll' || diceLeft(s, actor) === 0) return fail('지금은 굴릴 차례가 아니에요.'); + return ok; + case 'PLACE': { + if (s.phase !== 'place' || !s.lastRoll) return fail('먼저 주사위를 굴려 주세요.'); + const v = a.value; + if (!Number.isInteger(v) || !s.lastRoll.own.concat(s.lastRoll.neutral).includes(v)) return fail('그 숫자는 나오지 않았어요.'); + return ok; + } + } + return fail('알 수 없는 행동이에요.'); + }, + + apply(prev, actor, a, { rng, now }) { + const s = structuredClone(prev); + const ev: GameEvent[] = []; + if (a.type === 'TIMEOUT') { + s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1; + ev.push({ type: 'timeout', player: actor }); + if (s.timeouts[actor] === AWAY_AFTER) ev.push({ type: 'away', player: actor }); + if (s.phase === 'roll') doRoll(s, rng, ev); + else doPlace(s, autoPlaceValue(s, s.lastRoll!), true, rng, ev); + } else { + if (a.type !== 'CONCEDE') s.timeouts[actor] = 0; + switch (a.type) { + case 'ROLL': + if (s.phase !== 'roll') throw new Error('las-vegas: not the roll phase'); + doRoll(s, rng, ev); + break; + case 'PLACE': + if (s.phase !== 'place') throw new Error('las-vegas: not the place phase'); + doPlace(s, a.value, false, rng, ev); + break; + case 'CONCEDE': + doConcede(s, actor, rng, ev); + break; + } + } + refreshDeadline(s, now); + return { state: s, events: ev }; + }, + + view(s, viewer) { + const me = viewer !== null && s.money[viewer] ? viewer : null; + const done = s.phase === 'finished'; + const visible = (p: Owner) => done || !s.options.hideMoney || p === me || p === HOUSE; + const money: LasVegasView['money'] = {}; + for (const p of s.seats) { + const notes = s.money[p]!; + money[p] = visible(p) + ? { count: notes.length, total: sum(notes), notes: [...notes].sort((a, b) => b - a) } + : { count: notes.length, total: null, notes: null }; + } + const roundResults = s.roundResults.map((rr) => ({ + round: rr.round, + payouts: rr.payouts.map((po) => ({ + casino: po.casino, + removedTies: [...po.removedTies], + awards: po.awards.map((aw) => + visible(aw.owner) + ? { owner: aw.owner, note: aw.note, key: awardKey(rr.round, po.casino, aw.owner, aw.note) } + : { owner: aw.owner, note: null, key: null }, + ), + returned: [...po.returned], + })), + })); + const cur = done ? null : curId(s); + const choices: LasVegasChoice[] = []; + if (cur && s.phase === 'place' && s.lastRoll) { + for (const v of CASINO_VALUES) { + const own = s.lastRoll.own.filter((d) => d === v).length; + const neutral = s.lastRoll.neutral.filter((d) => d === v).length; + if (own + neutral === 0) continue; + const pv = placementPreview(s, cur, v, own, neutral); + choices.push({ value: v, own, neutral, standing: pv.standing, note: pv.note, countAfter: pv.count }); + } + } + return { + options: s.options, + me, + seats: [...s.seats], + round: s.round, + rounds: s.options.rounds, + startPlayer: s.seats[s.startSeat]!, + current: cur, + phase: s.phase, + pileCount: s.pile.length, + casinos: s.casinos.map((c) => ({ value: c.value, notes: [...c.notes], dice: { ...c.dice } })), + hand: Object.fromEntries(s.seats.map((p) => [p, { ...s.hand[p]! }])), + neutralPer: s.neutralPer, + houseExtra: s.houseExtra, + houseRoll: s.houseRoll, + lastRoll: s.lastRoll, + choices, + autoValue: cur && s.phase === 'place' && s.lastRoll ? autoPlaceValue(s, s.lastRoll) : null, + lastPlace: s.lastPlace, + money, + roundResults, + conceded: [...s.conceded], + away: s.seats.filter((p) => isIn(s, p) && (s.timeouts[p] ?? 0) >= AWAY_AFTER), + rollSeq: s.rollSeq, + turnSeq: s.turnSeq, + deadlineAt: s.deadlineAt, + result: done ? buildResult(s) : null, + }; + }, + + activePlayers: activeOf, + + deadline(s) { + return s.phase === 'finished' ? null : s.deadlineAt; + }, + + onTimeout() { + return { type: 'TIMEOUT' }; + }, + + onLeave(s, player) { + if (s.phase === 'finished' || !s.money[player] || !isIn(s, player)) return null; + return { type: 'CONCEDE' }; + }, + + result(s): GameResult | null { + if (s.phase !== 'finished') return null; + const r = buildResult(s); + const groups = new Map(); + for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]); + const scores: Record = {}; + for (const row of r.ranking) scores[row.player] = row.total; + return { ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps), scores, summary: r.summary, reason: r.reason }; + }, + + /** Test helper: ROLL, or one PLACE per rolled number (CONCEDE left out). */ + legalActions(s, player) { + if (s.phase === 'finished' || curId(s) !== player || !isIn(s, player)) return []; + if (s.phase === 'roll') return [{ type: 'ROLL' }]; + const vals = [...new Set([...s.lastRoll!.own, ...s.lastRoll!.neutral])].sort((a, b) => a - b); + return vals.map((value) => ({ type: 'PLACE' as const, value })); + }, + + /** Test helper: keys of this player's bills that other viewers must not see (while amounts are hidden). */ + secretsOf(s, player) { + if (s.phase === 'finished' || !s.options.hideMoney) return []; + return s.roundResults.flatMap((rr) => + rr.payouts.flatMap((po) => po.awards.filter((a) => a.owner === player).map((a) => awardKey(rr.round, po.casino, player, a.note))), + ); + }, +}; diff --git a/packages/games/src/las-vegas/las-vegas.test.ts b/packages/games/src/las-vegas/las-vegas.test.ts new file mode 100644 index 0000000..60d3715 --- /dev/null +++ b/packages/games/src/las-vegas/las-vegas.test.ts @@ -0,0 +1,712 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine'; +import { + ALL_NOTES, + AWAY_AFTER, + AWAY_MS, + HOUSE, + MIN_CASINO_SUM, + NOTE_COUNTS, + OWN_DICE, + autoPlaceValue, + lasVegas as lv, + lasVegasActionSchema, + lasVegasOptionsSchema, + refillCasinos, + settleCasino, + sum, + type LasVegasAction, + type LasVegasOptions, + type LasVegasState, + type LasVegasView, +} from './index'; + +// ---------------------------------------------------------------- helpers + +let clock = 1_000_000; +const rng = () => SeededRng.fromSeed([5, 4, 3, 2]); + +function make(opts: Partial = {}, players = ['A', 'B', 'C'], seed = 1): LasVegasState { + const options = lasVegasOptionsSchema.parse(opts); + return lv.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock }); +} + +/** A game whose round-1 first player is seat 0 (seeds are searched). */ +function makeFirst(opts: Partial = {}, players = ['A', 'B', 'C']): LasVegasState { + for (let seed = 1; seed < 500; seed++) { + const s = make(opts, players, seed); + if (s.startSeat === 0 && (s.houseExtra === 0 || s.houseRoll)) return s; + } + throw new Error('no seed starts at seat 0'); +} + +function actE(s: LasVegasState, p: string, a: LasVegasAction, r: SeededRng = rng()): { state: LasVegasState; events: GameEvent[] } { + const v = lv.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return lv.apply(s, p, a, { rng: r, now: (clock += 1000) }); +} +const act = (s: LasVegasState, p: string, a: LasVegasAction, r?: SeededRng) => actE(s, p, a, r).state; +const reject = (s: LasVegasState, p: string, a: LasVegasAction) => { + const v = lv.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const cur = (s: LasVegasState) => s.seats[s.current]!; + +/** Rolls for the current player, then forces the dice (lengths must match the hand). */ +function rolled(s: LasVegasState, own: number[], neutral: number[] = []): LasVegasState { + const p = cur(s); + const t = act(s, p, { type: 'ROLL' }); + expect(t.lastRoll!.own.length).toBe(own.length); + expect(t.lastRoll!.neutral.length).toBe(neutral.length); + t.lastRoll = { own: [...own], neutral: [...neutral] }; + return t; +} + +/** Sets every casino's bills (missing = no bills); bills come out of the pile so 54 are conserved. */ +function setBills(s: LasVegasState, bills: Record): void { + const pool = [...s.pile, ...s.casinos.flatMap((c) => c.notes)]; + for (const c of s.casinos) { + c.notes = [...(bills[c.value] ?? [])].sort((a, b) => b - a); + for (const n of c.notes) { + const i = pool.indexOf(n); + if (i < 0) throw new Error(`no bill ${n} left`); + pool.splice(i, 1); + } + } + s.pile = pool; +} + +/** Puts dice on a casino straight from the owners' hands (house dice need no hand). */ +function putDice(s: LasVegasState, value: number, dice: Record): void { + const c = s.casinos[value - 1]!; + for (const [o, n] of Object.entries(dice)) { + c.dice[o] = (c.dice[o] ?? 0) + n; + if (o !== HOUSE) s.hand[o]!.own -= n; + } +} + +const allBills = (s: LasVegasState) => [...s.pile, ...s.casinos.flatMap((c) => c.notes), ...Object.values(s.money).flat()].sort((a, b) => a - b); +const SORTED_NOTES = [...ALL_NOTES].sort((a, b) => a - b); + +// ---------------------------------------------------------------- setup + +describe('setup (§3, §4, §10)', () => { + test('54 bills 6·8·8·6·6·5·5·5·5 = 250', () => { + expect(ALL_NOTES.length).toBe(54); + expect(sum(ALL_NOTES)).toBe(250); + expect(Object.values(NOTE_COUNTS)).toEqual([6, 8, 8, 6, 6, 5, 5, 5, 5]); + for (let seed = 1; seed <= 50; seed++) expect(allBills(make({}, ['A', 'B'], seed))).toEqual(SORTED_NOTES); + }); + + test('each casino ≥ 5, and without the last bill dealt it would be < 5 (dealt from the shuffled pile, top first)', () => { + for (let seed = 1; seed <= 200; seed++) { + const s = make({}, ['A', 'B', 'C'], seed); + // Re-create the shuffle: setup shuffles first. + const pile = SeededRng.fromSeed(seedN(seed)).shuffle(ALL_NOTES); + for (const c of s.casinos) { + const dealt: number[] = []; + while (sum(dealt) < MIN_CASINO_SUM) dealt.push(pile.shift()!); + expect(c.notes).toEqual([...dealt].sort((a, b) => b - a)); + expect(sum(c.notes)).toBeGreaterThanOrEqual(5); + expect(sum(dealt) - dealt[dealt.length - 1]!).toBeLessThan(5); + } + expect(s.pile).toEqual(pile); + } + }); + + test('refill: [1,1,1,1,9,…] gives casino 1 four 1s + 9; an empty pile leaves casinos short', () => { + const t = { pile: [1, 1, 1, 1, 9, 5, 2, 3, 6], casinos: [1, 2, 3, 4, 5, 6].map((v) => ({ value: v as 1, notes: [] as number[], dice: {} })) }; + refillCasinos(t); + expect(t.casinos.map((c) => c.notes)).toEqual([[9, 1, 1, 1, 1], [5], [3, 2], [6], [], []]); + expect(t.pile).toEqual([]); + }); + + test('dice per player: 8 own; neutral auto = 4 each for 2 players only; on = 4 (2p) / 2 (3–4p); off = 0', () => { + expect(make({}, ['A', 'B']).hand.A).toEqual({ own: 8, neutral: 4 }); + expect(make({}, ['A', 'B', 'C']).hand.A).toEqual({ own: 8, neutral: 0 }); + expect(make({ neutralDice: 'on' }, ['A', 'B']).hand.B).toEqual({ own: 8, neutral: 4 }); + expect(make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D']).hand.D).toEqual({ own: 8, neutral: 2 }); + expect(make({ neutralDice: 'off' }, ['A', 'B']).hand.A).toEqual({ own: 8, neutral: 0 }); + expect(make({}, ['A', 'B', 'C', 'D', 'E']).hand.E).toEqual({ own: 8, neutral: 0 }); + }); + + test('5 players + neutral on is rejected (option check), auto/off are fine', () => { + const on = lasVegasOptionsSchema.parse({ neutralDice: 'on' }); + expect(lv.playerCountError!(on, 5)).toContain('5명'); + expect(lv.playerCountError!(on, 4)).toBeNull(); + expect(lv.playerCountError!(lasVegasOptionsSchema.parse({}), 5)).toBeNull(); + expect(() => make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D', 'E'])).toThrow(); + }); + + test('the first player is chosen by the RNG (varies by seed)', () => { + const starts = new Set(); + for (let seed = 1; seed <= 40; seed++) starts.add(make({}, ['A', 'B', 'C', 'D'], seed).startSeat); + expect(starts.size).toBe(4); + }); +}); + +// ---------------------------------------------------------------- turns + +describe('ROLL / PLACE (§5, §8.2, §10)', () => { + test('PLACE moves every die of that number (own + neutral); unrolled numbers and out-of-turn are rejected', () => { + let s = makeFirst({ neutralDice: 'on' }, ['A', 'B']); + expect(reject(s, 'A', { type: 'PLACE', value: 3 })).toBe('먼저 주사위를 굴려 주세요.'); + expect(reject(s, 'B', { type: 'ROLL' })).toBe('지금은 내 차례가 아니에요.'); + s = rolled(s, [3, 3, 5, 3, 1, 1, 2, 2], [3, 6, 6, 4]); + expect(reject(s, 'A', { type: 'ROLL' })).toBe('지금은 굴릴 차례가 아니에요.'); + expect(reject(s, 'A', { type: 'PLACE', value: 4.5 } as never)).toBe('그 숫자는 나오지 않았어요.'); + const v = lv.view(s, 'A'); + expect(v.choices.map((c) => [c.value, c.own, c.neutral])).toEqual([ + [1, 2, 0], + [2, 2, 0], + [3, 3, 1], + [4, 0, 1], + [5, 1, 0], + [6, 0, 2], + ]); + const { state, events } = actE(s, 'A', { type: 'PLACE', value: 3 }); + expect(state.casinos[2]!.dice).toEqual({ A: 3, house: 1 }); + expect(state.hand.A).toEqual({ own: 5, neutral: 3 }); + expect(events.find((e) => e.type === 'placed')).toMatchObject({ player: 'A', value: 3, ownCount: 3, neutralCount: 1 }); + expect(cur(state)).toBe('B'); + expect(state.phase).toBe('roll'); + }); + + test('a number that only came up on neutral dice may be chosen: only the neutral dice move', () => { + let s = makeFirst({}, ['A', 'B']); + s = rolled(s, [1, 1, 2, 2, 3, 3, 4, 4], [6, 6, 5, 1]); + expect(reject(s, 'A', { type: 'PLACE', value: 6 })).toBeNull(); + s = act(s, 'A', { type: 'PLACE', value: 6 }); + expect(s.casinos[5]!.dice).toEqual({ house: 2 }); + expect(s.hand.A).toEqual({ own: 8, neutral: 2 }); + }); + + test('players without dice are skipped; the last one with dice takes turns in a row', () => { + let s = makeFirst({}, ['A', 'B', 'C']); + // A places all 8 at once, B 7 of 8, C all 8. + s = rolled(s, [2, 2, 2, 2, 2, 2, 2, 2]); + s = act(s, 'A', { type: 'PLACE', value: 2 }); + s = rolled(s, [4, 4, 4, 4, 4, 4, 4, 1]); + s = act(s, 'B', { type: 'PLACE', value: 4 }); + expect(cur(s)).toBe('C'); + s = rolled(s, [5, 5, 5, 5, 5, 5, 5, 5]); + s = act(s, 'C', { type: 'PLACE', value: 5 }); + // A has no dice: B again. + expect(cur(s)).toBe('B'); + expect(s.hand.B).toEqual({ own: 1, neutral: 0 }); + s = rolled(s, [6]); + const { state, events } = actE(s, 'B', { type: 'PLACE', value: 6 }); + expect(events.some((e) => e.type === 'roundEnd')).toBe(true); + expect(state.round).toBe(2); + }); + + test('3 players: A,B used up → C rolls several times in a row', () => { + let s = makeFirst({}, ['A', 'B', 'C']); + s = rolled(s, [2, 2, 2, 2, 2, 2, 2, 2]); + s = act(s, 'A', { type: 'PLACE', value: 2 }); + s = rolled(s, [3, 3, 3, 3, 3, 3, 3, 3]); + s = act(s, 'B', { type: 'PLACE', value: 3 }); + s = rolled(s, [1, 1, 1, 1, 4, 4, 4, 4]); + s = act(s, 'C', { type: 'PLACE', value: 1 }); + expect(cur(s)).toBe('C'); + s = rolled(s, [4, 4, 5, 6]); + s = act(s, 'C', { type: 'PLACE', value: 4 }); + expect(cur(s)).toBe('C'); + expect(s.round).toBe(1); + }); +}); + +// ---------------------------------------------------------------- settlement + +describe('settlement (§5, §5.1, §10)', () => { + const order = ['A', 'B', 'C', 'D', HOUSE]; + test('example 1: [8,3,1] A5 B3 C3 D1 → B·C removed, A 8, D 3, 1 returned', () => { + const r = settleCasino([8, 3, 1], { A: 5, B: 3, C: 3, D: 1 }, order); + expect(r.removedTies).toEqual(['B', 'C']); + expect(r.awards).toEqual([ + { owner: 'A', note: 8 }, + { owner: 'D', note: 3 }, + ]); + expect(r.returned).toEqual([1]); + }); + test('example 2: [4,4] A2 C2 B1 D1 → everyone removed, both bills returned', () => { + const r = settleCasino([4, 4], { A: 2, C: 2, B: 1, D: 1 }, order); + expect(r.removedTies).toEqual(['A', 'B', 'C', 'D']); + expect(r.awards).toEqual([]); + expect(r.returned).toEqual([4, 4]); + }); + test('example 3: [7,2] C4 B2 D1 → C 7, B 2, D nothing', () => { + const r = settleCasino([7, 2], { C: 4, B: 2, D: 1 }, order); + expect(r.removedTies).toEqual([]); + expect(r.awards).toEqual([ + { owner: 'C', note: 7 }, + { owner: 'B', note: 2 }, + ]); + expect(r.returned).toEqual([]); + }); + test('example 4 (neutral, 2 players): [9] house 3, A 2, B 1 → the house takes 9 (returned), A·B nothing', () => { + const r = settleCasino([9], { house: 3, A: 2, B: 1 }, ['A', 'B', HOUSE]); + expect(r.awards).toEqual([{ owner: HOUSE, note: 9 }]); + expect(r.returned).toEqual([9]); + }); + test('house and a player tie → both removed', () => { + const r = settleCasino([6, 5], { house: 2, A: 2, B: 1 }, ['A', 'B', HOUSE]); + expect(r.removedTies).toEqual(['A', HOUSE]); + expect(r.awards).toEqual([{ owner: 'B', note: 6 }]); + expect(r.returned).toEqual([5]); + }); + + test('a full round in play: payouts, money, roundResults and bills back to the bottom in casino 1→6 order, high first', () => { + let s = makeFirst({}, ['A', 'B']); + setBills(s, { 1: [9], 2: [8, 3, 1], 3: [4, 4], 4: [7, 2], 5: [5], 6: [6, 1] }); + const pileBefore = [...s.pile]; + // Board before the last placement: casino 1 house 3/A 2/B 1, casino 2 A3/B3, casino 3 A2/B2, casino 4 B1, casino 6 house 1/A 1. + putDice(s, 1, { house: 3, A: 2, B: 1 }); + putDice(s, 2, { A: 3, B: 3 }); + putDice(s, 3, { A: 2, B: 2 }); + putDice(s, 4, { B: 1 }); + putDice(s, 6, { house: 1, A: 1 }); + s.hand.A = { own: 0, neutral: 0 }; + s.hand.B = { own: 1, neutral: 0 }; + s.current = 1; + s = rolled(s, [4]); + const { state, events } = actE(s, 'B', { type: 'PLACE', value: 4 }); + const rr = state.roundResults[0]!; + expect(rr.round).toBe(1); + expect(rr.payouts.map((p) => [p.casino, p.removedTies, p.awards.map((a) => `${a.owner}:${a.note}`), p.returned])).toEqual([ + [1, [], ['house:9'], [9]], + [2, ['A', 'B'], [], [8, 3, 1]], + [3, ['A', 'B'], [], [4, 4]], + [4, [], ['B:7'], [2]], + [5, [], [], [5]], + [6, ['A', 'house'], [], [6, 1]], + ]); + expect(state.money).toEqual({ A: [], B: [7] }); + // Returned bills go under the old pile (then casinos are refilled from the top for round 2). + const expectedPile = [...pileBefore, 9, 8, 3, 1, 4, 4, 2, 5, 6, 1]; + const dealtRound2 = state.casinos.flatMap((c) => c.notes).length; + expect(state.pile).toEqual(expectedPile.slice(dealtRound2)); + expect(events.filter((e) => e.type === 'tiesRemoved').map((e) => e.casino)).toEqual([2, 3, 6]); + expect(events.find((e) => e.type === 'notesReturned')).toMatchObject({ notes: [9, 8, 3, 1, 4, 4, 2, 5, 6, 1] }); + // hideMoney (default): the paid event hides B's amount, the house's is public. + expect(events.filter((e) => e.type === 'paid')).toEqual([ + { type: 'paid', casino: 1, owner: HOUSE, note: 9 }, + { type: 'paid', casino: 4, owner: 'B' }, + ]); + expect(allBills(state)).toEqual(SORTED_NOTES); + }); +}); + +// ---------------------------------------------------------------- rounds + +describe('rounds (§4.5, §5, §8.2, §10)', () => { + /** Plays the round out: everyone puts all dice on `value` (cycling 1..6 per player). */ + function playRound(s: LasVegasState): LasVegasState { + const round = s.round; + let guard = 0; + while (s.round === round && s.phase !== 'finished' && guard++ < 200) { + const p = cur(s); + const h = s.hand[p]!; + const v = 1 + (s.seats.indexOf(p) % 6); + s = rolled(s, Array(h.own).fill(v), Array(h.neutral).fill(v)); + s = act(s, p, { type: 'PLACE', value: v }); + } + return s; + } + + test('the first player moves one seat left each round; the game ends after `rounds` (4 by default)', () => { + let s = make({}, ['A', 'B', 'C'], 3); + const firsts: number[] = [s.startSeat]; + for (let r = 1; r <= 4; r++) { + expect(s.round).toBe(r); + expect(cur(s)).toBe(s.seats[s.startSeat]!); + s = playRound(s); + if (r < 4) firsts.push(s.startSeat); + } + expect(s.phase).toBe('finished'); + expect(s.roundResults.length).toBe(4); + expect(firsts).toEqual([0, 1, 2, 3].map((i) => (firsts[0]! + i) % 3)); + expect(lv.result(s)).not.toBeNull(); + }); + + test('rounds = 3 ends after three rounds; dice come back each round', () => { + let s = make({ rounds: 3 }, ['A', 'B'], 2); + s = playRound(s); + expect(s.hand).toEqual({ A: { own: 8, neutral: 4 }, B: { own: 8, neutral: 4 } }); + expect(s.casinos.every((c) => Object.keys(c.dice).length === 0 && sum(c.notes) >= 5)).toBe(true); + s = playRound(playRound(s)); + expect(s.phase).toBe('finished'); + }); + + test('3 players + neutral on: at every round start the first player rolls the 2 spare house dice onto casinos', () => { + let s = make({ neutralDice: 'on' }, ['A', 'B', 'C'], 4); + expect(s.hand.A).toEqual({ own: 8, neutral: 2 }); + const house = () => s.casinos.reduce((n, c) => n + (c.dice.house ?? 0), 0); + expect(house()).toBe(2); + expect(s.houseRoll).toMatchObject({ round: 1, player: s.seats[s.startSeat] }); + for (const v of s.houseRoll!.values) expect(s.casinos[v - 1]!.dice.house).toBeGreaterThanOrEqual(1); + const r = rng(); + const t = make({ neutralDice: 'on' }, ['A', 'B', 'C'], 4); + t.hand = { A: { own: 0, neutral: 0 }, B: { own: 0, neutral: 0 }, C: { own: 1, neutral: 0 } }; + t.current = 2; + t.phase = 'place'; + t.lastRoll = { own: [1], neutral: [] }; + const { state, events } = lv.apply(t, 'C', { type: 'PLACE', value: 1 }, { rng: r, now: 5 }); + expect(events.find((e) => e.type === 'houseDicePlaced')).toMatchObject({ player: state.seats[state.startSeat] }); + s = state; + expect(s.round).toBe(2); + expect(house()).toBe(2); + expect(s.houseRoll!.round).toBe(2); + // 4 players + on: no spare dice. + expect(make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D']).houseRoll).toBeNull(); + }); +}); + +// ---------------------------------------------------------------- result + +describe('final ranking (§6, §8.6, §10)', () => { + function finished(money: Record): LasVegasState { + const s = make({}, Object.keys(money), 1); + s.money = money; + s.phase = 'finished'; + return s; + } + test('higher total wins; equal totals → more bills wins; equal both → joint rank', () => { + let r = lv.result(finished({ A: [9, 9], B: [6, 6, 6], C: [5] }))!; + expect(r.ranking).toEqual([['B'], ['A'], ['C']]); + r = lv.result(finished({ A: [9, 9], B: [6, 6, 6], C: [9, 4, 5] }))!; + expect(r.ranking).toEqual([['B', 'C'], ['A']]); + expect(r.summary).toBe('공동 1위 2명, 18점 (지폐 3장)'); + r = lv.result(finished({ A: [9, 8], B: [6, 6] }))!; + expect(r.summary).toBe('1위 17점 (지폐 2장) · 2위 12점'); + expect(r.scores).toEqual({ A: 17, B: 12 }); + expect(r.reason).toBe('normal'); + const v = lv.view(finished({ A: [9, 8], B: [6, 6] }), null); + expect(v.result!.ranking.map((x) => [x.rank, x.player, x.total, x.notes])).toEqual([ + [1, 'A', 17, [9, 8]], + [2, 'B', 12, [6, 6]], + ]); + // Everything is revealed at the end. + expect(v.money.B).toEqual({ count: 2, total: 12, notes: [6, 6] }); + }); +}); + +// ---------------------------------------------------------------- timeout / away / leave + +describe('timeout, away and leaving (§8.5, §10)', () => { + test('timeout rolls in the roll phase, then places the number with the most dice', () => { + let s = makeFirst({ turnSeconds: 20 }, ['A', 'B']); + expect(s.deadlineAt).toBe(clock + 20_000); + expect(lv.onTimeout(s, 'A')).toEqual({ type: 'TIMEOUT' }); + s = act(s, 'A', { type: 'TIMEOUT' }); + expect(s.phase).toBe('place'); + expect(s.deadlineAt).toBe(clock + 20_000); + s.lastRoll = { own: [2, 2, 5, 5, 5, 1, 1, 6], neutral: [2, 3, 4, 6] }; + // 2 and 5 both have 3 dice (one of the 2s is neutral) → the better top bill decides. + setBills(s, { 2: [3, 2], 5: [9] }); + expect(autoPlaceValue(s, s.lastRoll)).toBe(5); + expect(lv.view(s, 'B').autoValue).toBe(5); + setBills(s, { 2: [9], 5: [9], 6: [9] }); + expect(autoPlaceValue(s, s.lastRoll)).toBe(2); // same count and bill → smaller number + s.lastRoll = { own: [4, 4, 4, 4, 1, 2, 3, 6], neutral: [6, 6, 1, 1] }; + const { state, events } = actE(s, 'A', { type: 'TIMEOUT' }); + expect(state.casinos[3]!.dice.A).toBe(4); + expect(events.find((e) => e.type === 'placed')).toMatchObject({ value: 4, auto: true }); + expect(state.lastPlace!.auto).toBe(true); + }); + + test('turnSeconds null → no deadline; 3 timeouts in a row → away (5 s deadline); acting again clears it', () => { + let s = makeFirst({ turnSeconds: null }, ['A', 'B']); + expect(lv.deadline!(s)).toBeNull(); + for (let i = 0; i < AWAY_AFTER; i++) s = act(s, cur(s), { type: 'TIMEOUT' }); + // A: roll, place (B's turn), B: roll — B 1 timeout, A 2. + expect(lv.view(s, null).away).toEqual([]); + s = act(s, 'B', { type: 'TIMEOUT' }); // B place → A + const { state, events } = actE(s, 'A', { type: 'TIMEOUT' }); // A's 3rd + expect(events.some((e) => e.type === 'away' && e.player === 'A')).toBe(true); + expect(lv.view(state, null).away).toEqual(['A']); + expect(state.deadlineAt).toBe(clock + AWAY_MS); + s = act(state, 'A', { type: 'PLACE', value: state.lastRoll!.own[0] ?? state.lastRoll!.neutral[0]! }); + expect(lv.view(s, null).away).toEqual([]); + }); + + test('a disconnected player (only timeouts) does not block the game: rounds finish', () => { + let s = make({}, ['A', 'B', 'C'], 7); + const r = SeededRng.fromSeed(seedN(70)); + let steps = 0; + while (s.phase !== 'finished' && steps++ < 2000) { + const p = cur(s); + const a: LasVegasAction = p === 'B' ? lv.onTimeout(s, p) : lv.legalActions!(s, p)[0]!; + expect(lv.validate(s, p, a)).toEqual({ ok: true }); + s = lv.apply(s, p, a, { rng: r, now: (clock += 1000) }).state; + } + expect(s.phase).toBe('finished'); + expect(s.roundResults.length).toBe(4); + }); + + test('concede: dice discarded (also those on the board), skipped afterwards, bills kept, ranked last; last one standing ends the game', () => { + let s = makeFirst({}, ['A', 'B', 'C']); + s.money.B = [9, 9, 9]; + for (let i = 0; i < 3; i++) s.pile.splice(s.pile.indexOf(9), 1); + s = rolled(s, [2, 2, 2, 3, 3, 4, 4, 5]); + s = act(s, 'A', { type: 'PLACE', value: 2 }); + s = rolled(s, [1, 1, 1, 1, 1, 1, 1, 6]); + s = act(s, 'B', { type: 'PLACE', value: 1 }); + expect(lv.onLeave!(s, 'B')).toEqual({ type: 'CONCEDE' }); + s = act(s, 'B', { type: 'CONCEDE' }); // not B's turn + expect(s.casinos[0]!.dice.B).toBeUndefined(); + expect(s.hand.B).toEqual({ own: 0, neutral: 0 }); + expect(reject(s, 'B', { type: 'ROLL' })).toBe('이미 기권했어요.'); + expect(lv.onLeave!(s, 'B')).toBeNull(); + expect(cur(s)).toBe('C'); + s = rolled(s, [6, 6, 6, 6, 6, 6, 6, 6]); + s = act(s, 'C', { type: 'PLACE', value: 6 }); + expect(cur(s)).toBe('A'); // B skipped + const { state, events } = actE(s, 'C', { type: 'CONCEDE' }); + expect(events.some((e) => e.type === 'gameOver')).toBe(true); + const r = lv.result(state)!; + expect(r.ranking).toEqual([['A'], ['B'], ['C']]); // conceded players last (B has more bills than C) + expect(r.reason).toBe('resign'); + expect(r.summary.startsWith('다른 사람이 모두 기권했어요.')).toBe(true); + expect(allBills(state)).toEqual([...SORTED_NOTES]); + }); + + test('the current player conceding passes the turn on', () => { + let s = makeFirst({}, ['A', 'B', 'C']); + s = rolled(s, [2, 2, 2, 3, 3, 4, 4, 5]); + s = act(s, 'A', { type: 'CONCEDE' }); + expect(cur(s)).toBe('B'); + expect(s.phase).toBe('roll'); + expect(s.lastRoll).toBeNull(); + }); +}); + +// ---------------------------------------------------------------- view / hidden info + +describe('view and hidden info (§8.3, §10)', () => { + function withMoney(opts: Partial) { + const s = makeFirst(opts, ['A', 'B', 'C']); + s.money = { A: [9, 2], B: [7], C: [] }; + s.roundResults = [ + { + round: 1, + payouts: [ + { + casino: 3, + removedTies: ['C'], + awards: [ + { owner: 'A', note: 9 }, + { owner: 'B', note: 7 }, + { owner: HOUSE, note: 5 }, + { owner: 'A', note: 2 }, + ], + returned: [5], + }, + ], + }, + ]; + // Keep 54 bills: take them out of the pile. + for (const n of [9, 2, 7]) s.pile.splice(s.pile.indexOf(n), 1); + return s; + } + + test('hideMoney: other players and spectators see bill counts only; own amounts and house/returned bills are public', () => { + const s = withMoney({}); + const vb = lv.view(s, 'B'); + expect(vb.money.A).toEqual({ count: 2, total: null, notes: null }); + expect(vb.money.B).toEqual({ count: 1, total: 7, notes: [7] }); + const aw = vb.roundResults[0]!.payouts[0]!.awards; + expect(aw.map((a) => [a.owner, a.note])).toEqual([ + ['A', null], + ['B', 7], + [HOUSE, 5], + ['A', null], + ]); + const spec = lv.view(s, null); + expect(Object.values(spec.money).every((m) => m.total === null && m.notes === null)).toBe(true); + expect(spec.roundResults[0]!.payouts[0]!.awards.filter((a) => a.owner !== HOUSE).every((a) => a.note === null && a.key === null)).toBe(true); + for (const viewer of ['A', 'B', 'C', null]) { + const json = JSON.stringify(lv.view(s, viewer)); + expect(json).not.toContain('"pile"'); + expect(json).not.toContain('"rng"'); + expect(json).not.toContain('"timeouts"'); + for (const other of ['A', 'B', 'C']) { + if (other === viewer) continue; + for (const secret of lv.secretsOf!(s, other)) expect(json).not.toContain(secret); + } + } + expect(lv.view(s, 'A').pileCount).toBe(s.pile.length); + expect(lv.secretsOf!(s, 'A').length).toBe(2); + }); + + test('hideMoney = false: amounts are public', () => { + const s = withMoney({ hideMoney: false }); + expect(lv.view(s, null).money.A).toEqual({ count: 2, total: 11, notes: [9, 2] }); + expect(lv.secretsOf!(s, 'A')).toEqual([]); + }); + + test('choices preview for the player to move: 1등 / 동점 주의 / lower bill / nothing', () => { + let s = makeFirst({ neutralDice: 'off' }, ['A', 'B']); + setBills(s, { 1: [9, 4], 2: [8], 3: [6], 4: [5] }); + putDice(s, 1, { B: 3 }); + putDice(s, 2, { B: 2 }); + putDice(s, 3, { B: 1 }); + putDice(s, 4, { B: 2 }); + s = rolled(s, [1, 2, 2, 3, 3, 3, 4, 5]); + const v: LasVegasView = lv.view(s, 'B'); + const by = Object.fromEntries(v.choices.map((c) => [c.value, [c.standing, c.note, c.countAfter]])); + expect(by).toEqual({ 1: ['award', 4, 1], 2: ['tie', null, 2], 3: ['first', 6, 3], 4: ['none', null, 1], 5: ['none', null, 1] }); + }); + + test('no RNG/pile in the view; same seed + same actions → same result (replay)', () => { + const run = () => { + let s = make({}, ['A', 'B', 'C'], 11); + const r = SeededRng.fromSeed(seedN(12)); + let t = 0; + while (s.phase !== 'finished') { + const p = cur(s); + const legal = lv.legalActions!(s, p); + s = lv.apply(s, p, legal[t++ % legal.length]!, { rng: r, now: t * 1000 }).state; + } + return s; + }; + expect(JSON.stringify(run())).toBe(JSON.stringify(run())); + expect(run().money).toEqual(run().money); + }); + + test('schema: defaults, Korean metadata for every option, client cannot send TIMEOUT', () => { + expect(lv.defaultOptions).toEqual({ rounds: 4, neutralDice: 'auto', hideMoney: true, turnSeconds: 30 }); + const shape = lasVegasOptionsSchema.shape; + for (const k of Object.keys(shape) as (keyof typeof shape)[]) { + const meta = shape[k].meta() as { title?: string }; + expect(meta?.title).toMatch(/[가-힣]/); + } + expect(lasVegasActionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false); + expect(lasVegasActionSchema.safeParse({ type: 'PLACE', value: 7 }).success).toBe(false); + expect(lasVegasActionSchema.safeParse({ type: 'PLACE', value: 3 }).success).toBe(true); + expect(lasVegasOptionsSchema.safeParse({ turnSeconds: null }).success).toBe(true); + expect(lasVegasOptionsSchema.safeParse({ rounds: 6 }).success).toBe(false); + }); +}); + +// ---------------------------------------------------------------- random playouts + +describe('random playouts (docs/09 §7)', () => { + const OPTS: Partial[] = [ + {}, + { neutralDice: 'on' }, + { neutralDice: 'off', hideMoney: false }, + { rounds: 3, turnSeconds: null }, + { rounds: 5, turnSeconds: 20 }, + ]; + + function invariant(s: LasVegasState, total: { neutral: number }): void { + // 54 bills always: pile + casinos + money. + const bills = allBills(s); + if (bills.length !== 54 || bills.join() !== SORTED_NOTES.join()) throw new Error('bills not conserved'); + if (s.phase === 'finished') return; + // Dice: active players hold 8 own dice between hand and casinos; house dice never exceed the neutral pool. + let house = 0; + for (const c of s.casinos) { + house += c.dice.house ?? 0; + for (const [o, n] of Object.entries(c.dice)) if (o !== HOUSE && (n! <= 0 || s.conceded.includes(o))) throw new Error('bad dice count'); + if (c.notes.some((n, i) => i > 0 && n > c.notes[i - 1]!)) throw new Error('casino bills not descending'); + } + for (const p of s.seats) { + const placed = s.casinos.reduce((n, c) => n + (c.dice[p] ?? 0), 0); + if (!s.conceded.includes(p) && placed + s.hand[p]!.own !== OWN_DICE) throw new Error(`own dice of ${p} not conserved`); + } + const neutralHands = s.seats.reduce((n, p) => n + s.hand[p]!.neutral, 0); + if (s.conceded.length === 0 && house + neutralHands !== total.neutral) throw new Error('neutral dice not conserved'); + if (house + neutralHands > total.neutral) throw new Error('too many neutral dice'); + // The player to act always has dice. + if (s.hand[cur(s)]!.own + s.hand[cur(s)]!.neutral === 0) throw new Error('current player has no dice'); + } + + function leakCheck(s: LasVegasState): void { + for (const viewer of [...s.seats, null]) { + const v = lv.view(s, viewer); + const json = JSON.stringify(v); + if (json.includes('"pile"') || json.includes('"rng"')) throw new Error('pile/rng in view'); + if (s.phase === 'finished' || !s.options.hideMoney) continue; + for (const p of s.seats) { + if (p === viewer) continue; + if (v.money[p]!.total !== null || v.money[p]!.notes !== null) throw new Error(`money of ${p} leaked to ${viewer}`); + for (const rr of v.roundResults) + for (const po of rr.payouts) for (const a of po.awards) if (a.owner === p && a.note !== null) throw new Error('award leaked'); + for (const secret of lv.secretsOf!(s, p)) if (json.includes(secret)) throw new Error('secret leaked'); + } + } + } + + test('1,000 seeds (custom fast loop with timeouts and concedes): finish, conservation, no leaks, onTimeout legal, deterministic', () => { + let finished = 0; + const play = (i: number, check: boolean): string => { + const n = 2 + (i % 4) + (i % 7 === 0 ? 1 : 0); + const players = Array.from({ length: Math.min(5, n) }, (_, k) => `p${k}`); + let opts = OPTS[i % OPTS.length]!; + if (players.length === 5 && opts.neutralDice === 'on') opts = { ...opts, neutralDice: 'auto' }; + const options = lasVegasOptionsSchema.parse(opts); + const rngG = SeededRng.fromSeed(seedN(i)); + const chooser = SeededRng.fromSeed(seedN(i + 100_000)); + let s = lv.setup({ players, options, rng: rngG, now: 0 }); + const total = { neutral: s.neutralPer * players.length + s.houseExtra }; + let now = 0; + for (let step = 0; step < 2000; step++) { + if (check) { + invariant(s, total); + if (step % 5 === 0) leakCheck(s); + } + if (lv.result(s)) { + if (check) leakCheck(s); + return JSON.stringify(s); + } + const p = cur(s); + if (check) { + const t = lv.onTimeout(s, p); + const v = lv.validate(s, p, t); + if (!v.ok) throw new Error(`timeout rejected: ${v.reason}`); + } + const roll = chooser.int(100); + let actor = p; + let a: LasVegasAction; + if (roll < 1) { + // A random seated player concedes now and then. + const alive = s.seats.filter((q) => !s.conceded.includes(q)); + actor = alive[chooser.int(alive.length)]!; + a = { type: 'CONCEDE' }; + } else if (roll < 10) a = { type: 'TIMEOUT' }; + else { + const legal = lv.legalActions!(s, p); + a = legal[chooser.int(legal.length)]!; + } + const v = lv.validate(s, actor, a); + if (!v.ok) throw new Error(`generated action rejected: ${v.reason}`); + s = lv.apply(s, actor, a, { rng: rngG, now: (now += 1000) }).state; + } + throw new Error(`seed ${i} did not finish`); + }; + for (let i = 1; i <= 1000; i++) { + const a = play(i, true); + finished++; + if (i % 50 === 0) expect(play(i, false)).toBe(a); + } + expect(finished).toBe(1000); + }, 60_000); + + test('engine randomPlayout: 100 seeds with round-trip, leak checker and determinism', () => { + for (let i = 1; i <= 100; i++) { + const players = ['A', 'B', 'C', 'D', 'E'].slice(0, 2 + (i % 4)); + const opts = lasVegasOptionsSchema.parse(OPTS[i % OPTS.length]!.neutralDice === 'on' && players.length === 5 ? {} : OPTS[i % OPTS.length]!); + const r = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 }); + expect(r.finished).toBe(true); + const st = r.finalState as LasVegasState; + expect(st.roundResults.length).toBe(opts.rounds); + expect(allBills(st)).toEqual(SORTED_NOTES); + if (i % 20 === 0) { + const again = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 }); + expect(JSON.stringify(again.finalState)).toBe(JSON.stringify(st)); + } + } + }, 60_000); +}); diff --git a/packages/games/src/las-vegas/rules.ts b/packages/games/src/las-vegas/rules.ts new file mode 100644 index 0000000..b97b6e6 --- /dev/null +++ b/packages/games/src/las-vegas/rules.ts @@ -0,0 +1,199 @@ +/** + * 라스베가스 규칙 계산 (docs/games/las-vegas.md §3–§5, §8). Pure helpers shared by the definition, + * the view and the tests. State-mutating helpers work on a state `apply` has already cloned. + */ +import { z } from 'zod'; +import type { PlayerId } from '@bg/engine'; + +// ---------------------------------------------------------------- options & actions + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const lasVegasOptionsSchema = z.object({ + rounds: lit(3, 4, 5) + .default(4) + .meta({ title: '라운드 수', labels: { 3: '3라운드(짧게)', 4: '4라운드(기본)', 5: '5라운드' } }), + neutralDice: z + .enum(['auto', 'on', 'off']) + .default('auto') + .meta({ + title: '중립(흰) 주사위', + labels: { auto: '자동(2명일 때만, 1인당 4개)', on: '켜기(2명 4개, 3~4명 2개씩)', off: '끄기' }, + }), + hideMoney: z.boolean().default(true).meta({ title: '다른 사람이 받은 지폐 금액 숨기기(장 수만 공개)' }), + turnSeconds: lit(20, 30, 60, null) + .default(30) + .meta({ title: '굴리기·놓기 제한 시간', labels: { 20: '20초', 30: '30초', 60: '60초', null: '무제한' } }), +}); +export type LasVegasOptions = z.infer; + +export const lasVegasActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('ROLL') }), + z.object({ type: z.literal('PLACE'), value: z.number().int().min(1).max(6) }), + z.object({ type: z.literal('CONCEDE') }), +]); +export type LasVegasClientAction = z.infer; +/** Client actions plus the system-only timer action (not in the schema, docs/09 §2). */ +export type LasVegasAction = LasVegasClientAction | { type: 'TIMEOUT' }; + +// ---------------------------------------------------------------- components + +export type Note = number; +export const HOUSE = 'house' as const; +export type Owner = PlayerId | typeof HOUSE; +export type CasinoValue = 1 | 2 | 3 | 4 | 5 | 6; + +/** Bill denominations → count (§3): 54 bills, 250 in total. */ +export const NOTE_COUNTS: Record = { 1: 6, 2: 8, 3: 8, 4: 6, 5: 6, 6: 5, 7: 5, 8: 5, 9: 5 }; +export const ALL_NOTES: Note[] = Object.entries(NOTE_COUNTS).flatMap(([v, n]) => Array(n).fill(Number(v))); +export const OWN_DICE = 8; +export const MIN_CASINO_SUM = 5; +export const CASINO_VALUES: CasinoValue[] = [1, 2, 3, 4, 5, 6]; +/** Consecutive automatic actions before a player is shown as "자리 비움" (§8.5). */ +export const AWAY_AFTER = 3; +/** Deadline for away players (keeps the game moving). */ +export const AWAY_MS = 5000; + +export interface Casino { + value: CasinoValue; + /** Descending. */ + notes: Note[]; + dice: Partial>; +} + +export interface Payout { + casino: number; + removedTies: Owner[]; + awards: { owner: Owner; note: Note }[]; + returned: Note[]; +} + +export interface RoundResult { + round: number; + payouts: Payout[]; +} + +export interface LasVegasState { + options: LasVegasOptions; + seats: PlayerId[]; + round: number; + /** This round's first player (seat index). */ + startSeat: number; + /** Seat index of the player to act. */ + current: number; + phase: 'roll' | 'place' | 'finished'; + /** Hidden draw pile, index 0 = top. */ + pile: Note[]; + casinos: Casino[]; + /** Dice still in hand. */ + hand: Record; + lastRoll: { own: number[]; neutral: number[] } | null; + money: Record; + roundResults: RoundResult[]; + /** Neutral dice each player gets per round (0 = variant off). */ + neutralPer: number; + /** Neutral dice the round's first player rolls straight onto the casinos (3 players + variant). */ + houseExtra: number; + /** Last round-start house roll (3 players + variant). */ + houseRoll: { round: number; player: PlayerId; values: number[] } | null; + lastPlace: { player: PlayerId; value: number; own: number; neutral: number; auto: boolean } | null; + conceded: PlayerId[]; + timeouts: Record; + rollSeq: number; + turnSeq: number; + deadlineAt: number | null; +} + +// ---------------------------------------------------------------- setup helpers + +/** Neutral dice per player for a player count; null = not allowed (5 players + `on`). */ +export function neutralPerFor(option: LasVegasOptions['neutralDice'], players: number): number | null { + if (option === 'off') return 0; + if (option === 'auto') return players === 2 ? 4 : 0; + if (players === 2) return 4; + if (players <= 4) return 2; + return null; +} + +/** Leftover neutral dice the first player rolls each round (only 3 players: 8 − 3×2 = 2). */ +export const houseExtraFor = (neutralPer: number, players: number): number => (neutralPer > 0 && players === 3 ? 2 : 0); + +/** Deals bills from the top of the pile until each casino holds ≥ 5 (§4.2). Casinos are emptied first. */ +export function refillCasinos(s: Pick): void { + for (const c of s.casinos) { + c.notes = []; + c.dice = {}; + let sum = 0; + while (sum < MIN_CASINO_SUM && s.pile.length > 0) { + const n = s.pile.shift()!; + c.notes.push(n); + sum += n; + } + c.notes.sort((a, b) => b - a); + } +} + +// ---------------------------------------------------------------- settlement + +/** + * One casino's payout (§5 정산): owners with equal counts are all removed (the house included), the rest + * take bills in descending order. Bills the house wins and bills nobody wins are returned. + * `order` fixes the listing order of tied owners (seat order, house last). + */ +export function settleCasino(notes: Note[], dice: Partial>, order: Owner[]): Payout & { ranked: Owner[] } { + const owners = order.filter((o) => (dice[o] ?? 0) > 0); + const byCount = new Map(); + for (const o of owners) byCount.set(dice[o]!, [...(byCount.get(dice[o]!) ?? []), o]); + const removedTies = owners.filter((o) => byCount.get(dice[o]!)!.length > 1); + const ranked = owners.filter((o) => !removedTies.includes(o)).sort((a, b) => dice[b]! - dice[a]!); + const sorted = [...notes].sort((a, b) => b - a); + const awards: Payout['awards'] = []; + ranked.forEach((o, i) => { + if (i < sorted.length) awards.push({ owner: o, note: sorted[i]! }); + }); + const returned = [...awards.filter((a) => a.owner === HOUSE).map((a) => a.note), ...sorted.slice(ranked.length)].sort((a, b) => b - a); + return { casino: 0, removedTies, awards, returned, ranked }; +} + +export const ownerOrder = (s: Pick): Owner[] => [...s.seats, HOUSE]; + +/** What placing `own` + `neutral` dice on casino `value` would mean for `player` right now (§9 미리 보기). */ +export function placementPreview( + s: Pick, + player: PlayerId, + value: number, + own: number, + neutral: number, +): { standing: 'first' | 'award' | 'tie' | 'none'; note: Note | null; count: number } { + const c = s.casinos[value - 1]!; + const dice = { ...c.dice }; + if (own > 0) dice[player] = (dice[player] ?? 0) + own; + if (neutral > 0) dice[HOUSE] = (dice[HOUSE] ?? 0) + neutral; + const count = dice[player] ?? 0; + if (count === 0) return { standing: 'none', note: null, count }; + const out = settleCasino(c.notes, dice, ownerOrder(s)); + if (out.removedTies.includes(player)) return { standing: 'tie', note: null, count }; + const idx = out.ranked.indexOf(player); + const note = out.awards.find((a) => a.owner === player)?.note ?? null; + return { standing: note === null ? 'none' : idx === 0 ? 'first' : 'award', note, count }; +} + +/** Deterministic timeout choice (§8.5): most dice, then the casino's best bill, then the smaller number. */ +export function autoPlaceValue(s: Pick, roll: { own: number[]; neutral: number[] }): number { + let best = 0; + let bestKey: [number, number] = [-1, -1]; + for (const v of CASINO_VALUES) { + const n = roll.own.filter((d) => d === v).length + roll.neutral.filter((d) => d === v).length; + if (n === 0) continue; + const key: [number, number] = [n, s.casinos[v - 1]!.notes[0] ?? 0]; + if (key[0] > bestKey[0] || (key[0] === bestKey[0] && key[1] > bestKey[1])) { + best = v; + bestKey = key; + } + } + return best; +} + +export const sum = (notes: Note[]): number => notes.reduce((a, b) => a + b, 0); +export const diceLeft = (s: Pick, p: PlayerId): number => (s.hand[p] ? s.hand[p]!.own + s.hand[p]!.neutral : 0); diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index dded0fa..3114fa1 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -28,6 +28,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true }, { id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true }, { id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true }, + { id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);