Files
joke-app/packages/games/src/las-vegas/index.ts
EJClaw fa7ceea668 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>
2026-10-05 14:53:47 +09:00

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))),
);
},
};