/** * 라스베가스 규칙 계산 (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 = (...vals: T[]) => z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); 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; 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; /** 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 = { 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(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>; } 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; lastRoll: { own: number[]; neutral: number[] } | null; money: Record; 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; 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): 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>, order: Owner[]): Payout & { ranked: Owner[] } { const owners = order.filter((o) => (dice[o] ?? 0) > 0); const byCount = new Map(); 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): Owner[] => [...s.seats, HOUSE]; /** What placing `own` + `neutral` dice on casino `value` would mean for `player` right now (§9 미리 보기). */ export function placementPreview( s: Pick, 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, 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, p: PlayerId): number => (s.hand[p] ? s.hand[p]!.own + s.hand[p]!.neutral : 0);