Merge branch 'm7/las-vegas' into codex/owner/grp_discord_joke-app
This commit is contained in:
816
apps/web/src/games/las-vegas/index.tsx
Normal file
816
apps/web/src/games/las-vegas/index.tsx
Normal 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 }}>
|
||||
흰 주사위 "켜기"는 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개씩 더 받아요. 같은 숫자가 나오면 내 주사위와 함께 놓이고, 흰 주사위는 "하우스"라는
|
||||
가상의 한 사람으로 쳐요. 하우스와 개수가 같아도 꽝이에요. 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;
|
||||
@@ -22,6 +22,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
dalmuti: () => import('./dalmuti'),
|
||||
splendor: () => import('./splendor'),
|
||||
quoridor: () => import('./quoridor'),
|
||||
'las-vegas': () => import('./las-vegas'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
@@ -19,6 +19,7 @@ export const GAME_ICON: Record<string, string> = {
|
||||
dalmuti: '👑',
|
||||
splendor: '💎',
|
||||
quoridor: '🧱',
|
||||
'las-vegas': '⚅',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
|
||||
@@ -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*)?원(?![가-힣])/, '"원" 단위'],
|
||||
|
||||
114
e2e/games.e2e.ts
114
e2e/games.e2e.ts
@@ -466,6 +466,68 @@ async function qdStep(p: Page, keyboard: boolean): Promise<boolean> {
|
||||
return d === 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 라스베가스: 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.
|
||||
@@ -1269,6 +1331,58 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` quoridor: 3 walls + 1 refused wall, ${steps} pawn moves (${jumps} jump), ${summary.split('\n').find((l) => l.includes('승리'))}`);
|
||||
},
|
||||
},
|
||||
'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);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { marble } from './marble';
|
||||
import { dalmuti } from './dalmuti';
|
||||
import { splendor } from './splendor';
|
||||
import { quoridor } from './quoridor';
|
||||
import { lasVegas } from './las-vegas';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -41,4 +42,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[dalmuti.id]: dalmuti,
|
||||
[splendor.id]: splendor,
|
||||
[quoridor.id]: quoridor,
|
||||
[lasVegas.id]: lasVegas,
|
||||
};
|
||||
|
||||
492
packages/games/src/las-vegas/index.ts
Normal file
492
packages/games/src/las-vegas/index.ts
Normal 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))),
|
||||
);
|
||||
},
|
||||
};
|
||||
712
packages/games/src/las-vegas/las-vegas.test.ts
Normal file
712
packages/games/src/las-vegas/las-vegas.test.ts
Normal 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);
|
||||
});
|
||||
199
packages/games/src/las-vegas/rules.ts
Normal file
199
packages/games/src/las-vegas/rules.ts
Normal 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);
|
||||
@@ -31,6 +31,7 @@ export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true },
|
||||
{ id: 'splendor', nameKo: '보석 상인', minPlayers: 2, maxPlayers: 4, minutes: '20~40', level: '보통', blurb: '보석을 모아 카드를 사고, 할인으로 더 비싼 카드까지! 귀족 방문은 덤', available: true },
|
||||
{ id: 'quoridor', nameKo: '벽 쌓기 길찾기', minPlayers: 2, maxPlayers: 4, minutes: '10~20', level: '쉬움', blurb: '말을 움직이거나 벽을 세워 먼저 반대편 끝줄로! · 2명 또는 4명', 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);
|
||||
|
||||
Reference in New Issue
Block a user