Merge branch 'm7/las-vegas' into codex/owner/grp_discord_joke-app
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user