/** * 라스베가스 (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; 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> }[]; hand: Record; 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; 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 = {}; 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 = { id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, stateVersion: 1, defaultOptions: lasVegasOptionsSchema.parse({}), optionsSchema: lasVegasOptionsSchema as unknown as ZodType, actionSchema: lasVegasActionSchema as unknown as ZodType, 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(); for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]); const scores: Record = {}; 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))), ); }, };