M7: 라스베가스 규칙·화면·테스트

- 규칙 모듈 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 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:53:47 +09:00
parent 559fb8507c
commit fa7ceea668
10 changed files with 2339 additions and 1 deletions

View File

@@ -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<number, [string, string]> = {
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<string, PublicUser>, id: string) => (id === HOUSE ? '하우스(흰 주사위)' : (players[id]?.nickname ?? '알 수 없음'));
const userOf = (players: Record<string, PublicUser>, 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<string, PublicUser>, 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<number, [number, number][]> = {
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 (
<span className={`lv-die${size ? ` ${size}` : ''}${rolling ? ' rolling' : ''}`} style={{ background: color }} aria-hidden>
<svg viewBox="0 0 100 100">
{PIPS[value]!.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={value === 1 ? 14 : 10} fill={pipOn(color)} />
))}
</svg>
</span>
);
}
function Bill({ note, small }: { note: number | null; small?: boolean }) {
if (note === null)
return (
<span className={`lv-bill hidden${small ? ' sm' : ''}`} aria-label="금액 비공개 지폐">
?
</span>
);
const [bg, fg] = BILL[note] ?? ['#eee', '#111'];
return (
<span className={`lv-bill${small ? ' sm' : ''}`} style={{ background: bg, color: fg }} aria-label={`지폐 ${note}점`}>
{note}
</span>
);
}
function Dot({ color }: { color: string }) {
return <span className="lv-dot" style={{ background: color }} aria-hidden />;
}
function OwnerChip({ v, players, owner, me }: { v: LasVegasView; players: Record<string, PublicUser>; owner: string; me: string | null }) {
return (
<span className="lv-chip">
<Dot color={seatColor(v, owner)} />
<span className="lv-pname">{owner === me ? '나' : owner === HOUSE ? '하우스' : nick(players, owner)}</span>
</span>
);
}
const STANDING_TAG: Record<LasVegasChoice['standing'], string> = { 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<LasVegasView>) {
const myTurn = v.phase !== 'finished' && !!me && v.current === me;
const [sel, setSel] = useState<number | null>(null);
const [rolling, setRolling] = useState(false);
const [flick, setFlick] = useState<number[] | null>(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 (
<div className="lv-wrap">
<style>{CSS}</style>
<div className="card lv-head">
<strong>{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.rounds} 라운드`}</strong>
{v.current && (
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
<Dot color={seatColor(v, v.current)} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
</span>
)}
<span className="small muted">
{v.phase !== 'finished' && `선 ${v.startPlayer === me ? '나' : nick(players, v.startPlayer)} · `}
<span className="lv-wide">남은 지폐 </span>더미 {v.pileCount}장
</span>
</div>
{v.round === 1 && v.phase !== 'finished' && (
<div className="lv-help" aria-label="처음 하는 분을 위한 도움말">
<span>🎲 같은 숫자는 한꺼번에</span>
<span>⚠️ 개수가 같으면 둘 다 꽝</span>
</div>
)}
{v.result && lastRR && (
<div className="lv-lower">
<FinalTable v={v} me={me} players={players} />
<Settlement round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
</div>
)}
{!v.result && (
<section className="lv-casinos" aria-label="카지노 6곳">
{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 = (
<>
<div className="lv-ctop">
<Die value={c.value} color="#fffdf8" />
<span className="lv-cnum">{c.value}</span>
<span className="lv-cname">번 카지노</span>
</div>
<div className="lv-bills">
{c.notes.length ? c.notes.map((n, i) => <Bill key={i} note={n} />) : <span className="lv-nobill">지폐 없음</span>}
</div>
<div className="lv-stacks">
{owners.map(([o, n]) => (
<div key={o} className="lv-stack" title={nick(players, o)}>
<span className="lv-pips">
{Array.from({ length: Math.min(n!, 8) }, (_, i) => (
<Dot key={i} color={seatColor(v, o)} />
))}
</span>
<span>{n! > 8 ? `${n}` : ''}</span>
<span className="muted" style={{ fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{initial(players, o)}
</span>
</div>
))}
{ch && isSel && (
<div className="lv-stack" style={{ opacity: 0.85 }}>
<span className="lv-pips">
{Array.from({ length: ch.own }, (_, i) => (
<Dot key={`o${i}`} color={seatColor(v, v.current!)} />
))}
{Array.from({ length: ch.neutral }, (_, i) => (
<Dot key={`n${i}`} color="#ffffff" />
))}
</span>
<span className="small">+{ch.own + ch.neutral}</span>
</div>
)}
</div>
{ch && isSel && <span className={`lv-tag ${ch.standing}`}>{STANDING_TAG[ch.standing]}</span>}
</>
);
return ch && canPick ? (
<button
key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`}
type="button"
className={cls}
aria-pressed={isSel}
aria-label={`${label}. 누르면 여기에 놓을 숫자로 골라요`}
onClick={() => pick(c.value)}
>
{body}
</button>
) : (
<div key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`} className={cls} role="group" aria-label={label}>
{body}
</div>
);
})}
</section>
)}
{!v.result && (
<div className="lv-lower">
<section className="card lv-tray" aria-label="주사위 굴리기와 놓기">
{v.phase === 'finished' ? (
<p className="muted" style={{ textAlign: 'center' }}>
게임이 끝났어요
</p>
) : rolling && v.lastRoll ? (
<div className="lv-mdice" aria-hidden>
{[...v.lastRoll.own.map(() => seatColor(v, v.current!)), ...v.lastRoll.neutral.map(() => '#ffffff')].map((col, i) => (
<Die key={`${v.rollSeq}-${i}`} value={flick?.[i] ?? 1} color={col} size="lg" rolling />
))}
</div>
) : v.phase === 'place' && v.current ? (
<>
<div className="lv-groups" role="group" aria-label={canPick ? '나온 숫자. 하나를 골라 주세요' : `${nick(players, v.current)}님이 굴린 주사위`}>
{choices.map((c) => (
<button
key={c.value}
type="button"
className="lv-group"
data-standing={c.standing}
aria-pressed={canPick ? sel === c.value : undefined}
disabled={!canPick}
aria-label={`${c.value} 주사위 ${c.own + c.neutral}개${c.neutral ? `(흰 ${c.neutral}개 포함)` : ''}. ${STANDING_TAG[c.standing]}`}
onClick={() => pick(c.value)}
>
<span className="lv-mdice">
{Array.from({ length: c.own }, (_, i) => (
<Die key={`o${i}`} value={c.value} color={seatColor(v, v.current!)} />
))}
{Array.from({ length: c.neutral }, (_, i) => (
<Die key={`n${i}`} value={c.value} color="#ffffff" />
))}
</span>
<b>
{c.value} × {c.own + c.neutral}개
</b>
{c.neutral > 0 && <small>흰 {c.neutral} 포함</small>}
</button>
))}
</div>
{canPick && selChoice && <div className={`lv-preview ${selChoice.standing}`}>{previewText(selChoice)}</div>}
{canPick ? (
<button className="btn btn-primary btn-block lv-roll" disabled={!selChoice} onClick={place}>
{selChoice ? `${selChoice.value}번 카지노에 ${selChoice.own + selChoice.neutral}개 놓기` : '놓을 숫자를 골라 주세요'}
</button>
) : (
<p className="muted small" style={{ textAlign: 'center' }}>
{myTurn ? '잠시만요…' : `${nick(players, v.current)}님이 놓을 숫자를 고르는 중이에요`}
</p>
)}
</>
) : myTurn ? (
<button className="btn btn-primary btn-block lv-roll" disabled={pending} onClick={roll}>
🎲 주사위 굴리기 ({(hand?.own ?? 0) + (hand?.neutral ?? 0)}개)
</button>
) : (
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
{v.current ? `${nick(players, v.current)}님이 굴릴 차례예요` : ''}
</p>
)}
{hand && v.phase !== 'finished' && !v.conceded.includes(me!) && (
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
내 주사위 {hand.own}개{v.neutralPer > 0 ? ` · 흰 주사위 ${hand.neutral}개` : ''} 남음
</p>
)}
{v.houseRoll && v.houseRoll.round === v.round && v.phase !== 'finished' && (
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
라운드 시작: 남는 흰 주사위 {v.houseRoll.values.length}개가 {v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요
</p>
)}
</section>
{lastRR ? (
<Settlement key={lastRR.round} round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
) : (
<section className="card lv-settle" aria-label="정산 안내">
<h3>라운드가 끝나면</h3>
<p className="small muted" style={{ margin: 0 }}>
카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 무효예요.
</p>
</section>
)}
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function Settlement({
round,
payouts,
v,
me,
players,
}: {
round: number;
payouts: LasVegasPayoutView[];
v: LasVegasView;
me: string | null;
players: Record<string, PublicUser>;
}) {
const fresh = v.phase !== 'finished' && round === v.round - 1;
return (
<section className={`card lv-settle${fresh ? ' lv-fresh' : ''}`} aria-label={`${round}라운드 정산`}>
<h3>{round}라운드 정산</h3>
{payouts.map((p) => (
<div key={p.casino} className="lv-prow">
<strong>{p.casino}번</strong>
<div className="lv-pbody">
{p.removedTies.length > 0 && (
<span className="lv-tieout" aria-label={`동점 무효: ${p.removedTies.map((o) => (o === me ? '나' : nick(players, o))).join(', ')}`}>
💥 동점 무효
{p.removedTies.map((o) => (
<Dot key={o} color={seatColor(v, o)} />
))}
</span>
)}
{p.awards.map((a, i) => (
<span key={a.key ?? `${a.owner}-${i}`} className="lv-chip">
<OwnerChip v={v} players={players} owner={a.owner} me={me} />
<Bill note={a.note} small />
{a.note === null && <span className="muted">1장</span>}
</span>
))}
{p.returned.length > 0 && <span className="lv-chip muted">↩ 더미 아래로 {p.returned.map((n) => n).join('·')}</span>}
{p.awards.length === 0 && p.removedTies.length === 0 && p.returned.length === 0 && <span className="muted">아무도 없었어요</span>}
</div>
</div>
))}
</section>
);
}
function FinalTable({ v, me, players }: { v: LasVegasView; me: string | null; players: Record<string, PublicUser> }) {
const r = v.result!;
return (
<section className="card lv-final" aria-label="최종 결과">
<h3 style={{ margin: '0 0 6px' }}>최종 결과</h3>
<table>
<thead>
<tr>
<th scope="col">순위</th>
<th scope="col">이름</th>
{v.roundResults.map((rr) => (
<th key={rr.round} scope="col">
{rr.round}R
</th>
))}
<th scope="col">합계</th>
</tr>
</thead>
<tbody>
{r.ranking.map((row) => (
<tr key={row.player}>
<td>{row.rank}등</td>
<td style={{ textAlign: 'left' }}>
<OwnerChip v={v} players={players} owner={row.player} me={me} />
{row.conceded && <span className="muted small"> (기권)</span>}
</td>
{(r.roundTotals[row.player] ?? []).map((t, i) => (
<td key={i}>{t}</td>
))}
<td>
<strong>{row.total}점</strong>
<div className="small muted">{row.notes.length}장</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
// ---------------------------------------------------------------- side panel
function LasVegasSide({ view: v, me, players, act, deadline }: BoardProps<LasVegasView>) {
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 (
<>
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
{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 (
<div
key={p}
className="row"
style={{ gap: 10, flexWrap: 'nowrap', padding: '4px 6px', borderRadius: 10, border: `3px solid ${turn ? 'var(--focus)' : 'transparent'}` }}
>
<span aria-label={`${COLOR_NAMES[i]} 주사위`} role="img">
<Die value={i + 1} color={COLORS[i]!} />
</span>
<Avatar user={userOf(players, p)} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{nick(players, p)}
{p === me && <span className="muted small"> (나)</span>}
{v.startPlayer === p && playing && (
<span className="badge" style={{ marginLeft: 4, padding: '0 6px' }}>
선
</span>
)}
</div>
<div className="small muted">
{out ? '기권' : v.away.includes(p) ? '자리 비움' : playing ? `주사위 ${h.own}${v.neutralPer ? ` · 흰 ${h.neutral}` : ''}` : '끝'}
</div>
</div>
<span
style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
aria-label={`${nick(players, p)} 지폐 ${m.count}장${m.total !== null ? `, ${m.total}점` : ''}`}
>
<strong style={{ fontSize: '1.2rem' }}>{m.total !== null ? `${m.total}점` : '?'}</strong>
<div className="small muted">{m.count}장</div>
</span>
{turn && playing && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
</div>
);
})}
</section>
{mine && (
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="내 지폐">
<div className="row small" style={{ justifyContent: 'space-between' }}>
<strong>내 지폐</strong>
<span>
{mine.count}장 · {mine.total ?? 0}점
</span>
</div>
<div className="lv-bills">
{mine.notes && mine.notes.length ? mine.notes.map((n, i) => <Bill key={i} note={n} small />) : <span className="lv-nobill">아직 없어요</span>}
</div>
{v.options.hideMoney && playing && (
<p className="small muted" style={{ margin: 0 }}>
다른 사람에게는 장 수만 보여요.
</p>
)}
</section>
)}
{playing && me && v.money[me] && !v.conceded.includes(me) && (
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
🏳 기권
</button>
)}
{ask && (
<ConfirmSheet
title="기권할까요?"
body="남은 주사위는 모두 버려지고 이후 차례에서 빠져요. 이미 받은 지폐는 결과에 남아요."
confirm="기권하기"
danger
onConfirm={() => 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, PublicUser>): 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<T extends string | number>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<LasVegasOptions>) {
const set = (patch: Partial<LasVegasOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="라운드 수">
<Seg
label="라운드 수"
value={o.rounds}
options={[
[3, '3라운드'],
[4, '4라운드'],
[5, '5라운드'],
]}
onChange={(rounds) => set({ rounds })}
/>
</Field>
<Field label="중립(흰) 주사위">
<Seg
label="중립(흰) 주사위"
value={o.neutralDice}
options={[
['auto', '자동(2명일 때만)'],
['on', '켜기(2~4명)'],
['off', '끄기'],
]}
onChange={(neutralDice) => set({ neutralDice })}
/>
</Field>
<Field label="다른 사람이 받은 지폐 금액">
<Seg
label="다른 사람이 받은 지폐 금액"
value={o.hideMoney ? 'hide' : 'show'}
options={[
['hide', '숨기기(장 수만)'],
['show', '보여 주기'],
]}
onChange={(k) => set({ hideMoney: k === 'hide' })}
/>
</Field>
<Field label="굴리기·놓기 제한 시간">
<Seg
label="굴리기·놓기 제한 시간"
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
options={[
['20', '20초'],
['30', '30초'],
['60', '60초'],
['none', '무제한'],
]}
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 20 | 30 | 60) })}
/>
</Field>
<p className="small muted" style={{ margin: 0 }}>
흰 주사위 &quot;켜기&quot;는 5명일 때 쓸 수 없어요(5색을 모두 쓰기 때문이에요).
</p>
</div>
);
}
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 (
<div className="stack">
<style>{CSS}</style>
<p>
1번부터 6번까지 카지노 6곳에 놀이용 지폐가 놓여 있어요(카지노마다 합이 5점 이상이 될 때까지). 각자 자기 색 주사위 <strong>8개</strong>를 가지고
시작해요.
</p>
<p>
내 차례에는 남은 주사위를 <strong>모두 굴린 뒤</strong>, 나온 숫자 하나를 골라 그 숫자가 나온 주사위를 <strong>한꺼번에</strong> 같은 번호 카지노에
놓아요. 일부만 놓을 수는 없어요. 주사위를 다 쓴 사람은 차례를 건너뛰어요.
</p>
<p>
모두 주사위를 다 놓으면 정산해요. 카지노마다 주사위가 가장 많은 사람이 가장 큰 지폐, 다음 사람이 그다음 지폐를 가져가요. 단,{' '}
<strong>개수가 같은 사람끼리는 모두 무효</strong>(꽝)예요.
</p>
<div className="row" style={{ gap: 6 }}>
<Bill note={8} />
<Bill note={3} />
<Bill note={1} />
<span className="small">가 놓인 카지노에 A 5개 · B 3개 · C 3개 · D 1개 → B와 C는 꽝, A가 8, D가 3을 받아요.</span>
</div>
<p>
<strong>흰 주사위</strong>(중립): 2명일 때 기본으로 각자 4개씩 더 받아요. 같은 숫자가 나오면 내 주사위와 함께 놓이고, 흰 주사위는 &quot;하우스&quot;라는
가상의 한 사람으로 쳐요. 하우스와 개수가 같아도 꽝이에요. 3명이 켜고 하면 남는 흰 주사위 2개를 라운드마다 선 플레이어가 굴려 바로 놓아요.
</p>
<p>{rounds}라운드가 끝나면 받은 지폐 합이 가장 큰 사람이 이겨요. 같으면 지폐 장 수가 많은 사람이 이겨요. 라운드마다 선은 다음 사람에게 넘어가요.</p>
<p className="muted">
시간 안에 굴리지 않으면 자동으로 굴리고, 시간 안에 고르지 않으면 개수가 가장 많은 숫자(같으면 지폐가 큰 카지노, 그래도 같으면 작은 숫자)에 자동으로
놓아요.
</p>
<p className="muted small">지폐는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 실제 화폐나 상품과 바꿀 수 없어요.</p>
</div>
);
}
const ui: GameUI<LasVegasView, LasVegasOptions> = {
Board: LasVegasBoard,
Side: LasVegasSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -19,6 +19,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
bang: () => import('./bang'),
'halli-galli': () => import('./halli-galli'),
marble: () => import('./marble'),
'las-vegas': () => import('./las-vegas'),
};
const cache = new Map<string, GameUI>();

View File

@@ -16,6 +16,7 @@ export const GAME_ICON: Record<string, string> = {
marble: '🏙️',
bang: '🤠',
'halli-galli': '🔔',
'las-vegas': '⚅',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */

View File

@@ -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*)?원(?![가-힣])/, '"원" 단위'],

View File

@@ -260,6 +260,68 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
return null;
}
/**
* 라스베가스: one UI step for `p` — press "주사위 굴리기", or pick a number group (every other pick prefers a group
* marked "동점 주의" so duplicate-tie cancellations really happen) and confirm "N번 카지노에 M개 놓기".
* The PC host's first pick uses the keyboard, its second one taps the casino card instead of the group.
*/
async function lasVegasStep(
p: Page,
phone: boolean,
st: { rolls: number; places: number; keyboard: boolean; casinoPick: boolean; shots: Set<string> },
): Promise<boolean> {
const press = (l: ReturnType<Page['locator']>) => (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<string, Plan> = {
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<string, Plan> = {
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<string>() };
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);

View File

@@ -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<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -35,4 +36,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[bang.id]: bang,
[halliGalli.id]: halliGalli,
[marble.id]: marble,
[lasVegas.id]: lasVegas,
};

View File

@@ -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<PlayerId, number[]>;
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<Record<Owner, number>> }[];
hand: Record<PlayerId, { own: number; neutral: number }>;
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<PlayerId, LasVegasMoneyView>;
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<PlayerId, number[]> = {};
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<LasVegasState, LasVegasAction, LasVegasView, LasVegasOptions> = {
id: 'las-vegas',
nameKo: '라스베가스',
minPlayers: 2,
maxPlayers: 5,
stateVersion: 1,
defaultOptions: lasVegasOptionsSchema.parse({}),
optionsSchema: lasVegasOptionsSchema as unknown as ZodType<LasVegasOptions>,
actionSchema: lasVegasActionSchema as unknown as ZodType<LasVegasAction>,
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<number, PlayerId[]>();
for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]);
const scores: Record<PlayerId, number> = {};
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))),
);
},
};

View File

@@ -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<LasVegasOptions> = {}, 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<LasVegasOptions> = {}, 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<number, number[]>): 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<string, number>): 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<number>();
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<string, number[]>): 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<LasVegasOptions>) {
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<LasVegasOptions>[] = [
{},
{ 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);
});

View File

@@ -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 = <T extends number | null>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
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<typeof lasVegasOptionsSchema>;
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<typeof lasVegasActionSchema>;
/** 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<Note, number> = { 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<Note>(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<Record<Owner, number>>;
}
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<PlayerId, { own: number; neutral: number }>;
lastRoll: { own: number[]; neutral: number[] } | null;
money: Record<PlayerId, Note[]>;
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<PlayerId, number>;
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<LasVegasState, 'pile' | 'casinos'>): 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<Record<Owner, number>>, order: Owner[]): Payout & { ranked: Owner[] } {
const owners = order.filter((o) => (dice[o] ?? 0) > 0);
const byCount = new Map<number, Owner[]>();
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<LasVegasState, 'seats'>): Owner[] => [...s.seats, HOUSE];
/** What placing `own` + `neutral` dice on casino `value` would mean for `player` right now (§9 미리 보기). */
export function placementPreview(
s: Pick<LasVegasState, 'seats' | 'casinos'>,
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<LasVegasState, 'casinos'>, 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<LasVegasState, 'hand'>, p: PlayerId): number => (s.hand[p] ? s.hand[p]!.own + s.hand[p]!.neutral : 0);

View File

@@ -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);