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

- 규칙 모듈 packages/games/src/las-vegas: 지폐 54장 더미·카지노 채우기(합 5 이상), 굴리기/놓기(같은 눈 전부),
  중립(흰) 주사위 변형(auto/on/off, 3명은 라운드 시작 하우스 주사위 2개 자동 배치), 동점 무효 정산,
  하우스·남은 지폐 더미 아래로(카지노 1→6, 큰 금액부터), 라운드마다 선 이동, 장 수 동점 처리,
  시간 초과(굴리기 자동 / 개수→지폐→작은 수 순 자동 놓기)와 3회 연속 자리 비움, 기권,
  hideMoney 시 남의 금액은 장 수만 보이는 view, 놓기 미리 보기(1등/동점 주의) 계산
- 테스트: 문서 10절 시나리오 전부, 1,000시드 무작위 대국(타임아웃·기권 포함, 지폐·주사위 보존,
  정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 100시드
- 화면 apps/web/src/games/las-vegas: 카지노 6곳(PC 한 줄, 폰 3열×2행), 색깔 주사위 쌓기, 놀이용 지폐,
  굴림 애니메이션, 숫자별 묶음 버튼 → 카지노 강조와 예상 순위 → 확정, 라운드 정산(동점 무효) 표시,
  최종 결과표, 폰에서는 굴리기/놓기 영역이 화면 아래에 고정
- 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘, 놀이용 점수 문구 검사 대상에 추가
- E2E: PC 방장 + 폰 친구, 3라운드로 바꿔 결과까지 실제 플레이(키보드 선택, 카지노 카드 선택, 동점 무효 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:53:47 +09:00
parent 559fb8507c
commit fa7ceea668
10 changed files with 2339 additions and 1 deletions

View File

@@ -16,6 +16,7 @@ import { gostop } from './gostop';
import { bang } from './bang';
import { halliGalli } from './halli-galli';
import { marble } from './marble';
import { lasVegas } from './las-vegas';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -35,4 +36,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[bang.id]: bang,
[halliGalli.id]: halliGalli,
[marble.id]: marble,
[lasVegas.id]: lasVegas,
};

View File

@@ -0,0 +1,492 @@
/**
* 라스베가스 (docs/games/las-vegas.md). Hidden info: the bill pile (only its size is public) and, with
* `hideMoney`, the amounts other players won (only the number of bills is public until the end).
*/
import type { ZodType } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import {
ALL_NOTES,
AWAY_AFTER,
AWAY_MS,
CASINO_VALUES,
HOUSE,
OWN_DICE,
autoPlaceValue,
diceLeft,
houseExtraFor,
lasVegasActionSchema,
lasVegasOptionsSchema,
neutralPerFor,
ownerOrder,
placementPreview,
refillCasinos,
settleCasino,
sum,
type CasinoValue,
type LasVegasAction,
type LasVegasOptions,
type LasVegasState,
type Note,
type Owner,
type Payout,
} from './rules';
export * from './rules';
// ---------------------------------------------------------------- result & view types
export interface LasVegasRankRow {
rank: number;
player: PlayerId;
total: number;
notes: Note[];
conceded: boolean;
}
export interface LasVegasResult {
ranking: LasVegasRankRow[];
winners: PlayerId[];
/** Points won per round. */
roundTotals: Record<PlayerId, number[]>;
summary: string;
reason: GameResult['reason'];
}
export interface LasVegasMoneyView {
count: number;
/** null when hidden from this viewer (`hideMoney`). */
total: number | null;
notes: Note[] | null;
}
export interface LasVegasPayoutView {
casino: number;
removedTies: Owner[];
/** `note`/`key` are null for another player's bill while `hideMoney` hides amounts. */
awards: { owner: Owner; note: Note | null; key: string | null }[];
returned: Note[];
}
export interface LasVegasChoice {
value: number;
own: number;
neutral: number;
/** For the player to move, after placing these dice (§9 미리 보기). */
standing: 'first' | 'award' | 'tie' | 'none';
note: Note | null;
countAfter: number;
}
export interface LasVegasView {
options: LasVegasOptions;
me: PlayerId | null;
seats: PlayerId[];
round: number;
rounds: number;
startPlayer: PlayerId;
/** Player to act, null once finished. */
current: PlayerId | null;
phase: LasVegasState['phase'];
pileCount: number;
casinos: { value: CasinoValue; notes: Note[]; dice: Partial<Record<Owner, number>> }[];
hand: Record<PlayerId, { own: number; neutral: number }>;
neutralPer: number;
houseExtra: number;
houseRoll: LasVegasState['houseRoll'];
lastRoll: LasVegasState['lastRoll'];
/** Number groups of the current roll with previews (phase `place`). */
choices: LasVegasChoice[];
/** What a timeout would pick now (phase `place`). */
autoValue: number | null;
lastPlace: LasVegasState['lastPlace'];
money: Record<PlayerId, LasVegasMoneyView>;
roundResults: { round: number; payouts: LasVegasPayoutView[] }[];
conceded: PlayerId[];
away: PlayerId[];
rollSeq: number;
turnSeq: number;
deadlineAt: number | null;
result: LasVegasResult | null;
}
// ---------------------------------------------------------------- state machine
type S = LasVegasState;
const curId = (s: S): PlayerId => s.seats[s.current]!;
const isIn = (s: S, p: PlayerId) => !s.conceded.includes(p);
const activeIds = (s: S) => s.seats.filter((p) => isIn(s, p));
/** Stable React key / leak-check token of one received bill. */
export const awardKey = (round: number, casino: number, owner: Owner, note: Note): string => `lv-r${round}c${casino}-${owner}-${note}`;
function activeOf(s: S): PlayerId[] {
return s.phase === 'finished' ? [] : [curId(s)];
}
function refreshDeadline(s: S, now: number): void {
if (s.phase === 'finished') {
s.deadlineAt = null;
return;
}
const away = (s.timeouts[curId(s)] ?? 0) >= AWAY_AFTER;
if (away) s.deadlineAt = now + AWAY_MS;
else s.deadlineAt = s.options.turnSeconds === null ? null : now + s.options.turnSeconds * 1000;
}
function finish(s: S, ev: GameEvent[]): void {
s.phase = 'finished';
s.lastRoll = null;
s.deadlineAt = null;
ev.push({ type: 'gameOver' });
}
function startRound(s: S, rng: Rng, ev: GameEvent[]): void {
for (const p of s.seats) s.hand[p] = isIn(s, p) ? { own: OWN_DICE, neutral: s.neutralPer } : { own: 0, neutral: 0 };
refillCasinos(s);
ev.push({ type: 'casinosRefilled', round: s.round, casinos: s.casinos.map((c) => [...c.notes]) });
s.houseRoll = null;
if (s.houseExtra > 0) {
const values: number[] = [];
for (let i = 0; i < s.houseExtra; i++) values.push(1 + rng.int(6));
for (const v of values) {
const c = s.casinos[v - 1]!;
c.dice[HOUSE] = (c.dice[HOUSE] ?? 0) + 1;
}
const player = s.seats[s.startSeat]!;
s.houseRoll = { round: s.round, player, values };
ev.push({ type: 'houseDicePlaced', player, values: [...values] });
}
s.current = diceLeft(s, s.seats[s.startSeat]!) > 0 ? s.startSeat : nextSeat(s.startSeat, s.seats.length, (i) => diceLeft(s, s.seats[i]!) === 0);
s.phase = 'roll';
s.lastRoll = null;
s.turnSeq++;
ev.push({ type: 'roundStart', round: s.round, player: curId(s) });
}
function settleRound(s: S, ev: GameEvent[]): void {
const payouts: Payout[] = [];
const back: Note[] = [];
const order = ownerOrder(s);
for (const c of s.casinos) {
const { removedTies, awards, returned } = settleCasino(c.notes, c.dice, order);
if (removedTies.length) ev.push({ type: 'tiesRemoved', casino: c.value, owners: [...removedTies] });
for (const a of awards) {
if (a.owner !== HOUSE) s.money[a.owner]!.push(a.note);
ev.push({ type: 'paid', casino: c.value, owner: a.owner, ...(a.owner === HOUSE || !s.options.hideMoney ? { note: a.note } : {}) });
}
back.push(...returned);
payouts.push({ casino: c.value, removedTies, awards, returned });
c.notes = [];
c.dice = {};
}
s.pile.push(...back);
if (back.length) ev.push({ type: 'notesReturned', notes: back });
s.roundResults.push({ round: s.round, payouts });
ev.push({ type: 'roundEnd', round: s.round });
}
/** Next seat with dice left (the same player again if only they have dice); settles the round when nobody has. */
function advance(s: S, rng: Rng, ev: GameEvent[]): void {
s.lastRoll = null;
const n = s.seats.length;
const hasDice = (i: number) => diceLeft(s, s.seats[i]!) > 0;
const next = nextSeat(s.current, n, (i) => !hasDice(i));
if (hasDice(next)) {
s.current = next;
s.phase = 'roll';
s.turnSeq++;
ev.push({ type: 'turnChanged', player: curId(s) });
return;
}
settleRound(s, ev);
if (s.round >= s.options.rounds) {
finish(s, ev);
return;
}
s.round++;
s.startSeat = nextSeat(s.startSeat, n, (i) => !isIn(s, s.seats[i]!));
startRound(s, rng, ev);
}
function doRoll(s: S, rng: Rng, ev: GameEvent[]): void {
const p = curId(s);
const h = s.hand[p]!;
const own: number[] = [];
const neutral: number[] = [];
for (let i = 0; i < h.own; i++) own.push(1 + rng.int(6));
for (let i = 0; i < h.neutral; i++) neutral.push(1 + rng.int(6));
s.lastRoll = { own, neutral };
s.phase = 'place';
s.rollSeq++;
ev.push({ type: 'rolled', player: p, own: [...own], neutral: [...neutral] });
}
function doPlace(s: S, value: number, auto: boolean, rng: Rng, ev: GameEvent[]): void {
const p = curId(s);
const roll = s.lastRoll;
if (!roll) throw new Error('las-vegas: place without a roll');
const ownCount = roll.own.filter((d) => d === value).length;
const neutralCount = roll.neutral.filter((d) => d === value).length;
if (ownCount + neutralCount === 0) throw new Error('las-vegas: value not rolled');
const c = s.casinos[value - 1]!;
if (ownCount) c.dice[p] = (c.dice[p] ?? 0) + ownCount;
if (neutralCount) c.dice[HOUSE] = (c.dice[HOUSE] ?? 0) + neutralCount;
s.hand[p]!.own -= ownCount;
s.hand[p]!.neutral -= neutralCount;
s.lastPlace = { player: p, value, own: ownCount, neutral: neutralCount, auto };
ev.push({ type: 'placed', player: p, value, ownCount, neutralCount, auto });
advance(s, rng, ev);
}
function doConcede(s: S, p: PlayerId, rng: Rng, ev: GameEvent[]): void {
const wasCurrent = curId(s) === p;
s.conceded.push(p);
s.hand[p] = { own: 0, neutral: 0 };
for (const c of s.casinos) delete c.dice[p];
ev.push({ type: 'conceded', player: p });
if (activeIds(s).length <= 1) {
finish(s, ev);
return;
}
if (wasCurrent) advance(s, rng, ev);
}
// ---------------------------------------------------------------- result
function rankRows(s: S): LasVegasRankRow[] {
const rows = s.seats.map((p) => ({ player: p, total: sum(s.money[p]!), notes: [...s.money[p]!].sort((a, b) => b - a), conceded: !isIn(s, p) }));
rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.total - a.total || b.notes.length - a.notes.length);
const out: LasVegasRankRow[] = [];
rows.forEach((r, i) => {
const prev = out[i - 1];
const tied = prev && prev.conceded === r.conceded && prev.total === r.total && prev.notes.length === r.notes.length;
out.push({ ...r, rank: tied ? prev.rank : i + 1 });
});
return out;
}
function buildResult(s: S): LasVegasResult {
const ranking = rankRows(s);
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
const roundTotals: Record<PlayerId, number[]> = {};
for (const p of s.seats)
roundTotals[p] = s.roundResults.map((rr) => sum(rr.payouts.flatMap((po) => po.awards.filter((a) => a.owner === p).map((a) => a.note))));
const top = ranking[0]!;
const early = s.conceded.length > 0 && activeIds(s).length <= 1;
let summary: string;
if (winners.length > 1) summary = `공동 1위 ${winners.length}명, ${top.total}점 (지폐 ${top.notes.length}장)`;
else {
const second = ranking[1];
summary = `1위 ${top.total}점 (지폐 ${top.notes.length}장)${second ? ` · 2위 ${second.total}점` : ''}`;
}
if (early) summary = `다른 사람이 모두 기권했어요. ${summary}`;
return { ranking, winners, roundTotals, summary, reason: early ? 'resign' : 'normal' };
}
// ---------------------------------------------------------------- definition
export const lasVegas: GameDefinition<LasVegasState, LasVegasAction, LasVegasView, LasVegasOptions> = {
id: 'las-vegas',
nameKo: '라스베가스',
minPlayers: 2,
maxPlayers: 5,
stateVersion: 1,
defaultOptions: lasVegasOptionsSchema.parse({}),
optionsSchema: lasVegasOptionsSchema as unknown as ZodType<LasVegasOptions>,
actionSchema: lasVegasActionSchema as unknown as ZodType<LasVegasAction>,
playerCountError(options, count) {
return neutralPerFor(options.neutralDice, count) === null ? '5명일 때는 중립(흰) 주사위를 켤 수 없어요. "자동"이나 "끄기"로 바꿔 주세요.' : null;
},
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 5) throw new Error('las-vegas: 2~5명이 할 수 있어요.');
if (players.includes(HOUSE)) throw new Error('las-vegas: reserved player id');
const neutralPer = neutralPerFor(options.neutralDice, players.length);
if (neutralPer === null) throw new Error('las-vegas: 5명일 때는 중립 주사위를 켤 수 없어요.');
const pile = rng.shuffle(ALL_NOTES);
const startSeat = rng.int(players.length);
const s: S = {
options,
seats: [...players],
round: 1,
startSeat,
current: startSeat,
phase: 'roll',
pile,
casinos: CASINO_VALUES.map((value) => ({ value, notes: [], dice: {} })),
hand: {},
lastRoll: null,
money: Object.fromEntries(players.map((p) => [p, []])),
roundResults: [],
neutralPer,
houseExtra: houseExtraFor(neutralPer, players.length),
houseRoll: null,
lastPlace: null,
conceded: [],
timeouts: Object.fromEntries(players.map((p) => [p, 0])),
rollSeq: 0,
turnSeq: 0,
deadlineAt: null,
};
startRound(s, rng, []);
refreshDeadline(s, now);
return s;
},
validate(s, actor, a) {
if (!s.money[actor]) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (!isIn(s, actor)) return fail('이미 기권했어요.');
if (a.type === 'CONCEDE') return ok;
if (a.type === 'TIMEOUT') return activeOf(s).includes(actor) ? ok : fail('지금은 시간 초과를 처리할 수 없어요.');
if (curId(s) !== actor) return fail('지금은 내 차례가 아니에요.');
switch (a.type) {
case 'ROLL':
if (s.phase !== 'roll' || diceLeft(s, actor) === 0) return fail('지금은 굴릴 차례가 아니에요.');
return ok;
case 'PLACE': {
if (s.phase !== 'place' || !s.lastRoll) return fail('먼저 주사위를 굴려 주세요.');
const v = a.value;
if (!Number.isInteger(v) || !s.lastRoll.own.concat(s.lastRoll.neutral).includes(v)) return fail('그 숫자는 나오지 않았어요.');
return ok;
}
}
return fail('알 수 없는 행동이에요.');
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const ev: GameEvent[] = [];
if (a.type === 'TIMEOUT') {
s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1;
ev.push({ type: 'timeout', player: actor });
if (s.timeouts[actor] === AWAY_AFTER) ev.push({ type: 'away', player: actor });
if (s.phase === 'roll') doRoll(s, rng, ev);
else doPlace(s, autoPlaceValue(s, s.lastRoll!), true, rng, ev);
} else {
if (a.type !== 'CONCEDE') s.timeouts[actor] = 0;
switch (a.type) {
case 'ROLL':
if (s.phase !== 'roll') throw new Error('las-vegas: not the roll phase');
doRoll(s, rng, ev);
break;
case 'PLACE':
if (s.phase !== 'place') throw new Error('las-vegas: not the place phase');
doPlace(s, a.value, false, rng, ev);
break;
case 'CONCEDE':
doConcede(s, actor, rng, ev);
break;
}
}
refreshDeadline(s, now);
return { state: s, events: ev };
},
view(s, viewer) {
const me = viewer !== null && s.money[viewer] ? viewer : null;
const done = s.phase === 'finished';
const visible = (p: Owner) => done || !s.options.hideMoney || p === me || p === HOUSE;
const money: LasVegasView['money'] = {};
for (const p of s.seats) {
const notes = s.money[p]!;
money[p] = visible(p)
? { count: notes.length, total: sum(notes), notes: [...notes].sort((a, b) => b - a) }
: { count: notes.length, total: null, notes: null };
}
const roundResults = s.roundResults.map((rr) => ({
round: rr.round,
payouts: rr.payouts.map((po) => ({
casino: po.casino,
removedTies: [...po.removedTies],
awards: po.awards.map((aw) =>
visible(aw.owner)
? { owner: aw.owner, note: aw.note, key: awardKey(rr.round, po.casino, aw.owner, aw.note) }
: { owner: aw.owner, note: null, key: null },
),
returned: [...po.returned],
})),
}));
const cur = done ? null : curId(s);
const choices: LasVegasChoice[] = [];
if (cur && s.phase === 'place' && s.lastRoll) {
for (const v of CASINO_VALUES) {
const own = s.lastRoll.own.filter((d) => d === v).length;
const neutral = s.lastRoll.neutral.filter((d) => d === v).length;
if (own + neutral === 0) continue;
const pv = placementPreview(s, cur, v, own, neutral);
choices.push({ value: v, own, neutral, standing: pv.standing, note: pv.note, countAfter: pv.count });
}
}
return {
options: s.options,
me,
seats: [...s.seats],
round: s.round,
rounds: s.options.rounds,
startPlayer: s.seats[s.startSeat]!,
current: cur,
phase: s.phase,
pileCount: s.pile.length,
casinos: s.casinos.map((c) => ({ value: c.value, notes: [...c.notes], dice: { ...c.dice } })),
hand: Object.fromEntries(s.seats.map((p) => [p, { ...s.hand[p]! }])),
neutralPer: s.neutralPer,
houseExtra: s.houseExtra,
houseRoll: s.houseRoll,
lastRoll: s.lastRoll,
choices,
autoValue: cur && s.phase === 'place' && s.lastRoll ? autoPlaceValue(s, s.lastRoll) : null,
lastPlace: s.lastPlace,
money,
roundResults,
conceded: [...s.conceded],
away: s.seats.filter((p) => isIn(s, p) && (s.timeouts[p] ?? 0) >= AWAY_AFTER),
rollSeq: s.rollSeq,
turnSeq: s.turnSeq,
deadlineAt: s.deadlineAt,
result: done ? buildResult(s) : null,
};
},
activePlayers: activeOf,
deadline(s) {
return s.phase === 'finished' ? null : s.deadlineAt;
},
onTimeout() {
return { type: 'TIMEOUT' };
},
onLeave(s, player) {
if (s.phase === 'finished' || !s.money[player] || !isIn(s, player)) return null;
return { type: 'CONCEDE' };
},
result(s): GameResult | null {
if (s.phase !== 'finished') return null;
const r = buildResult(s);
const groups = new Map<number, PlayerId[]>();
for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]);
const scores: Record<PlayerId, number> = {};
for (const row of r.ranking) scores[row.player] = row.total;
return { ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps), scores, summary: r.summary, reason: r.reason };
},
/** Test helper: ROLL, or one PLACE per rolled number (CONCEDE left out). */
legalActions(s, player) {
if (s.phase === 'finished' || curId(s) !== player || !isIn(s, player)) return [];
if (s.phase === 'roll') return [{ type: 'ROLL' }];
const vals = [...new Set([...s.lastRoll!.own, ...s.lastRoll!.neutral])].sort((a, b) => a - b);
return vals.map((value) => ({ type: 'PLACE' as const, value }));
},
/** Test helper: keys of this player's bills that other viewers must not see (while amounts are hidden). */
secretsOf(s, player) {
if (s.phase === 'finished' || !s.options.hideMoney) return [];
return s.roundResults.flatMap((rr) =>
rr.payouts.flatMap((po) => po.awards.filter((a) => a.owner === player).map((a) => awardKey(rr.round, po.casino, player, a.note))),
);
},
};

