- 규칙 모듈 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>
200 lines
8.3 KiB
TypeScript
200 lines
8.3 KiB
TypeScript
/**
|
||
* 라스베가스 규칙 계산 (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);
|