- 규칙 모듈 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>
493 lines
18 KiB
TypeScript
493 lines
18 KiB
TypeScript
/**
|
|
* 라스베가스 (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))),
|
|
);
|
|
},
|
|
};
|