View File

@@ -0,0 +1,712 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
ALL_NOTES,
AWAY_AFTER,
AWAY_MS,
HOUSE,
MIN_CASINO_SUM,
NOTE_COUNTS,
OWN_DICE,
autoPlaceValue,
lasVegas as lv,
lasVegasActionSchema,
lasVegasOptionsSchema,
refillCasinos,
settleCasino,
sum,
type LasVegasAction,
type LasVegasOptions,
type LasVegasState,
type LasVegasView,
} from './index';
// ---------------------------------------------------------------- helpers
let clock = 1_000_000;
const rng = () => SeededRng.fromSeed([5, 4, 3, 2]);
function make(opts: Partial<LasVegasOptions> = {}, players = ['A', 'B', 'C'], seed = 1): LasVegasState {
const options = lasVegasOptionsSchema.parse(opts);
return lv.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock });
}
/** A game whose round-1 first player is seat 0 (seeds are searched). */
function makeFirst(opts: Partial<LasVegasOptions> = {}, players = ['A', 'B', 'C']): LasVegasState {
for (let seed = 1; seed < 500; seed++) {
const s = make(opts, players, seed);
if (s.startSeat === 0 && (s.houseExtra === 0 || s.houseRoll)) return s;
}
throw new Error('no seed starts at seat 0');
}
function actE(s: LasVegasState, p: string, a: LasVegasAction, r: SeededRng = rng()): { state: LasVegasState; events: GameEvent[] } {
const v = lv.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return lv.apply(s, p, a, { rng: r, now: (clock += 1000) });
}
const act = (s: LasVegasState, p: string, a: LasVegasAction, r?: SeededRng) => actE(s, p, a, r).state;
const reject = (s: LasVegasState, p: string, a: LasVegasAction) => {
const v = lv.validate(s, p, a);
return v.ok ? null : v.reason;
};
const cur = (s: LasVegasState) => s.seats[s.current]!;
/** Rolls for the current player, then forces the dice (lengths must match the hand). */
function rolled(s: LasVegasState, own: number[], neutral: number[] = []): LasVegasState {
const p = cur(s);
const t = act(s, p, { type: 'ROLL' });
expect(t.lastRoll!.own.length).toBe(own.length);
expect(t.lastRoll!.neutral.length).toBe(neutral.length);
t.lastRoll = { own: [...own], neutral: [...neutral] };
return t;
}
/** Sets every casino's bills (missing = no bills); bills come out of the pile so 54 are conserved. */
function setBills(s: LasVegasState, bills: Record<number, number[]>): void {
const pool = [...s.pile, ...s.casinos.flatMap((c) => c.notes)];
for (const c of s.casinos) {
c.notes = [...(bills[c.value] ?? [])].sort((a, b) => b - a);
for (const n of c.notes) {
const i = pool.indexOf(n);
if (i < 0) throw new Error(`no bill ${n} left`);
pool.splice(i, 1);
}
}
s.pile = pool;
}
/** Puts dice on a casino straight from the owners' hands (house dice need no hand). */
function putDice(s: LasVegasState, value: number, dice: Record<string, number>): void {
const c = s.casinos[value - 1]!;
for (const [o, n] of Object.entries(dice)) {
c.dice[o] = (c.dice[o] ?? 0) + n;
if (o !== HOUSE) s.hand[o]!.own -= n;
}
}
const allBills = (s: LasVegasState) => [...s.pile, ...s.casinos.flatMap((c) => c.notes), ...Object.values(s.money).flat()].sort((a, b) => a - b);
const SORTED_NOTES = [...ALL_NOTES].sort((a, b) => a - b);
// ---------------------------------------------------------------- setup
describe('setup (§3, §4, §10)', () => {
test('54 bills 6·8·8·6·6·5·5·5·5 = 250', () => {
expect(ALL_NOTES.length).toBe(54);
expect(sum(ALL_NOTES)).toBe(250);
expect(Object.values(NOTE_COUNTS)).toEqual([6, 8, 8, 6, 6, 5, 5, 5, 5]);
for (let seed = 1; seed <= 50; seed++) expect(allBills(make({}, ['A', 'B'], seed))).toEqual(SORTED_NOTES);
});
test('each casino ≥ 5, and without the last bill dealt it would be < 5 (dealt from the shuffled pile, top first)', () => {
for (let seed = 1; seed <= 200; seed++) {
const s = make({}, ['A', 'B', 'C'], seed);
// Re-create the shuffle: setup shuffles first.
const pile = SeededRng.fromSeed(seedN(seed)).shuffle(ALL_NOTES);
for (const c of s.casinos) {
const dealt: number[] = [];
while (sum(dealt) < MIN_CASINO_SUM) dealt.push(pile.shift()!);
expect(c.notes).toEqual([...dealt].sort((a, b) => b - a));
expect(sum(c.notes)).toBeGreaterThanOrEqual(5);
expect(sum(dealt) - dealt[dealt.length - 1]!).toBeLessThan(5);
}
expect(s.pile).toEqual(pile);
}
});
test('refill: [1,1,1,1,9,…] gives casino 1 four 1s + 9; an empty pile leaves casinos short', () => {
const t = { pile: [1, 1, 1, 1, 9, 5, 2, 3, 6], casinos: [1, 2, 3, 4, 5, 6].map((v) => ({ value: v as 1, notes: [] as number[], dice: {} })) };
refillCasinos(t);
expect(t.casinos.map((c) => c.notes)).toEqual([[9, 1, 1, 1, 1], [5], [3, 2], [6], [], []]);
expect(t.pile).toEqual([]);
});
test('dice per player: 8 own; neutral auto = 4 each for 2 players only; on = 4 (2p) / 2 (3–4p); off = 0', () => {
expect(make({}, ['A', 'B']).hand.A).toEqual({ own: 8, neutral: 4 });
expect(make({}, ['A', 'B', 'C']).hand.A).toEqual({ own: 8, neutral: 0 });
expect(make({ neutralDice: 'on' }, ['A', 'B']).hand.B).toEqual({ own: 8, neutral: 4 });
expect(make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D']).hand.D).toEqual({ own: 8, neutral: 2 });
expect(make({ neutralDice: 'off' }, ['A', 'B']).hand.A).toEqual({ own: 8, neutral: 0 });
expect(make({}, ['A', 'B', 'C', 'D', 'E']).hand.E).toEqual({ own: 8, neutral: 0 });
});
test('5 players + neutral on is rejected (option check), auto/off are fine', () => {
const on = lasVegasOptionsSchema.parse({ neutralDice: 'on' });
expect(lv.playerCountError!(on, 5)).toContain('5명');
expect(lv.playerCountError!(on, 4)).toBeNull();
expect(lv.playerCountError!(lasVegasOptionsSchema.parse({}), 5)).toBeNull();
expect(() => make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D', 'E'])).toThrow();
});
test('the first player is chosen by the RNG (varies by seed)', () => {
const starts = new Set<number>();
for (let seed = 1; seed <= 40; seed++) starts.add(make({}, ['A', 'B', 'C', 'D'], seed).startSeat);
expect(starts.size).toBe(4);
});
});
// ---------------------------------------------------------------- turns
describe('ROLL / PLACE (§5, §8.2, §10)', () => {
test('PLACE moves every die of that number (own + neutral); unrolled numbers and out-of-turn are rejected', () => {
let s = makeFirst({ neutralDice: 'on' }, ['A', 'B']);
expect(reject(s, 'A', { type: 'PLACE', value: 3 })).toBe('먼저 주사위를 굴려 주세요.');
expect(reject(s, 'B', { type: 'ROLL' })).toBe('지금은 내 차례가 아니에요.');
s = rolled(s, [3, 3, 5, 3, 1, 1, 2, 2], [3, 6, 6, 4]);
expect(reject(s, 'A', { type: 'ROLL' })).toBe('지금은 굴릴 차례가 아니에요.');
expect(reject(s, 'A', { type: 'PLACE', value: 4.5 } as never)).toBe('그 숫자는 나오지 않았어요.');
const v = lv.view(s, 'A');
expect(v.choices.map((c) => [c.value, c.own, c.neutral])).toEqual([
[1, 2, 0],
[2, 2, 0],
[3, 3, 1],
[4, 0, 1],
[5, 1, 0],
[6, 0, 2],
]);
const { state, events } = actE(s, 'A', { type: 'PLACE', value: 3 });
expect(state.casinos[2]!.dice).toEqual({ A: 3, house: 1 });
expect(state.hand.A).toEqual({ own: 5, neutral: 3 });
expect(events.find((e) => e.type === 'placed')).toMatchObject({ player: 'A', value: 3, ownCount: 3, neutralCount: 1 });
expect(cur(state)).toBe('B');
expect(state.phase).toBe('roll');
});
test('a number that only came up on neutral dice may be chosen: only the neutral dice move', () => {
let s = makeFirst({}, ['A', 'B']);
s = rolled(s, [1, 1, 2, 2, 3, 3, 4, 4], [6, 6, 5, 1]);
expect(reject(s, 'A', { type: 'PLACE', value: 6 })).toBeNull();
s = act(s, 'A', { type: 'PLACE', value: 6 });
expect(s.casinos[5]!.dice).toEqual({ house: 2 });
expect(s.hand.A).toEqual({ own: 8, neutral: 2 });
});
test('players without dice are skipped; the last one with dice takes turns in a row', () => {
let s = makeFirst({}, ['A', 'B', 'C']);
// A places all 8 at once, B 7 of 8, C all 8.
s = rolled(s, [2, 2, 2, 2, 2, 2, 2, 2]);
s = act(s, 'A', { type: 'PLACE', value: 2 });
s = rolled(s, [4, 4, 4, 4, 4, 4, 4, 1]);
s = act(s, 'B', { type: 'PLACE', value: 4 });
expect(cur(s)).toBe('C');
s = rolled(s, [5, 5, 5, 5, 5, 5, 5, 5]);
s = act(s, 'C', { type: 'PLACE', value: 5 });
// A has no dice: B again.
expect(cur(s)).toBe('B');
expect(s.hand.B).toEqual({ own: 1, neutral: 0 });
s = rolled(s, [6]);
const { state, events } = actE(s, 'B', { type: 'PLACE', value: 6 });
expect(events.some((e) => e.type === 'roundEnd')).toBe(true);
expect(state.round).toBe(2);
});
test('3 players: A,B used up → C rolls several times in a row', () => {
let s = makeFirst({}, ['A', 'B', 'C']);
s = rolled(s, [2, 2, 2, 2, 2, 2, 2, 2]);
s = act(s, 'A', { type: 'PLACE', value: 2 });
s = rolled(s, [3, 3, 3, 3, 3, 3, 3, 3]);
s = act(s, 'B', { type: 'PLACE', value: 3 });
s = rolled(s, [1, 1, 1, 1, 4, 4, 4, 4]);
s = act(s, 'C', { type: 'PLACE', value: 1 });
expect(cur(s)).toBe('C');
s = rolled(s, [4, 4, 5, 6]);
s = act(s, 'C', { type: 'PLACE', value: 4 });
expect(cur(s)).toBe('C');
expect(s.round).toBe(1);
});
});
// ---------------------------------------------------------------- settlement
describe('settlement (§5, §5.1, §10)', () => {
const order = ['A', 'B', 'C', 'D', HOUSE];
test('example 1: [8,3,1] A5 B3 C3 D1 → B·C removed, A 8, D 3, 1 returned', () => {
const r = settleCasino([8, 3, 1], { A: 5, B: 3, C: 3, D: 1 }, order);
expect(r.removedTies).toEqual(['B', 'C']);
expect(r.awards).toEqual([
{ owner: 'A', note: 8 },
{ owner: 'D', note: 3 },
]);
expect(r.returned).toEqual([1]);
});
test('example 2: [4,4] A2 C2 B1 D1 → everyone removed, both bills returned', () => {
const r = settleCasino([4, 4], { A: 2, C: 2, B: 1, D: 1 }, order);
expect(r.removedTies).toEqual(['A', 'B', 'C', 'D']);
expect(r.awards).toEqual([]);
expect(r.returned).toEqual([4, 4]);
});
test('example 3: [7,2] C4 B2 D1 → C 7, B 2, D nothing', () => {
const r = settleCasino([7, 2], { C: 4, B: 2, D: 1 }, order);
expect(r.removedTies).toEqual([]);
expect(r.awards).toEqual([
{ owner: 'C', note: 7 },
{ owner: 'B', note: 2 },
]);
expect(r.returned).toEqual([]);
});
test('example 4 (neutral, 2 players): [9] house 3, A 2, B 1 → the house takes 9 (returned), A·B nothing', () => {
const r = settleCasino([9], { house: 3, A: 2, B: 1 }, ['A', 'B', HOUSE]);
expect(r.awards).toEqual([{ owner: HOUSE, note: 9 }]);
expect(r.returned).toEqual([9]);
});
test('house and a player tie → both removed', () => {
const r = settleCasino([6, 5], { house: 2, A: 2, B: 1 }, ['A', 'B', HOUSE]);
expect(r.removedTies).toEqual(['A', HOUSE]);
expect(r.awards).toEqual([{ owner: 'B', note: 6 }]);
expect(r.returned).toEqual([5]);
});
test('a full round in play: payouts, money, roundResults and bills back to the bottom in casino 1→6 order, high first', () => {
let s = makeFirst({}, ['A', 'B']);
setBills(s, { 1: [9], 2: [8, 3, 1], 3: [4, 4], 4: [7, 2], 5: [5], 6: [6, 1] });
const pileBefore = [...s.pile];
// Board before the last placement: casino 1 house 3/A 2/B 1, casino 2 A3/B3, casino 3 A2/B2, casino 4 B1, casino 6 house 1/A 1.
putDice(s, 1, { house: 3, A: 2, B: 1 });
putDice(s, 2, { A: 3, B: 3 });
putDice(s, 3, { A: 2, B: 2 });
putDice(s, 4, { B: 1 });
putDice(s, 6, { house: 1, A: 1 });
s.hand.A = { own: 0, neutral: 0 };
s.hand.B = { own: 1, neutral: 0 };
s.current = 1;
s = rolled(s, [4]);
const { state, events } = actE(s, 'B', { type: 'PLACE', value: 4 });
const rr = state.roundResults[0]!;
expect(rr.round).toBe(1);
expect(rr.payouts.map((p) => [p.casino, p.removedTies, p.awards.map((a) => `${a.owner}:${a.note}`), p.returned])).toEqual([
[1, [], ['house:9'], [9]],
[2, ['A', 'B'], [], [8, 3, 1]],
[3, ['A', 'B'], [], [4, 4]],
[4, [], ['B:7'], [2]],
[5, [], [], [5]],
[6, ['A', 'house'], [], [6, 1]],
]);
expect(state.money).toEqual({ A: [], B: [7] });
// Returned bills go under the old pile (then casinos are refilled from the top for round 2).
const expectedPile = [...pileBefore, 9, 8, 3, 1, 4, 4, 2, 5, 6, 1];
const dealtRound2 = state.casinos.flatMap((c) => c.notes).length;
expect(state.pile).toEqual(expectedPile.slice(dealtRound2));
expect(events.filter((e) => e.type === 'tiesRemoved').map((e) => e.casino)).toEqual([2, 3, 6]);
expect(events.find((e) => e.type === 'notesReturned')).toMatchObject({ notes: [9, 8, 3, 1, 4, 4, 2, 5, 6, 1] });
// hideMoney (default): the paid event hides B's amount, the house's is public.
expect(events.filter((e) => e.type === 'paid')).toEqual([
{ type: 'paid', casino: 1, owner: HOUSE, note: 9 },
{ type: 'paid', casino: 4, owner: 'B' },
]);
expect(allBills(state)).toEqual(SORTED_NOTES);
});
});
// ---------------------------------------------------------------- rounds
describe('rounds (§4.5, §5, §8.2, §10)', () => {
/** Plays the round out: everyone puts all dice on `value` (cycling 1..6 per player). */
function playRound(s: LasVegasState): LasVegasState {
const round = s.round;
let guard = 0;
while (s.round === round && s.phase !== 'finished' && guard++ < 200) {
const p = cur(s);
const h = s.hand[p]!;
const v = 1 + (s.seats.indexOf(p) % 6);
s = rolled(s, Array(h.own).fill(v), Array(h.neutral).fill(v));
s = act(s, p, { type: 'PLACE', value: v });
}
return s;
}
test('the first player moves one seat left each round; the game ends after `rounds` (4 by default)', () => {
let s = make({}, ['A', 'B', 'C'], 3);
const firsts: number[] = [s.startSeat];
for (let r = 1; r <= 4; r++) {
expect(s.round).toBe(r);
expect(cur(s)).toBe(s.seats[s.startSeat]!);
s = playRound(s);
if (r < 4) firsts.push(s.startSeat);
}
expect(s.phase).toBe('finished');
expect(s.roundResults.length).toBe(4);
expect(firsts).toEqual([0, 1, 2, 3].map((i) => (firsts[0]! + i) % 3));
expect(lv.result(s)).not.toBeNull();
});
test('rounds = 3 ends after three rounds; dice come back each round', () => {
let s = make({ rounds: 3 }, ['A', 'B'], 2);
s = playRound(s);
expect(s.hand).toEqual({ A: { own: 8, neutral: 4 }, B: { own: 8, neutral: 4 } });
expect(s.casinos.every((c) => Object.keys(c.dice).length === 0 && sum(c.notes) >= 5)).toBe(true);
s = playRound(playRound(s));
expect(s.phase).toBe('finished');
});
test('3 players + neutral on: at every round start the first player rolls the 2 spare house dice onto casinos', () => {
let s = make({ neutralDice: 'on' }, ['A', 'B', 'C'], 4);
expect(s.hand.A).toEqual({ own: 8, neutral: 2 });
const house = () => s.casinos.reduce((n, c) => n + (c.dice.house ?? 0), 0);
expect(house()).toBe(2);
expect(s.houseRoll).toMatchObject({ round: 1, player: s.seats[s.startSeat] });
for (const v of s.houseRoll!.values) expect(s.casinos[v - 1]!.dice.house).toBeGreaterThanOrEqual(1);
const r = rng();
const t = make({ neutralDice: 'on' }, ['A', 'B', 'C'], 4);
t.hand = { A: { own: 0, neutral: 0 }, B: { own: 0, neutral: 0 }, C: { own: 1, neutral: 0 } };
t.current = 2;
t.phase = 'place';
t.lastRoll = { own: [1], neutral: [] };
const { state, events } = lv.apply(t, 'C', { type: 'PLACE', value: 1 }, { rng: r, now: 5 });
expect(events.find((e) => e.type === 'houseDicePlaced')).toMatchObject({ player: state.seats[state.startSeat] });
s = state;
expect(s.round).toBe(2);
expect(house()).toBe(2);
expect(s.houseRoll!.round).toBe(2);
// 4 players + on: no spare dice.
expect(make({ neutralDice: 'on' }, ['A', 'B', 'C', 'D']).houseRoll).toBeNull();
});
});
// ---------------------------------------------------------------- result
describe('final ranking (§6, §8.6, §10)', () => {
function finished(money: Record<string, number[]>): LasVegasState {
const s = make({}, Object.keys(money), 1);
s.money = money;
s.phase = 'finished';
return s;
}
test('higher total wins; equal totals → more bills wins; equal both → joint rank', () => {
let r = lv.result(finished({ A: [9, 9], B: [6, 6, 6], C: [5] }))!;
expect(r.ranking).toEqual([['B'], ['A'], ['C']]);
r = lv.result(finished({ A: [9, 9], B: [6, 6, 6], C: [9, 4, 5] }))!;
expect(r.ranking).toEqual([['B', 'C'], ['A']]);
expect(r.summary).toBe('공동 1위 2명, 18점 (지폐 3장)');
r = lv.result(finished({ A: [9, 8], B: [6, 6] }))!;
expect(r.summary).toBe('1위 17점 (지폐 2장) · 2위 12점');
expect(r.scores).toEqual({ A: 17, B: 12 });
expect(r.reason).toBe('normal');
const v = lv.view(finished({ A: [9, 8], B: [6, 6] }), null);
expect(v.result!.ranking.map((x) => [x.rank, x.player, x.total, x.notes])).toEqual([
[1, 'A', 17, [9, 8]],
[2, 'B', 12, [6, 6]],
]);
// Everything is revealed at the end.
expect(v.money.B).toEqual({ count: 2, total: 12, notes: [6, 6] });
});
});
// ---------------------------------------------------------------- timeout / away / leave
describe('timeout, away and leaving (§8.5, §10)', () => {
test('timeout rolls in the roll phase, then places the number with the most dice', () => {
let s = makeFirst({ turnSeconds: 20 }, ['A', 'B']);
expect(s.deadlineAt).toBe(clock + 20_000);
expect(lv.onTimeout(s, 'A')).toEqual({ type: 'TIMEOUT' });
s = act(s, 'A', { type: 'TIMEOUT' });
expect(s.phase).toBe('place');
expect(s.deadlineAt).toBe(clock + 20_000);
s.lastRoll = { own: [2, 2, 5, 5, 5, 1, 1, 6], neutral: [2, 3, 4, 6] };
// 2 and 5 both have 3 dice (one of the 2s is neutral) → the better top bill decides.
setBills(s, { 2: [3, 2], 5: [9] });
expect(autoPlaceValue(s, s.lastRoll)).toBe(5);
expect(lv.view(s, 'B').autoValue).toBe(5);
setBills(s, { 2: [9], 5: [9], 6: [9] });
expect(autoPlaceValue(s, s.lastRoll)).toBe(2); // same count and bill → smaller number
s.lastRoll = { own: [4, 4, 4, 4, 1, 2, 3, 6], neutral: [6, 6, 1, 1] };
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' });
expect(state.casinos[3]!.dice.A).toBe(4);
expect(events.find((e) => e.type === 'placed')).toMatchObject({ value: 4, auto: true });
expect(state.lastPlace!.auto).toBe(true);
});
test('turnSeconds null → no deadline; 3 timeouts in a row → away (5 s deadline); acting again clears it', () => {
let s = makeFirst({ turnSeconds: null }, ['A', 'B']);
expect(lv.deadline!(s)).toBeNull();
for (let i = 0; i < AWAY_AFTER; i++) s = act(s, cur(s), { type: 'TIMEOUT' });
// A: roll, place (B's turn), B: roll — B 1 timeout, A 2.
expect(lv.view(s, null).away).toEqual([]);
s = act(s, 'B', { type: 'TIMEOUT' }); // B place → A
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' }); // A's 3rd
expect(events.some((e) => e.type === 'away' && e.player === 'A')).toBe(true);
expect(lv.view(state, null).away).toEqual(['A']);
expect(state.deadlineAt).toBe(clock + AWAY_MS);
s = act(state, 'A', { type: 'PLACE', value: state.lastRoll!.own[0] ?? state.lastRoll!.neutral[0]! });
expect(lv.view(s, null).away).toEqual([]);
});
test('a disconnected player (only timeouts) does not block the game: rounds finish', () => {
let s = make({}, ['A', 'B', 'C'], 7);
const r = SeededRng.fromSeed(seedN(70));
let steps = 0;
while (s.phase !== 'finished' && steps++ < 2000) {
const p = cur(s);
const a: LasVegasAction = p === 'B' ? lv.onTimeout(s, p) : lv.legalActions!(s, p)[0]!;
expect(lv.validate(s, p, a)).toEqual({ ok: true });
s = lv.apply(s, p, a, { rng: r, now: (clock += 1000) }).state;
}
expect(s.phase).toBe('finished');
expect(s.roundResults.length).toBe(4);
});
test('concede: dice discarded (also those on the board), skipped afterwards, bills kept, ranked last; last one standing ends the game', () => {
let s = makeFirst({}, ['A', 'B', 'C']);
s.money.B = [9, 9, 9];
for (let i = 0; i < 3; i++) s.pile.splice(s.pile.indexOf(9), 1);
s = rolled(s, [2, 2, 2, 3, 3, 4, 4, 5]);
s = act(s, 'A', { type: 'PLACE', value: 2 });
s = rolled(s, [1, 1, 1, 1, 1, 1, 1, 6]);
s = act(s, 'B', { type: 'PLACE', value: 1 });
expect(lv.onLeave!(s, 'B')).toEqual({ type: 'CONCEDE' });
s = act(s, 'B', { type: 'CONCEDE' }); // not B's turn
expect(s.casinos[0]!.dice.B).toBeUndefined();
expect(s.hand.B).toEqual({ own: 0, neutral: 0 });
expect(reject(s, 'B', { type: 'ROLL' })).toBe('이미 기권했어요.');
expect(lv.onLeave!(s, 'B')).toBeNull();
expect(cur(s)).toBe('C');
s = rolled(s, [6, 6, 6, 6, 6, 6, 6, 6]);
s = act(s, 'C', { type: 'PLACE', value: 6 });
expect(cur(s)).toBe('A'); // B skipped
const { state, events } = actE(s, 'C', { type: 'CONCEDE' });
expect(events.some((e) => e.type === 'gameOver')).toBe(true);
const r = lv.result(state)!;
expect(r.ranking).toEqual([['A'], ['B'], ['C']]); // conceded players last (B has more bills than C)
expect(r.reason).toBe('resign');
expect(r.summary.startsWith('다른 사람이 모두 기권했어요.')).toBe(true);
expect(allBills(state)).toEqual([...SORTED_NOTES]);
});
test('the current player conceding passes the turn on', () => {
let s = makeFirst({}, ['A', 'B', 'C']);
s = rolled(s, [2, 2, 2, 3, 3, 4, 4, 5]);
s = act(s, 'A', { type: 'CONCEDE' });
expect(cur(s)).toBe('B');
expect(s.phase).toBe('roll');
expect(s.lastRoll).toBeNull();
});
});
// ---------------------------------------------------------------- view / hidden info
describe('view and hidden info (§8.3, §10)', () => {
function withMoney(opts: Partial<LasVegasOptions>) {
const s = makeFirst(opts, ['A', 'B', 'C']);
s.money = { A: [9, 2], B: [7], C: [] };
s.roundResults = [
{
round: 1,
payouts: [
{
casino: 3,
removedTies: ['C'],
awards: [
{ owner: 'A', note: 9 },
{ owner: 'B', note: 7 },
{ owner: HOUSE, note: 5 },
{ owner: 'A', note: 2 },
],
returned: [5],
},
],
},
];
// Keep 54 bills: take them out of the pile.
for (const n of [9, 2, 7]) s.pile.splice(s.pile.indexOf(n), 1);
return s;
}
test('hideMoney: other players and spectators see bill counts only; own amounts and house/returned bills are public', () => {
const s = withMoney({});
const vb = lv.view(s, 'B');
expect(vb.money.A).toEqual({ count: 2, total: null, notes: null });
expect(vb.money.B).toEqual({ count: 1, total: 7, notes: [7] });
const aw = vb.roundResults[0]!.payouts[0]!.awards;
expect(aw.map((a) => [a.owner, a.note])).toEqual([
['A', null],
['B', 7],
[HOUSE, 5],
['A', null],
]);
const spec = lv.view(s, null);
expect(Object.values(spec.money).every((m) => m.total === null && m.notes === null)).toBe(true);
expect(spec.roundResults[0]!.payouts[0]!.awards.filter((a) => a.owner !== HOUSE).every((a) => a.note === null && a.key === null)).toBe(true);
for (const viewer of ['A', 'B', 'C', null]) {
const json = JSON.stringify(lv.view(s, viewer));
expect(json).not.toContain('"pile"');
expect(json).not.toContain('"rng"');
expect(json).not.toContain('"timeouts"');
for (const other of ['A', 'B', 'C']) {
if (other === viewer) continue;
for (const secret of lv.secretsOf!(s, other)) expect(json).not.toContain(secret);
}
}
expect(lv.view(s, 'A').pileCount).toBe(s.pile.length);
expect(lv.secretsOf!(s, 'A').length).toBe(2);
});
test('hideMoney = false: amounts are public', () => {
const s = withMoney({ hideMoney: false });
expect(lv.view(s, null).money.A).toEqual({ count: 2, total: 11, notes: [9, 2] });
expect(lv.secretsOf!(s, 'A')).toEqual([]);
});
test('choices preview for the player to move: 1등 / 동점 주의 / lower bill / nothing', () => {
let s = makeFirst({ neutralDice: 'off' }, ['A', 'B']);
setBills(s, { 1: [9, 4], 2: [8], 3: [6], 4: [5] });
putDice(s, 1, { B: 3 });
putDice(s, 2, { B: 2 });
putDice(s, 3, { B: 1 });
putDice(s, 4, { B: 2 });
s = rolled(s, [1, 2, 2, 3, 3, 3, 4, 5]);
const v: LasVegasView = lv.view(s, 'B');
const by = Object.fromEntries(v.choices.map((c) => [c.value, [c.standing, c.note, c.countAfter]]));
expect(by).toEqual({ 1: ['award', 4, 1], 2: ['tie', null, 2], 3: ['first', 6, 3], 4: ['none', null, 1], 5: ['none', null, 1] });
});
test('no RNG/pile in the view; same seed + same actions → same result (replay)', () => {
const run = () => {
let s = make({}, ['A', 'B', 'C'], 11);
const r = SeededRng.fromSeed(seedN(12));
let t = 0;
while (s.phase !== 'finished') {
const p = cur(s);
const legal = lv.legalActions!(s, p);
s = lv.apply(s, p, legal[t++ % legal.length]!, { rng: r, now: t * 1000 }).state;
}
return s;
};
expect(JSON.stringify(run())).toBe(JSON.stringify(run()));
expect(run().money).toEqual(run().money);
});
test('schema: defaults, Korean metadata for every option, client cannot send TIMEOUT', () => {
expect(lv.defaultOptions).toEqual({ rounds: 4, neutralDice: 'auto', hideMoney: true, turnSeconds: 30 });
const shape = lasVegasOptionsSchema.shape;
for (const k of Object.keys(shape) as (keyof typeof shape)[]) {
const meta = shape[k].meta() as { title?: string };
expect(meta?.title).toMatch(/[가-힣]/);
}
expect(lasVegasActionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false);
expect(lasVegasActionSchema.safeParse({ type: 'PLACE', value: 7 }).success).toBe(false);
expect(lasVegasActionSchema.safeParse({ type: 'PLACE', value: 3 }).success).toBe(true);
expect(lasVegasOptionsSchema.safeParse({ turnSeconds: null }).success).toBe(true);
expect(lasVegasOptionsSchema.safeParse({ rounds: 6 }).success).toBe(false);
});
});
// ---------------------------------------------------------------- random playouts
describe('random playouts (docs/09 §7)', () => {
const OPTS: Partial<LasVegasOptions>[] = [
{},
{ neutralDice: 'on' },
{ neutralDice: 'off', hideMoney: false },
{ rounds: 3, turnSeconds: null },
{ rounds: 5, turnSeconds: 20 },
];
function invariant(s: LasVegasState, total: { neutral: number }): void {
// 54 bills always: pile + casinos + money.
const bills = allBills(s);
if (bills.length !== 54 || bills.join() !== SORTED_NOTES.join()) throw new Error('bills not conserved');
if (s.phase === 'finished') return;
// Dice: active players hold 8 own dice between hand and casinos; house dice never exceed the neutral pool.
let house = 0;
for (const c of s.casinos) {
house += c.dice.house ?? 0;
for (const [o, n] of Object.entries(c.dice)) if (o !== HOUSE && (n! <= 0 || s.conceded.includes(o))) throw new Error('bad dice count');
if (c.notes.some((n, i) => i > 0 && n > c.notes[i - 1]!)) throw new Error('casino bills not descending');
}
for (const p of s.seats) {
const placed = s.casinos.reduce((n, c) => n + (c.dice[p] ?? 0), 0);
if (!s.conceded.includes(p) && placed + s.hand[p]!.own !== OWN_DICE) throw new Error(`own dice of ${p} not conserved`);
}
const neutralHands = s.seats.reduce((n, p) => n + s.hand[p]!.neutral, 0);
if (s.conceded.length === 0 && house + neutralHands !== total.neutral) throw new Error('neutral dice not conserved');
if (house + neutralHands > total.neutral) throw new Error('too many neutral dice');
// The player to act always has dice.
if (s.hand[cur(s)]!.own + s.hand[cur(s)]!.neutral === 0) throw new Error('current player has no dice');
}
function leakCheck(s: LasVegasState): void {
for (const viewer of [...s.seats, null]) {
const v = lv.view(s, viewer);
const json = JSON.stringify(v);
if (json.includes('"pile"') || json.includes('"rng"')) throw new Error('pile/rng in view');
if (s.phase === 'finished' || !s.options.hideMoney) continue;
for (const p of s.seats) {
if (p === viewer) continue;
if (v.money[p]!.total !== null || v.money[p]!.notes !== null) throw new Error(`money of ${p} leaked to ${viewer}`);
for (const rr of v.roundResults)
for (const po of rr.payouts) for (const a of po.awards) if (a.owner === p && a.note !== null) throw new Error('award leaked');
for (const secret of lv.secretsOf!(s, p)) if (json.includes(secret)) throw new Error('secret leaked');
}
}
}
test('1,000 seeds (custom fast loop with timeouts and concedes): finish, conservation, no leaks, onTimeout legal, deterministic', () => {
let finished = 0;
const play = (i: number, check: boolean): string => {
const n = 2 + (i % 4) + (i % 7 === 0 ? 1 : 0);
const players = Array.from({ length: Math.min(5, n) }, (_, k) => `p${k}`);
let opts = OPTS[i % OPTS.length]!;
if (players.length === 5 && opts.neutralDice === 'on') opts = { ...opts, neutralDice: 'auto' };
const options = lasVegasOptionsSchema.parse(opts);
const rngG = SeededRng.fromSeed(seedN(i));
const chooser = SeededRng.fromSeed(seedN(i + 100_000));
let s = lv.setup({ players, options, rng: rngG, now: 0 });
const total = { neutral: s.neutralPer * players.length + s.houseExtra };
let now = 0;
for (let step = 0; step < 2000; step++) {
if (check) {
invariant(s, total);
if (step % 5 === 0) leakCheck(s);
}
if (lv.result(s)) {
if (check) leakCheck(s);
return JSON.stringify(s);
}
const p = cur(s);
if (check) {
const t = lv.onTimeout(s, p);
const v = lv.validate(s, p, t);
if (!v.ok) throw new Error(`timeout rejected: ${v.reason}`);
}
const roll = chooser.int(100);
let actor = p;
let a: LasVegasAction;
if (roll < 1) {
// A random seated player concedes now and then.
const alive = s.seats.filter((q) => !s.conceded.includes(q));
actor = alive[chooser.int(alive.length)]!;
a = { type: 'CONCEDE' };
} else if (roll < 10) a = { type: 'TIMEOUT' };
else {
const legal = lv.legalActions!(s, p);
a = legal[chooser.int(legal.length)]!;
}
const v = lv.validate(s, actor, a);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason}`);
s = lv.apply(s, actor, a, { rng: rngG, now: (now += 1000) }).state;
}
throw new Error(`seed ${i} did not finish`);
};
for (let i = 1; i <= 1000; i++) {
const a = play(i, true);
finished++;
if (i % 50 === 0) expect(play(i, false)).toBe(a);
}
expect(finished).toBe(1000);
}, 60_000);
test('engine randomPlayout: 100 seeds with round-trip, leak checker and determinism', () => {
for (let i = 1; i <= 100; i++) {
const players = ['A', 'B', 'C', 'D', 'E'].slice(0, 2 + (i % 4));
const opts = lasVegasOptionsSchema.parse(OPTS[i % OPTS.length]!.neutralDice === 'on' && players.length === 5 ? {} : OPTS[i % OPTS.length]!);
const r = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
expect(r.finished).toBe(true);
const st = r.finalState as LasVegasState;
expect(st.roundResults.length).toBe(opts.rounds);
expect(allBills(st)).toEqual(SORTED_NOTES);
if (i % 20 === 0) {
const again = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
expect(JSON.stringify(again.finalState)).toBe(JSON.stringify(st));
}
}
}, 60_000);
});

View File

@@ -0,0 +1,199 @@
/**
* 라스베가스 규칙 계산 (docs/games/las-vegas.md §3–§5, §8). Pure helpers shared by the definition,
* the view and the tests. State-mutating helpers work on a state `apply` has already cloned.
*/
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
// ---------------------------------------------------------------- options & actions
const lit = <T extends number | null>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const lasVegasOptionsSchema = z.object({
rounds: lit(3, 4, 5)
.default(4)
.meta({ title: '라운드 수', labels: { 3: '3라운드(짧게)', 4: '4라운드(기본)', 5: '5라운드' } }),
neutralDice: z
.enum(['auto', 'on', 'off'])
.default('auto')
.meta({
title: '중립(흰) 주사위',
labels: { auto: '자동(2명일 때만, 1인당 4개)', on: '켜기(2명 4개, 3~4명 2개씩)', off: '끄기' },
}),
hideMoney: z.boolean().default(true).meta({ title: '다른 사람이 받은 지폐 금액 숨기기(장 수만 공개)' }),
turnSeconds: lit(20, 30, 60, null)
.default(30)
.meta({ title: '굴리기·놓기 제한 시간', labels: { 20: '20초', 30: '30초', 60: '60초', null: '무제한' } }),
});
export type LasVegasOptions = z.infer<typeof lasVegasOptionsSchema>;
export const lasVegasActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('ROLL') }),
z.object({ type: z.literal('PLACE'), value: z.number().int().min(1).max(6) }),
z.object({ type: z.literal('CONCEDE') }),
]);
export type LasVegasClientAction = z.infer<typeof lasVegasActionSchema>;
/** Client actions plus the system-only timer action (not in the schema, docs/09 §2). */
export type LasVegasAction = LasVegasClientAction | { type: 'TIMEOUT' };
// ---------------------------------------------------------------- components
export type Note = number;
export const HOUSE = 'house' as const;
export type Owner = PlayerId | typeof HOUSE;
export type CasinoValue = 1 | 2 | 3 | 4 | 5 | 6;
/** Bill denominations → count (§3): 54 bills, 250 in total. */
export const NOTE_COUNTS: Record<Note, number> = { 1: 6, 2: 8, 3: 8, 4: 6, 5: 6, 6: 5, 7: 5, 8: 5, 9: 5 };
export const ALL_NOTES: Note[] = Object.entries(NOTE_COUNTS).flatMap(([v, n]) => Array<Note>(n).fill(Number(v)));
export const OWN_DICE = 8;
export const MIN_CASINO_SUM = 5;
export const CASINO_VALUES: CasinoValue[] = [1, 2, 3, 4, 5, 6];
/** Consecutive automatic actions before a player is shown as "자리 비움" (§8.5). */
export const AWAY_AFTER = 3;
/** Deadline for away players (keeps the game moving). */
export const AWAY_MS = 5000;
export interface Casino {
value: CasinoValue;
/** Descending. */
notes: Note[];
dice: Partial<Record<Owner, number>>;
}
export interface Payout {
casino: number;
removedTies: Owner[];
awards: { owner: Owner; note: Note }[];
returned: Note[];
}
export interface RoundResult {
round: number;
payouts: Payout[];
}
export interface LasVegasState {
options: LasVegasOptions;
seats: PlayerId[];
round: number;
/** This round's first player (seat index). */
startSeat: number;
/** Seat index of the player to act. */
current: number;
phase: 'roll' | 'place' | 'finished';
/** Hidden draw pile, index 0 = top. */
pile: Note[];
casinos: Casino[];
/** Dice still in hand. */
hand: Record<PlayerId, { own: number; neutral: number }>;
lastRoll: { own: number[]; neutral: number[] } | null;
money: Record<PlayerId, Note[]>;
roundResults: RoundResult[];
/** Neutral dice each player gets per round (0 = variant off). */
neutralPer: number;
/** Neutral dice the round's first player rolls straight onto the casinos (3 players + variant). */
houseExtra: number;
/** Last round-start house roll (3 players + variant). */
houseRoll: { round: number; player: PlayerId; values: number[] } | null;
lastPlace: { player: PlayerId; value: number; own: number; neutral: number; auto: boolean } | null;
conceded: PlayerId[];
timeouts: Record<PlayerId, number>;
rollSeq: number;
turnSeq: number;
deadlineAt: number | null;
}
// ---------------------------------------------------------------- setup helpers
/** Neutral dice per player for a player count; null = not allowed (5 players + `on`). */
export function neutralPerFor(option: LasVegasOptions['neutralDice'], players: number): number | null {
if (option === 'off') return 0;
if (option === 'auto') return players === 2 ? 4 : 0;
if (players === 2) return 4;
if (players <= 4) return 2;
return null;
}
/** Leftover neutral dice the first player rolls each round (only 3 players: 8 − 3×2 = 2). */
export const houseExtraFor = (neutralPer: number, players: number): number => (neutralPer > 0 && players === 3 ? 2 : 0);
/** Deals bills from the top of the pile until each casino holds ≥ 5 (§4.2). Casinos are emptied first. */
export function refillCasinos(s: Pick<LasVegasState, 'pile' | 'casinos'>): void {
for (const c of s.casinos) {
c.notes = [];
c.dice = {};
let sum = 0;
while (sum < MIN_CASINO_SUM && s.pile.length > 0) {
const n = s.pile.shift()!;
c.notes.push(n);
sum += n;
}
c.notes.sort((a, b) => b - a);
}
}
// ---------------------------------------------------------------- settlement
/**
* One casino's payout (§5 정산): owners with equal counts are all removed (the house included), the rest
* take bills in descending order. Bills the house wins and bills nobody wins are returned.
* `order` fixes the listing order of tied owners (seat order, house last).
*/
export function settleCasino(notes: Note[], dice: Partial<Record<Owner, number>>, order: Owner[]): Payout & { ranked: Owner[] } {
const owners = order.filter((o) => (dice[o] ?? 0) > 0);
const byCount = new Map<number, Owner[]>();
for (const o of owners) byCount.set(dice[o]!, [...(byCount.get(dice[o]!) ?? []), o]);
const removedTies = owners.filter((o) => byCount.get(dice[o]!)!.length > 1);
const ranked = owners.filter((o) => !removedTies.includes(o)).sort((a, b) => dice[b]! - dice[a]!);
const sorted = [...notes].sort((a, b) => b - a);
const awards: Payout['awards'] = [];
ranked.forEach((o, i) => {
if (i < sorted.length) awards.push({ owner: o, note: sorted[i]! });
});
const returned = [...awards.filter((a) => a.owner === HOUSE).map((a) => a.note), ...sorted.slice(ranked.length)].sort((a, b) => b - a);
return { casino: 0, removedTies, awards, returned, ranked };
}
export const ownerOrder = (s: Pick<LasVegasState, 'seats'>): Owner[] => [...s.seats, HOUSE];
/** What placing `own` + `neutral` dice on casino `value` would mean for `player` right now (§9 미리 보기). */
export function placementPreview(
s: Pick<LasVegasState, 'seats' | 'casinos'>,
player: PlayerId,
value: number,
own: number,
neutral: number,
): { standing: 'first' | 'award' | 'tie' | 'none'; note: Note | null; count: number } {
const c = s.casinos[value - 1]!;
const dice = { ...c.dice };
if (own > 0) dice[player] = (dice[player] ?? 0) + own;
if (neutral > 0) dice[HOUSE] = (dice[HOUSE] ?? 0) + neutral;
const count = dice[player] ?? 0;
if (count === 0) return { standing: 'none', note: null, count };
const out = settleCasino(c.notes, dice, ownerOrder(s));
if (out.removedTies.includes(player)) return { standing: 'tie', note: null, count };
const idx = out.ranked.indexOf(player);
const note = out.awards.find((a) => a.owner === player)?.note ?? null;
return { standing: note === null ? 'none' : idx === 0 ? 'first' : 'award', note, count };
}
/** Deterministic timeout choice (§8.5): most dice, then the casino's best bill, then the smaller number. */
export function autoPlaceValue(s: Pick<LasVegasState, 'casinos'>, roll: { own: number[]; neutral: number[] }): number {
let best = 0;
let bestKey: [number, number] = [-1, -1];
for (const v of CASINO_VALUES) {
const n = roll.own.filter((d) => d === v).length + roll.neutral.filter((d) => d === v).length;
if (n === 0) continue;
const key: [number, number] = [n, s.casinos[v - 1]!.notes[0] ?? 0];
if (key[0] > bestKey[0] || (key[0] === bestKey[0] && key[1] > bestKey[1])) {
best = v;
bestKey = key;
}
}
return best;
}
export const sum = (notes: Note[]): number => notes.reduce((a, b) => a + b, 0);
export const diceLeft = (s: Pick<LasVegasState, 'hand'>, p: PlayerId): number => (s.hand[p] ? s.hand[p]!.own + s.hand[p]!.neutral : 0);

View File

@@ -28,6 +28,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
{ id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);