Files
joke-app/packages/games/src/las-vegas/rules.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

200 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 라스베가스 규칙 계산 (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);