M7: 구십구 규칙·화면·테스트
- 규칙 모듈 packages/games/src/ninety-nine: 전용 카드(1~8·10, ±9·±10·0, 조커)와 트럼프(전통 99) 두 규칙, 모든 옵션 한국어 메타. 낼 카드가 없으면 같은 apply 안에서 탈락/목숨 감소/새 판을 연쇄 처리, 시간 초과는 결과 합계가 가장 작은 카드를 자동으로 냄, 연속 3번 시간 초과면 마감 3초, 나가면 탈락 처리. view는 남의 손패·더미를 숨기고 카드별 결과 합계 미리보기와 선택지(+/−, A 1/11, 조커 범위)를 계산해 줌. - 테스트: 문서 10절 시나리오 전부, 무작위 1,000판(보존·유출·onTimeout·결정성)과 엔진 randomPlayout 18판. - 화면 apps/web/src/games/ninety-nine: 큰 합계와 게이지 링, 방향 표시, 목숨 하트, 카드 위 결과 미리보기, ± 두 버튼, 조커 숫자 다이얼, 키보드·스크린리더 지원. - 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 1 + 휴대폰 2, 끝까지 진행). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { gostop } from './gostop';
|
||||
import { bang } from './bang';
|
||||
import { halliGalli } from './halli-galli';
|
||||
import { marble } from './marble';
|
||||
import { ninetyNine } from './ninety-nine';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -35,4 +36,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[bang.id]: bang,
|
||||
[halliGalli.id]: halliGalli,
|
||||
[marble.id]: marble,
|
||||
[ninetyNine.id]: ninetyNine,
|
||||
};
|
||||
|
||||
139
packages/games/src/ninety-nine/cards.ts
Normal file
139
packages/games/src/ninety-nine/cards.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/** 구십구 cards and what each one does to the total (docs/games/ninety-nine.md §3, §5.2). */
|
||||
import type { CardFace, ChoiceView, NNCard, NNOptions, PlayAction, Suit } from './types';
|
||||
|
||||
export const SUITS: Suit[] = ['S', 'H', 'D', 'C'];
|
||||
|
||||
/** Dedicated deck: 1–8 ×7, 10 ×21, ±9 / ±10 / 0 × `specialCounts`, joker ×2 (91 cards by default). */
|
||||
export function krDeck(specialCounts: number): NNCard[] {
|
||||
const out: NNCard[] = [];
|
||||
let n = 0;
|
||||
const id = () => `k${n++}`;
|
||||
for (let v = 1; v <= 8; v++) for (let i = 0; i < 7; i++) out.push({ id: id(), kind: 'add', v, face: { label: String(v) } });
|
||||
for (let i = 0; i < 21; i++) out.push({ id: id(), kind: 'add', v: 10, face: { label: '10' } });
|
||||
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'plusMinus', v: 9, face: { label: '±9' } });
|
||||
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'plusMinus', v: 10, face: { label: '±10' } });
|
||||
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'reverse', face: { label: '0' } });
|
||||
for (let i = 0; i < 2; i++) out.push({ id: id(), kind: 'joker', face: { label: '조커' } });
|
||||
return out;
|
||||
}
|
||||
|
||||
const RANK_LABEL: Record<number, string> = { 1: 'A', 11: 'J', 12: 'Q', 13: 'K' };
|
||||
|
||||
/** Classic card from rank 1..13 (A=1, J=11, Q=12, K=13), Bicycle effects. */
|
||||
function classicCard(id: string, suit: Suit, rank: number): NNCard {
|
||||
const face: CardFace = { label: RANK_LABEL[rank] ?? String(rank), suit };
|
||||
if (rank === 1) return { id, kind: 'ace', face };
|
||||
if (rank === 4) return { id, kind: 'reverse', face };
|
||||
if (rank === 9) return { id, kind: 'hold', face };
|
||||
if (rank === 10) return { id, kind: 'minus', v: 10, face };
|
||||
if (rank === 13) return { id, kind: 'to99', face };
|
||||
return { id, kind: 'add', v: rank >= 11 ? 10 : rank, face };
|
||||
}
|
||||
|
||||
/** 52 trump cards per deck (no jokers); the second deck adds a "b" suffix. */
|
||||
export function classicDeck(decks: 1 | 2): NNCard[] {
|
||||
const out: NNCard[] = [];
|
||||
for (let d = 0; d < decks; d++) {
|
||||
for (const suit of SUITS) for (let rank = 1; rank <= 13; rank++) out.push(classicCard(`t${suit}${rank}${d ? 'b' : ''}`, suit, rank));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const jokerRange = (o: Pick<NNOptions, 'jokerRange'>): { min: number; max: number } => (o.jokerRange === '0-99' ? { min: 0, max: 99 } : { min: 60, max: 99 });
|
||||
|
||||
export const OVER = '99를 넘어서 낼 수 없어요';
|
||||
export const UNDER = '0보다 작아져서 뺄 수 없어요';
|
||||
|
||||
function choice(label: string, total: number, o: NNOptions, extra: Partial<ChoiceView> = {}, minus = false): ChoiceView {
|
||||
let t = total;
|
||||
if (minus && t < 0 && o.floorAtZero === 'clamp') t = 0;
|
||||
if (t > 99) return { label, total: t, legal: false, reason: OVER, ...extra };
|
||||
if (t < 0) return { label, total: t, legal: false, reason: UNDER, ...extra };
|
||||
return { label, total: t, legal: true, ...extra };
|
||||
}
|
||||
|
||||
/** Every way to play `c` on total `s` (empty for the joker, which picks a number). */
|
||||
export function choicesFor(c: NNCard, s: number, o: NNOptions): ChoiceView[] {
|
||||
switch (c.kind) {
|
||||
case 'add':
|
||||
return [choice(`+${c.v}`, s + c.v, o)];
|
||||
case 'plusMinus':
|
||||
return [choice(`+${c.v}`, s + c.v, o, { sign: '+' }), choice(`−${c.v}`, s - c.v, o, { sign: '-' }, true)];
|
||||
case 'minus':
|
||||
return [choice(`−${c.v}`, s - c.v, o, {}, true)];
|
||||
case 'reverse':
|
||||
return [choice('방향 바꾸기', s, o)];
|
||||
case 'hold':
|
||||
return [choice('그대로', s, o)];
|
||||
case 'to99':
|
||||
return [choice('99로', 99, o)];
|
||||
case 'ace':
|
||||
return [choice('+1', s + 1, o, { aceValue: 1 }), choice(`+${o.aceHigh}`, s + o.aceHigh, o, { aceValue: o.aceHigh })];
|
||||
case 'joker':
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Some way to play `c` keeps the total within 0..99. */
|
||||
export function playable(c: NNCard, s: number, o: NNOptions): boolean {
|
||||
return c.kind === 'joker' || choicesFor(c, s, o).some((x) => x.legal);
|
||||
}
|
||||
|
||||
/** The total a play makes, or the Korean reason it is not allowed (§8.2). */
|
||||
export function resolvePlay(c: NNCard, s: number, o: NNOptions, a: PlayAction): { ok: true; total: number; label: string } | { ok: false; reason: string } {
|
||||
const extra = (k: 'sign' | 'aceValue' | 'jokerValue') => a[k] !== undefined;
|
||||
if (c.kind === 'joker') {
|
||||
if (extra('sign') || extra('aceValue')) return { ok: false, reason: '조커는 숫자만 골라 주세요' };
|
||||
const r = jokerRange(o);
|
||||
if (a.jokerValue === undefined) return { ok: false, reason: `조커로 만들 수(${r.min}~${r.max})를 골라 주세요` };
|
||||
if (!Number.isInteger(a.jokerValue) || a.jokerValue < r.min || a.jokerValue > r.max) return { ok: false, reason: `조커는 ${r.min}부터 ${r.max} 사이 숫자만 고를 수 있어요` };
|
||||
return { ok: true, total: a.jokerValue, label: String(a.jokerValue) };
|
||||
}
|
||||
if (extra('jokerValue')) return { ok: false, reason: '숫자는 조커를 낼 때만 고를 수 있어요' };
|
||||
const options = choicesFor(c, s, o);
|
||||
let pick: ChoiceView | undefined;
|
||||
if (c.kind === 'plusMinus') {
|
||||
if (extra('aceValue')) return { ok: false, reason: '더하기와 빼기 중 하나를 골라 주세요' };
|
||||
if (!a.sign) return { ok: false, reason: '더하기와 빼기 중 하나를 골라 주세요' };
|
||||
pick = options.find((x) => x.sign === a.sign);
|
||||
} else if (c.kind === 'ace') {
|
||||
if (extra('sign')) return { ok: false, reason: `A를 1로 낼지 ${o.aceHigh}로 낼지 골라 주세요` };
|
||||
if (a.aceValue === undefined) return { ok: false, reason: `A를 1로 낼지 ${o.aceHigh}로 낼지 골라 주세요` };
|
||||
pick = options.find((x) => x.aceValue === a.aceValue);
|
||||
if (!pick) return { ok: false, reason: `A는 1이나 ${o.aceHigh}로만 낼 수 있어요` };
|
||||
} else {
|
||||
if (extra('sign') || extra('aceValue')) return { ok: false, reason: '이 카드는 고를 것 없이 바로 내요' };
|
||||
pick = options[0];
|
||||
}
|
||||
if (!pick) return { ok: false, reason: '낼 수 없는 카드예요' };
|
||||
if (!pick.legal) return { ok: false, reason: pick.reason ?? OVER };
|
||||
return { ok: true, total: pick.total, label: pick.label };
|
||||
}
|
||||
|
||||
/** §8.5 tie-break group: no change → plain numbers → ± cards → K / joker. */
|
||||
const GROUP: Record<NNCard['kind'], number> = { reverse: 0, hold: 0, add: 1, minus: 1, ace: 1, plusMinus: 2, to99: 3, joker: 3 };
|
||||
|
||||
/**
|
||||
* Timer move (§8.5): the legal (card, choice) with the smallest resulting total; ties by the group order above,
|
||||
* then card id. Uses only the player's own hand and the public total.
|
||||
*/
|
||||
export function safestPlay(hand: NNCard[], s: number, o: NNOptions): PlayAction | null {
|
||||
let best: { a: PlayAction; total: number; g: number; id: string } | null = null;
|
||||
for (const c of hand) {
|
||||
const cands: { a: PlayAction; total: number }[] = [];
|
||||
if (c.kind === 'joker') {
|
||||
const r = jokerRange(o);
|
||||
cands.push({ a: { type: 'play', cardId: c.id, jokerValue: r.min }, total: r.min });
|
||||
} else {
|
||||
for (const x of choicesFor(c, s, o)) {
|
||||
if (!x.legal) continue;
|
||||
cands.push({ a: { type: 'play', cardId: c.id, ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) }, total: x.total });
|
||||
}
|
||||
}
|
||||
for (const k of cands) {
|
||||
const g = GROUP[c.kind];
|
||||
if (!best || k.total < best.total || (k.total === best.total && (g < best.g || (g === best.g && c.id < best.id)))) best = { a: k.a, total: k.total, g, id: c.id };
|
||||
}
|
||||
}
|
||||
return best?.a ?? null;
|
||||
}
|
||||
459
packages/games/src/ninety-nine/index.ts
Normal file
459
packages/games/src/ninety-nine/index.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/** 구십구 (docs/games/ninety-nine.md). Pure and deterministic; state is JSON. */
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
|
||||
import { choicesFor, classicDeck, jokerRange, krDeck, playable, resolvePlay, safestPlay } from './cards';
|
||||
import {
|
||||
ninetyNineActionSchema,
|
||||
ninetyNineOptionsSchema,
|
||||
type HandCardView,
|
||||
type LogEntry,
|
||||
type NNAction,
|
||||
type NNCard,
|
||||
type NNOptions,
|
||||
type NinetyNineState,
|
||||
type NinetyNineView,
|
||||
type PlayAction,
|
||||
type PlayerResult,
|
||||
} from './types';
|
||||
|
||||
export * from './cards';
|
||||
export * from './types';
|
||||
|
||||
/** Deadline once a player has timed out this many turns in a row (§8.5). */
|
||||
const AWAY_AFTER = 3;
|
||||
const AWAY_MS = 3000;
|
||||
const LOG_MAX = 40;
|
||||
const LOG_VIEW = 20;
|
||||
const RECENT_VIEW = 6;
|
||||
|
||||
interface Ctx {
|
||||
rng: Rng;
|
||||
now: number;
|
||||
events: GameEvent[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/** Copy for `apply`: containers are copied, card objects / log entries / options are shared (never mutated). */
|
||||
function cloneState(s: NinetyNineState): NinetyNineState {
|
||||
const rec = <T>(r: Record<PlayerId, T>): Record<PlayerId, T> => ({ ...r });
|
||||
return {
|
||||
...s,
|
||||
seats: [...s.seats],
|
||||
hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])),
|
||||
drawPile: [...s.drawPile],
|
||||
discard: [...s.discard],
|
||||
alive: rec(s.alive),
|
||||
lives: rec(s.lives),
|
||||
livesLost: rec(s.livesLost),
|
||||
onBoard: rec(s.onBoard),
|
||||
eliminationOrder: [...s.eliminationOrder],
|
||||
eliminatedAtRound: rec(s.eliminatedAtRound),
|
||||
leftOrder: [...s.leftOrder],
|
||||
timeoutsInRow: rec(s.timeoutsInRow),
|
||||
away: rec(s.away),
|
||||
log: [...s.log],
|
||||
};
|
||||
}
|
||||
|
||||
export const resolvedLives = (o: Pick<NNOptions, 'mode' | 'lives'>): number => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
|
||||
export const resolvedDecks = (o: Pick<NNOptions, 'mode' | 'deckCount'>, players: number): 1 | 2 =>
|
||||
o.mode !== 'classic' ? 1 : o.deckCount === 'auto' ? (players >= 7 ? 2 : 1) : o.deckCount;
|
||||
export const fullDeck = (s: Pick<NinetyNineState, 'mode' | 'options' | 'decks'>): NNCard[] =>
|
||||
s.mode === 'classic' ? classicDeck(s.decks) : krDeck(s.options.specialCounts);
|
||||
/** Every card is in exactly one place (test invariant). */
|
||||
export const cardTotal = (s: NinetyNineState): number =>
|
||||
s.drawPile.length + s.discard.length + s.seats.reduce((a, p) => a + (s.hands[p]?.length ?? 0), 0);
|
||||
|
||||
const aliveSeats = (s: NinetyNineState): PlayerId[] => s.seats.filter((p) => s.alive[p]);
|
||||
|
||||
function log(s: NinetyNineState, e: Record<string, unknown> & { t: LogEntry['t'] }): void {
|
||||
s.log.push({ seq: s.seq, ...e } as LogEntry);
|
||||
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
|
||||
}
|
||||
|
||||
/** Next player still in the game from `from` (who may be out) in direction `dir`. */
|
||||
function nextAlive(s: NinetyNineState, from: PlayerId, dir: 1 | -1): PlayerId {
|
||||
const n = s.seats.length;
|
||||
let idx = s.seats.indexOf(from);
|
||||
for (let k = 0; k < n; k++) {
|
||||
idx = (idx + dir + n) % n;
|
||||
if (s.alive[s.seats[idx]!]) return s.seats[idx]!;
|
||||
}
|
||||
return from;
|
||||
}
|
||||
|
||||
const hasPlay = (s: NinetyNineState, p: PlayerId): boolean => (s.hands[p] ?? []).some((c) => playable(c, s.total, s.options));
|
||||
|
||||
/** Collects every card, shuffles, deals `handSize` to everyone still in from `starter` clockwise; total 0 (§5.3). */
|
||||
function dealRound(s: NinetyNineState, starter: PlayerId, ctx: Ctx): void {
|
||||
const pool = [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p] ?? [])];
|
||||
s.drawPile = ctx.rng.shuffle(pool);
|
||||
s.discard = [];
|
||||
s.hands = Object.fromEntries(s.seats.map((p) => [p, [] as NNCard[]]));
|
||||
s.total = 0;
|
||||
s.direction = 1;
|
||||
const order: PlayerId[] = [];
|
||||
for (let k = 0, p = starter; k < aliveSeats(s).length; k++, p = nextAlive(s, p, 1)) order.push(p);
|
||||
for (let r = 0; r < s.options.handSize; r++) for (const p of order) {
|
||||
const c = s.drawPile.shift();
|
||||
if (c) s.hands[p]!.push(c);
|
||||
}
|
||||
s.round += 1;
|
||||
ctx.events.push({ type: 'roundStarted', round: s.round, starter });
|
||||
log(s, { t: 'round', round: s.round, starter });
|
||||
}
|
||||
|
||||
function eliminate(s: NinetyNineState, p: PlayerId, why: 'noCard' | 'left', ctx: Ctx): void {
|
||||
s.alive[p] = false;
|
||||
s.eliminationOrder.push(p);
|
||||
s.eliminatedAtRound[p] = s.round;
|
||||
// The hand is not revealed (§8.3): only the count moves to the discard pile.
|
||||
s.discard.push(...s.hands[p]!);
|
||||
s.hands[p] = [];
|
||||
ctx.events.push({ type: 'eliminated', player: p, why });
|
||||
log(s, { t: 'out', p, why });
|
||||
}
|
||||
|
||||
function finish(s: NinetyNineState, ctx: Ctx): void {
|
||||
const winners = aliveSeats(s);
|
||||
const order = [...winners, ...[...s.eliminationOrder].reverse()];
|
||||
const ranking: PlayerResult[] = order.map((p, i) => ({
|
||||
player: p,
|
||||
rank: i + 1,
|
||||
livesLost: s.livesLost[p] ?? 0,
|
||||
eliminatedAtRound: s.eliminatedAtRound[p] ?? null,
|
||||
left: s.leftOrder.includes(p),
|
||||
}));
|
||||
const allLeft = s.eliminationOrder.length > 0 && s.eliminationOrder.every((p) => s.leftOrder.includes(p));
|
||||
const summary = allLeft
|
||||
? '다른 사람이 모두 나가서 남은 사람이 이겼어요'
|
||||
: `끝까지 살아남은 사람이 이겼어요! 마지막 합계 ${s.total}${s.livesStart > 0 ? ` · ${s.round}판` : ''}`;
|
||||
s.final = { ranking, winner: winners, lastTotal: s.total, summary };
|
||||
s.phase = 'finished';
|
||||
ctx.events.push({ type: 'gameEnd', winner: winners });
|
||||
}
|
||||
|
||||
/**
|
||||
* Player `p` could not play (§5.3). Returns who must be checked next, or null when the game is over.
|
||||
*/
|
||||
function lose(s: NinetyNineState, p: PlayerId, ctx: Ctx): PlayerId | null {
|
||||
ctx.events.push({ type: 'noCard', player: p, total: s.total });
|
||||
log(s, { t: 'noCard', p });
|
||||
if (s.livesStart > 0) {
|
||||
let out = false;
|
||||
if (s.lives[p]! > 0) {
|
||||
s.lives[p]! -= 1;
|
||||
s.livesLost[p]! += 1;
|
||||
ctx.events.push({ type: 'lifeLost', player: p, left: s.lives[p] });
|
||||
log(s, { t: 'life', p, left: s.lives[p]! });
|
||||
if (s.lives[p] === 0) {
|
||||
if (s.options.onTheBoard) s.onBoard[p] = true;
|
||||
else out = true;
|
||||
}
|
||||
} else out = true; // on the board with no lives left: one more loss is out
|
||||
const next = nextAlive(s, p, s.direction);
|
||||
if (out) eliminate(s, p, 'noCard', ctx);
|
||||
if (aliveSeats(s).length <= 1) {
|
||||
finish(s, ctx);
|
||||
return null;
|
||||
}
|
||||
const starter = out ? next : p;
|
||||
dealRound(s, starter, ctx);
|
||||
return starter;
|
||||
}
|
||||
const next = nextAlive(s, p, s.direction);
|
||||
eliminate(s, p, 'noCard', ctx);
|
||||
if (aliveSeats(s).length <= 1) {
|
||||
finish(s, ctx);
|
||||
return null;
|
||||
}
|
||||
if (s.options.afterElimination === 'reset') dealRound(s, next, ctx);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Hands the turn to `p`; anyone with nothing playable loses at once, possibly several in a row (§8.2). */
|
||||
function startTurn(s: NinetyNineState, p: PlayerId | null, ctx: Ctx): void {
|
||||
let who = p;
|
||||
// Each loss costs a life or a seat, so this always ends.
|
||||
while (who !== null && s.phase === 'playing') {
|
||||
if (aliveSeats(s).length <= 1) {
|
||||
finish(s, ctx);
|
||||
return;
|
||||
}
|
||||
if (hasPlay(s, who)) {
|
||||
s.current = who;
|
||||
s.turnNo += 1;
|
||||
return;
|
||||
}
|
||||
who = lose(s, who, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
function reshuffle(s: NinetyNineState, ctx: Ctx): void {
|
||||
if (s.discard.length === 0) return;
|
||||
const n = s.discard.length;
|
||||
s.drawPile = [...s.drawPile, ...ctx.rng.shuffle(s.discard)];
|
||||
s.discard = [];
|
||||
ctx.events.push({ type: 'reshuffled', count: n });
|
||||
log(s, { t: 'reshuffle', n });
|
||||
}
|
||||
|
||||
function playCard(s: NinetyNineState, p: PlayerId, a: PlayAction, ctx: Ctx, auto: boolean): void {
|
||||
const hand = s.hands[p]!;
|
||||
const card = hand.find((c) => c.id === a.cardId)!;
|
||||
const r = resolvePlay(card, s.total, s.options, a);
|
||||
if (!r.ok) throw new Error(`illegal play reached apply: ${r.reason}`);
|
||||
const from = s.total;
|
||||
s.hands[p] = hand.filter((c) => c.id !== card.id);
|
||||
s.discard.push(card);
|
||||
s.total = r.total;
|
||||
const choice = card.kind === 'plusMinus' || card.kind === 'ace' || card.kind === 'joker' ? r.label : undefined;
|
||||
ctx.events.push({ type: 'cardPlayed', player: p, face: card.face, kind: card.kind, total: s.total, ...(choice ? { choice } : {}) });
|
||||
log(s, { t: 'play', p, face: card.face, kind: card.kind, from, total: s.total, ...(choice ? { choice } : {}), ...(auto ? { auto: true } : {}) });
|
||||
if (card.kind === 'reverse') {
|
||||
s.direction = s.direction === 1 ? -1 : 1;
|
||||
ctx.events.push({ type: 'directionChanged', direction: s.direction });
|
||||
}
|
||||
if (s.options.refill) {
|
||||
if (s.drawPile.length === 0) reshuffle(s, ctx);
|
||||
const c = s.drawPile.shift();
|
||||
if (c) {
|
||||
s.hands[p]!.push(c);
|
||||
ctx.events.push({ type: 'cardDrawn', player: p });
|
||||
}
|
||||
}
|
||||
startTurn(s, nextAlive(s, p, s.direction), ctx);
|
||||
}
|
||||
|
||||
function leave(s: NinetyNineState, p: PlayerId, ctx: Ctx): void {
|
||||
s.leftOrder.push(p);
|
||||
s.timeoutsInRow[p] = 0;
|
||||
const wasCurrent = s.current === p;
|
||||
const next = nextAlive(s, p, s.direction);
|
||||
eliminate(s, p, 'left', ctx);
|
||||
if (aliveSeats(s).length <= 1) {
|
||||
finish(s, ctx);
|
||||
return;
|
||||
}
|
||||
// Leaving is not losing a round: the total stays and the next player goes on.
|
||||
if (wasCurrent) startTurn(s, next, ctx);
|
||||
}
|
||||
|
||||
function validatePlay(s: NinetyNineState, actor: PlayerId, a: PlayAction): Validation {
|
||||
if (s.current !== actor) return fail('지금은 내 차례가 아니에요');
|
||||
const card = s.hands[actor]!.find((c) => c.id === a.cardId);
|
||||
if (!card) return fail('손에 없는 카드예요');
|
||||
const r = resolvePlay(card, s.total, s.options, a);
|
||||
return r.ok ? ok : fail(r.reason);
|
||||
}
|
||||
|
||||
function deadlineMs(s: NinetyNineState): number | null {
|
||||
if (s.phase !== 'playing') return null;
|
||||
return s.away[s.current] ? AWAY_MS : s.options.turnSeconds * 1000;
|
||||
}
|
||||
|
||||
function handView(s: NinetyNineState, p: PlayerId, myTurn: boolean): HandCardView[] {
|
||||
const o = s.options;
|
||||
return (s.hands[p] ?? []).map((c) => {
|
||||
const choices = choicesFor(c, s.total, o);
|
||||
const legal = playable(c, s.total, o);
|
||||
let badge: string | null = null;
|
||||
if (!legal) badge = choices.some((x) => x.total > 99) ? '99 초과' : '0 미만';
|
||||
return { id: c.id, kind: c.kind, face: c.face, choices, joker: c.kind === 'joker' ? jokerRange(o) : null, legal: legal && myTurn, badge };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- definition
|
||||
|
||||
type Def = GameDefinition<NinetyNineState, NNAction, NinetyNineView, NNOptions>;
|
||||
|
||||
export const ninetyNine: Def = {
|
||||
id: 'ninety-nine',
|
||||
nameKo: '구십구',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 10,
|
||||
stateVersion: 1,
|
||||
defaultOptions: ninetyNineOptionsSchema.parse({}),
|
||||
optionsSchema: ninetyNineOptionsSchema as unknown as Def['optionsSchema'],
|
||||
actionSchema: ninetyNineActionSchema as unknown as Def['actionSchema'],
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
if (players.length < 2 || players.length > 10) throw new Error(`ninety-nine: ${players.length} players not allowed`);
|
||||
const livesStart = resolvedLives(options);
|
||||
const rec = <T>(v: T) => Object.fromEntries(players.map((p) => [p, v])) as Record<PlayerId, T>;
|
||||
const s: NinetyNineState = {
|
||||
mode: options.mode,
|
||||
options,
|
||||
livesStart,
|
||||
decks: resolvedDecks(options, players.length),
|
||||
seats: [...players],
|
||||
hands: {},
|
||||
drawPile: [],
|
||||
discard: [],
|
||||
total: 0,
|
||||
direction: 1,
|
||||
current: players[0]!,
|
||||
alive: rec(true),
|
||||
lives: rec(livesStart),
|
||||
livesLost: rec(0),
|
||||
onBoard: rec(false),
|
||||
eliminationOrder: [],
|
||||
eliminatedAtRound: rec<number | null>(null),
|
||||
leftOrder: [],
|
||||
round: 1,
|
||||
phase: 'playing',
|
||||
turnStartedAt: now,
|
||||
seq: 0,
|
||||
turnNo: 0,
|
||||
timeoutsInRow: rec(0),
|
||||
away: rec(false),
|
||||
log: [],
|
||||
final: null,
|
||||
};
|
||||
const ctx: Ctx = { rng, now, events: [] };
|
||||
// §4: shuffle the deck, then pick the first player with the same RNG.
|
||||
const deck = rng.shuffle(fullDeck(s));
|
||||
const starter = players[rng.int(players.length)]!;
|
||||
s.drawPile = deck;
|
||||
const order: PlayerId[] = [];
|
||||
for (let k = 0, p = starter; k < players.length; k++, p = nextAlive(s, p, 1)) order.push(p);
|
||||
s.hands = Object.fromEntries(players.map((p) => [p, [] as NNCard[]]));
|
||||
for (let r = 0; r < options.handSize; r++) for (const p of order) s.hands[p]!.push(s.drawPile.shift()!);
|
||||
// Someone may already have nothing playable (e.g. only trump 10s at 0).
|
||||
startTurn(s, starter, ctx);
|
||||
return s;
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요');
|
||||
if (s.phase === 'finished') return fail('게임이 이미 끝났어요');
|
||||
if (a.type === 'leave') return s.alive[actor] ? ok : fail('이미 게임에서 빠졌어요');
|
||||
if (!s.alive[actor]) return fail('탈락해서 카드를 낼 수 없어요');
|
||||
if (a.type === 'timeout') return s.current === actor ? ok : fail('시간 초과 대상이 아니에요');
|
||||
if (a.type === 'play') return validatePlay(s, actor, a);
|
||||
return fail('알 수 없는 행동이에요');
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { rng, now }) {
|
||||
const s = cloneState(prev);
|
||||
const ctx: Ctx = { rng, now, events: [] };
|
||||
const before = s.turnNo;
|
||||
s.seq += 1;
|
||||
switch (a.type) {
|
||||
case 'play':
|
||||
s.timeoutsInRow[actor] = 0;
|
||||
s.away[actor] = false;
|
||||
playCard(s, actor, a, ctx, false);
|
||||
break;
|
||||
case 'timeout': {
|
||||
ctx.events.push({ type: 'timeout', player: actor });
|
||||
log(s, { t: 'timeout', p: actor });
|
||||
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
|
||||
if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) {
|
||||
s.away[actor] = true;
|
||||
ctx.events.push({ type: 'away', player: actor });
|
||||
}
|
||||
const auto = safestPlay(s.hands[actor]!, s.total, s.options);
|
||||
if (!auto) throw new Error('current player has no legal play');
|
||||
playCard(s, actor, auto, ctx, true);
|
||||
break;
|
||||
}
|
||||
case 'leave':
|
||||
leave(s, actor, ctx);
|
||||
break;
|
||||
}
|
||||
if (s.turnNo !== before) s.turnStartedAt = now;
|
||||
return { state: s, events: ctx.events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const me = viewer && s.seats.includes(viewer) ? viewer : null;
|
||||
const live = s.phase === 'playing';
|
||||
const myTurn = live && me !== null && s.current === me;
|
||||
let from = s.log.length;
|
||||
while (from > 0 && s.log[from - 1]!.t !== 'round') from--;
|
||||
const plays = s.log.slice(from).filter((e): e is Extract<LogEntry, { t: 'play' }> => e.t === 'play');
|
||||
return {
|
||||
mode: s.mode,
|
||||
options: s.options,
|
||||
livesStart: s.livesStart,
|
||||
jokerRange: jokerRange(s.options),
|
||||
me,
|
||||
seats: s.seats.map((p) => ({
|
||||
id: p,
|
||||
cards: s.hands[p]?.length ?? 0,
|
||||
lives: s.lives[p] ?? 0,
|
||||
livesLost: s.livesLost[p] ?? 0,
|
||||
alive: !!s.alive[p],
|
||||
onBoard: !!s.onBoard[p],
|
||||
left: s.leftOrder.includes(p),
|
||||
away: !!s.away[p],
|
||||
isTurn: live && s.current === p,
|
||||
})),
|
||||
total: s.total,
|
||||
direction: s.direction,
|
||||
current: live ? s.current : null,
|
||||
phase: s.phase,
|
||||
turnNo: s.turnNo,
|
||||
round: s.round,
|
||||
drawPileCount: s.drawPile.length,
|
||||
discardCount: s.discard.length,
|
||||
recent: plays.slice(-RECENT_VIEW),
|
||||
log: s.log.slice(-LOG_VIEW),
|
||||
eliminationOrder: s.eliminationOrder,
|
||||
my: me ? { hand: s.alive[me] ? handView(s, me, myTurn) : [], myTurn } : null,
|
||||
turnStartedAt: s.turnStartedAt,
|
||||
deadlineTotalMs: deadlineMs(s) ?? 0,
|
||||
result: s.final,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers(s) {
|
||||
return s.phase === 'playing' ? [s.current] : [];
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
const ms = deadlineMs(s);
|
||||
return ms === null ? null : s.turnStartedAt + ms;
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
if (s.phase !== 'playing' || !s.seats.includes(player) || !s.alive[player]) return null;
|
||||
return { type: 'leave' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
if (!s.final) return null;
|
||||
const winners = s.final.winner;
|
||||
const ranking: PlayerId[][] = [winners, ...s.final.ranking.filter((r) => !winners.includes(r.player)).map((r) => [r.player])];
|
||||
return { ranking, summary: s.final.summary, reason: 'normal' };
|
||||
},
|
||||
|
||||
/** Test helper. The joker offers a few representative numbers instead of all 40. */
|
||||
legalActions(s, p) {
|
||||
if (s.phase !== 'playing' || s.current !== p) return [];
|
||||
const out: NNAction[] = [];
|
||||
for (const c of s.hands[p]!) {
|
||||
if (c.kind === 'joker') {
|
||||
const r = jokerRange(s.options);
|
||||
const vals = [...new Set([r.min, r.max, Math.round((r.min + r.max) / 2), Math.min(r.max, Math.max(r.min, s.total))])];
|
||||
for (const jokerValue of vals) out.push({ type: 'play', cardId: c.id, jokerValue });
|
||||
continue;
|
||||
}
|
||||
for (const x of choicesFor(c, s.total, s.options)) {
|
||||
if (!x.legal) continue;
|
||||
out.push({ type: 'play', cardId: c.id, ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Own hand plus the draw pile — card ids exactly as they would appear in a view. */
|
||||
secretsOf(s, player) {
|
||||
return [...(s.hands[player] ?? []).map((c) => c.id), ...s.drawPile.map((c) => c.id)];
|
||||
},
|
||||
};
|
||||
599
packages/games/src/ninety-nine/ninety-nine.test.ts
Normal file
599
packages/games/src/ninety-nine/ninety-nine.test.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
|
||||
import {
|
||||
cardTotal,
|
||||
classicDeck,
|
||||
fullDeck,
|
||||
krDeck,
|
||||
ninetyNine,
|
||||
safestPlay,
|
||||
type NNAction,
|
||||
type NNCard,
|
||||
type NNOptions,
|
||||
type NinetyNineState,
|
||||
type PlayAction,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
|
||||
let clock = 1_000_000;
|
||||
|
||||
function make(opts: Partial<NNOptions> = {}, players = ['A', 'B', 'C'], seed = 1): NinetyNineState {
|
||||
const options = ninetyNine.optionsSchema.parse(opts);
|
||||
return ninetyNine.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces hands (by face label, e.g. "7", "±9", "0", "조커", "K"), total and turn. Unused cards become the
|
||||
* draw pile in deck order (or the discard pile with `emptyDraw`).
|
||||
*/
|
||||
function rig(
|
||||
s: NinetyNineState,
|
||||
r: { hands: Record<string, string[]>; total: number; current?: string; direction?: 1 | -1; emptyDraw?: boolean },
|
||||
): NinetyNineState {
|
||||
const pool = fullDeck(s);
|
||||
const take = (label: string): NNCard => {
|
||||
const i = pool.findIndex((c) => c.face.label === label);
|
||||
if (i < 0) throw new Error(`no card ${label} left`);
|
||||
return pool.splice(i, 1)[0]!;
|
||||
};
|
||||
for (const p of s.seats) s.hands[p] = (r.hands[p] ?? []).map(take);
|
||||
s.total = r.total;
|
||||
s.current = r.current ?? s.seats[0]!;
|
||||
s.direction = r.direction ?? 1;
|
||||
s.drawPile = r.emptyDraw ? [] : pool;
|
||||
s.discard = r.emptyDraw ? pool : [];
|
||||
s.phase = 'playing';
|
||||
s.final = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
function actE(s: NinetyNineState, p: string, a: NNAction): { state: NinetyNineState; events: GameEvent[] } {
|
||||
const v = ninetyNine.validate(s, p, a);
|
||||
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
return ninetyNine.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
|
||||
}
|
||||
const act = (s: NinetyNineState, p: string, a: NNAction) => actE(s, p, a).state;
|
||||
const idOf = (s: NinetyNineState, p: string, label: string): string => {
|
||||
const c = s.hands[p]!.find((x) => x.face.label === label);
|
||||
if (!c) throw new Error(`${p} has no ${label}`);
|
||||
return c.id;
|
||||
};
|
||||
const play = (s: NinetyNineState, p: string, label: string, extra: Partial<PlayAction> = {}): PlayAction => ({ type: 'play', cardId: idOf(s, p, label), ...extra });
|
||||
const reject = (s: NinetyNineState, p: string, a: NNAction): string | null => {
|
||||
const v = ninetyNine.validate(s, p, a);
|
||||
return v.ok ? null : v.reason;
|
||||
};
|
||||
const labelsOf = (s: NinetyNineState, p: string) => s.hands[p]!.map((c) => c.face.label);
|
||||
const legalLabels = (s: NinetyNineState, p: string) =>
|
||||
[...new Set(ninetyNine.legalActions!(s, p).map((a) => (a.type === 'play' ? s.hands[p]!.find((c) => c.id === a.cardId)!.face.label : a.type)))].sort();
|
||||
const types = (events: GameEvent[]) => events.map((e) => e.type);
|
||||
|
||||
// ---------------------------------------------------------------- deck and setup
|
||||
|
||||
describe('덱 구성과 셋업 (§3, §4)', () => {
|
||||
test('kr-product default: 91 cards (1–8 ×7, 10 ×21, ±9/±10/0 ×4, joker ×2); specialCounts=2 → 85', () => {
|
||||
const d = krDeck(4);
|
||||
expect(d).toHaveLength(91);
|
||||
const count = (f: (c: NNCard) => boolean) => d.filter(f).length;
|
||||
for (let v = 1; v <= 8; v++) expect(count((c) => c.kind === 'add' && c.v === v)).toBe(7);
|
||||
expect(count((c) => c.kind === 'add' && c.v === 10)).toBe(21);
|
||||
expect(count((c) => c.kind === 'add' && c.v === 9)).toBe(0);
|
||||
expect(count((c) => c.kind === 'plusMinus' && c.v === 9)).toBe(4);
|
||||
expect(count((c) => c.kind === 'plusMinus' && c.v === 10)).toBe(4);
|
||||
expect(count((c) => c.kind === 'reverse')).toBe(4);
|
||||
expect(count((c) => c.kind === 'joker')).toBe(2);
|
||||
expect(krDeck(2)).toHaveLength(85);
|
||||
expect(new Set(d.map((c) => c.id)).size).toBe(91);
|
||||
expect(cardTotal(make({ specialCounts: 2 }))).toBe(85);
|
||||
});
|
||||
|
||||
test('classic: 52 cards (A ace, 4 reverse, 9 hold, 10 minus, K to99); 7+ players with auto → 104', () => {
|
||||
const d = classicDeck(1);
|
||||
expect(d).toHaveLength(52);
|
||||
expect(d.filter((c) => c.kind === 'to99')).toHaveLength(4);
|
||||
expect(d.filter((c) => c.kind === 'ace')).toHaveLength(4);
|
||||
expect(d.filter((c) => c.kind === 'add' && c.v === 10)).toHaveLength(8); // J, Q
|
||||
expect(cardTotal(make({ mode: 'classic' }, ['A', 'B', 'C', 'D', 'E', 'F']))).toBe(52);
|
||||
const seven = make({ mode: 'classic' }, ['A', 'B', 'C', 'D', 'E', 'F', 'G']);
|
||||
expect(cardTotal(seven)).toBe(104);
|
||||
expect(new Set(fullDeck(seven).map((c) => c.id)).size).toBe(104);
|
||||
expect(cardTotal(make({ mode: 'classic', deckCount: 2 }))).toBe(104);
|
||||
expect(cardTotal(make({ mode: 'classic', deckCount: 1 }, ['A', 'B', 'C', 'D', 'E', 'F', 'G']))).toBe(52);
|
||||
});
|
||||
|
||||
test('same seed → same deal and first player; handSize cards each; total 0; lives per mode', () => {
|
||||
const a = make({}, ['A', 'B', 'C', 'D'], 5);
|
||||
const b = make({}, ['A', 'B', 'C', 'D'], 5);
|
||||
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
||||
expect(JSON.stringify(make({}, ['A', 'B', 'C', 'D'], 6).hands)).not.toBe(JSON.stringify(a.hands));
|
||||
const starters = new Set(Array.from({ length: 20 }, (_, i) => make({}, ['A', 'B', 'C', 'D'], i).current));
|
||||
expect(starters.size).toBeGreaterThan(1);
|
||||
for (const p of a.seats) expect(a.hands[p]).toHaveLength(3);
|
||||
expect(a.total).toBe(0);
|
||||
expect(a.livesStart).toBe(0);
|
||||
expect(make({ mode: 'classic' }).livesStart).toBe(3);
|
||||
expect(make({ mode: 'classic', lives: 0 }).livesStart).toBe(0);
|
||||
expect(make({ handSize: 5 }).hands.A).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- legality
|
||||
|
||||
describe('낼 수 있는 카드 (§5.2, §10)', () => {
|
||||
test('95 with [8, 5, 4]: only 4; it makes 99; the next player with [1, 2, 3] is out at once', () => {
|
||||
let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['8', '5', '4'], B: ['1', '2', '3'], C: ['0', '10', '7'] }, total: 95 });
|
||||
expect(legalLabels(s, 'A')).toEqual(['4']);
|
||||
expect(reject(s, 'A', play(s, 'A', '8'))).toBe('99를 넘어서 낼 수 없어요');
|
||||
const v = ninetyNine.view(s, 'A');
|
||||
expect(v.my!.hand.map((c) => [c.face.label, c.legal, c.badge, c.choices[0]!.total])).toEqual([
|
||||
['8', false, '99 초과', 103],
|
||||
['5', false, '99 초과', 100],
|
||||
['4', true, null, 99],
|
||||
]);
|
||||
const out = actE(s, 'A', play(s, 'A', '4'));
|
||||
s = out.state;
|
||||
expect(s.total).toBe(99);
|
||||
expect(s.alive.B).toBe(false);
|
||||
expect(s.eliminationOrder).toEqual(['B']);
|
||||
expect(s.current).toBe('C');
|
||||
expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'eliminated']);
|
||||
expect(s.hands.B).toEqual([]);
|
||||
expect(cardTotal(s)).toBe(91);
|
||||
});
|
||||
|
||||
test('±10 at 5: minus is refused with forbid, makes 0 with clamp; plus makes 15; the sign is required', () => {
|
||||
const base = (floorAtZero: NNOptions['floorAtZero']) => rig(make({ floorAtZero }), { hands: { A: ['±10', '1'], B: ['2'], C: ['3'] }, total: 5 });
|
||||
let s = base('forbid');
|
||||
expect(reject(s, 'A', play(s, 'A', '±10', { sign: '-' }))).toBe('0보다 작아져서 뺄 수 없어요');
|
||||
expect(reject(s, 'A', play(s, 'A', '±10'))).toBe('더하기와 빼기 중 하나를 골라 주세요');
|
||||
expect(act(s, 'A', play(s, 'A', '±10', { sign: '+' })).total).toBe(15);
|
||||
expect(ninetyNine.view(s, 'A').my!.hand[0]!.choices.map((c) => [c.label, c.total, c.legal])).toEqual([
|
||||
['+10', 15, true],
|
||||
['−10', -5, false],
|
||||
]);
|
||||
s = base('clamp');
|
||||
expect(act(s, 'A', play(s, 'A', '±10', { sign: '-' })).total).toBe(0);
|
||||
// ±9 at 95: plus is over, minus is fine → still playable.
|
||||
s = rig(make(), { hands: { A: ['±9'], B: ['1'], C: ['1'] }, total: 95 });
|
||||
expect(reject(s, 'A', play(s, 'A', '±9', { sign: '+' }))).toBe('99를 넘어서 낼 수 없어요');
|
||||
expect(act(s, 'A', play(s, 'A', '±9', { sign: '-' })).total).toBe(86);
|
||||
});
|
||||
|
||||
test('joker at 20: 99 is fine; 59 and 100 are refused; 0~99 range allows small numbers', () => {
|
||||
const s = rig(make(), { hands: { A: ['조커', '1'], B: ['2'], C: ['3'] }, total: 20 });
|
||||
expect(act(s, 'A', play(s, 'A', '조커', { jokerValue: 99 })).total).toBe(99);
|
||||
const high = rig(make(), { hands: { A: ['조커'], B: ['2'], C: ['3'] }, total: 70 });
|
||||
expect(act(high, 'A', play(high, 'A', '조커', { jokerValue: 70 })).total).toBe(70); // the same number is fine
|
||||
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 59 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요');
|
||||
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 100 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요');
|
||||
expect(ninetyNine.actionSchema.safeParse(play(s, 'A', '조커', { jokerValue: 100 })).success).toBe(false);
|
||||
expect(reject(s, 'A', play(s, 'A', '조커'))).toContain('골라 주세요');
|
||||
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 70.5 }))).toContain('60부터 99');
|
||||
const kid = rig(make({ jokerRange: '0-99' }), { hands: { A: ['조커'], B: ['2'], C: ['3'] }, total: 80 });
|
||||
expect(act(kid, 'A', play(kid, 'A', '조커', { jokerValue: 0 })).total).toBe(0);
|
||||
expect(ninetyNine.view(kid, 'A').my!.hand[0]!.joker).toEqual({ min: 0, max: 99 });
|
||||
});
|
||||
|
||||
test('0 card: with 3 players B’s 0 turns the direction and A plays next; with 2 players it stays the opponent', () => {
|
||||
let s = rig(make(), { hands: { A: ['1', '2'], B: ['0', '3'], C: ['4', '5'] }, total: 30, current: 'B' });
|
||||
const out = actE(s, 'B', play(s, 'B', '0'));
|
||||
s = out.state;
|
||||
expect(s.total).toBe(30);
|
||||
expect(s.direction).toBe(-1);
|
||||
expect(s.current).toBe('A');
|
||||
expect(types(out.events)).toContain('directionChanged');
|
||||
s = act(s, 'A', play(s, 'A', '1'));
|
||||
expect(s.current).toBe('C');
|
||||
let two = rig(make({}, ['A', 'B']), { hands: { A: ['1'], B: ['0', '3'] }, total: 30, current: 'B' });
|
||||
two = act(two, 'B', play(two, 'B', '0'));
|
||||
expect(two.direction).toBe(-1);
|
||||
expect(two.current).toBe('A');
|
||||
});
|
||||
|
||||
test('classic: K at 90 → 99; then only 4 / 9 / 10 / K; A is refused even as 1; A as 11 at 88 makes 99', () => {
|
||||
const o = { mode: 'classic' as const, lives: 0 as const };
|
||||
let s = rig(make(o), { hands: { A: ['K', '2'], B: ['A', '4', '9', '10', 'K', '2', 'J'], C: ['3'] }, total: 90 });
|
||||
s = act(s, 'A', play(s, 'A', 'K'));
|
||||
expect(s.total).toBe(99);
|
||||
expect(s.current).toBe('B');
|
||||
expect(legalLabels(s, 'B')).toEqual(['10', '4', '9', 'K']);
|
||||
expect(reject(s, 'B', play(s, 'B', 'A', { aceValue: 1 }))).toBe('99를 넘어서 낼 수 없어요');
|
||||
expect(act(s, 'B', play(s, 'B', '10')).total).toBe(89);
|
||||
expect(act(s, 'B', play(s, 'B', '9')).total).toBe(99);
|
||||
expect(act(s, 'B', play(s, 'B', 'K')).total).toBe(99);
|
||||
s = rig(make(o), { hands: { A: ['A', '2'], B: ['3'], C: ['3'] }, total: 88 });
|
||||
expect(act(s, 'A', play(s, 'A', 'A', { aceValue: 11 })).total).toBe(99);
|
||||
expect(act(s, 'A', play(s, 'A', 'A', { aceValue: 1 })).total).toBe(89);
|
||||
expect(reject(s, 'A', play(s, 'A', 'A'))).toBe('A를 1로 낼지 11로 낼지 골라 주세요');
|
||||
expect(reject(s, 'A', play(s, 'A', 'A', { aceValue: 10 }))).toBe('A는 1이나 11로만 낼 수 있어요');
|
||||
const ten = rig(make({ ...o, aceHigh: 10 }), { hands: { A: ['A'], B: ['3'], C: ['3'] }, total: 80 });
|
||||
expect(act(ten, 'A', play(ten, 'A', 'A', { aceValue: 10 })).total).toBe(90);
|
||||
// 10 at 5: forbid refuses, clamp makes 0.
|
||||
const low = rig(make(o), { hands: { A: ['10', '2'], B: ['3'], C: ['3'] }, total: 5 });
|
||||
expect(reject(low, 'A', play(low, 'A', '10'))).toBe('0보다 작아져서 뺄 수 없어요');
|
||||
const clamp = rig(make({ ...o, floorAtZero: 'clamp' }), { hands: { A: ['10'], B: ['3'], C: ['3'] }, total: 5 });
|
||||
expect(act(clamp, 'A', play(clamp, 'A', '10')).total).toBe(0);
|
||||
// 9 passes without changing anything; 4 reverses.
|
||||
const hold = rig(make(o), { hands: { A: ['9'], B: ['3'], C: ['3'] }, total: 50 });
|
||||
const h = act(hold, 'A', play(hold, 'A', '9'));
|
||||
expect([h.total, h.direction, h.current]).toEqual([50, 1, 'B']);
|
||||
});
|
||||
|
||||
test('a cleared hand (refill off) has nothing to play and loses at the start of the turn', () => {
|
||||
let s = rig(make({ refill: false }), { hands: { A: ['1'], B: ['2', '3'], C: ['4'] }, total: 10 });
|
||||
s = act(s, 'A', play(s, 'A', '1'));
|
||||
expect(s.hands.A).toEqual([]);
|
||||
s = act(s, 'B', play(s, 'B', '2'));
|
||||
s = act(s, 'C', play(s, 'C', '4'));
|
||||
expect(s.alive.A).toBe(false);
|
||||
expect(s.current).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- losing
|
||||
|
||||
describe('패배 처리 (§5.3, §10)', () => {
|
||||
test('lives=3: the loser loses one life, a new round starts at 0 with the loser first; at 0 lives → out, the next one starts', () => {
|
||||
let s = rig(make({ lives: 3 }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
|
||||
const out = actE(s, 'A', play(s, 'A', '4'));
|
||||
s = out.state;
|
||||
expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'lifeLost', 'roundStarted']);
|
||||
expect(s.lives.B).toBe(2);
|
||||
expect(s.livesLost.B).toBe(1);
|
||||
expect(s.alive.B).toBe(true);
|
||||
expect([s.total, s.round, s.current, s.direction]).toEqual([0, 2, 'B', 1]);
|
||||
for (const p of s.seats) expect(s.hands[p]).toHaveLength(3);
|
||||
expect(s.discard).toEqual([]);
|
||||
expect(cardTotal(s)).toBe(91);
|
||||
// B on the last life loses again → out; C (next after B) starts round 3.
|
||||
s = rig(s, { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
|
||||
s.lives.B = 1;
|
||||
s = act(s, 'A', play(s, 'A', '4'));
|
||||
expect(s.alive.B).toBe(false);
|
||||
expect(s.lives.B).toBe(0);
|
||||
expect(s.eliminationOrder).toEqual(['B']);
|
||||
expect([s.total, s.round, s.current]).toEqual([0, 3, 'C']);
|
||||
expect(s.hands.B).toEqual([]);
|
||||
expect(s.hands.A).toHaveLength(3);
|
||||
expect(s.eliminatedAtRound.B).toBe(2);
|
||||
});
|
||||
|
||||
test('onTheBoard: at 0 lives the player stays in and is out only after one more loss', () => {
|
||||
let s = rig(make({ lives: 1, onTheBoard: true }), { hands: { A: ['4'], B: ['5', '6'], C: ['1'] }, total: 95 });
|
||||
s = act(s, 'A', play(s, 'A', '4'));
|
||||
expect([s.alive.B, s.lives.B, s.onBoard.B, s.current]).toEqual([true, 0, true, 'B']);
|
||||
s = rig(s, { hands: { A: ['4'], B: ['5', '6'], C: ['1'] }, total: 95 });
|
||||
s = act(s, 'A', play(s, 'A', '4'));
|
||||
expect(s.alive.B).toBe(false);
|
||||
expect(s.current).toBe('C');
|
||||
});
|
||||
|
||||
test('chain (lives=0, continue): at 98 the next two have nothing → both out in one apply, in order; the third plays', () => {
|
||||
let s = rig(make({}, ['A', 'B', 'C', 'D']), { hands: { A: ['8'], B: ['2', '3', '5'], C: ['2', '4', '6'], D: ['1', '10'] }, total: 90 });
|
||||
const out = actE(s, 'A', play(s, 'A', '8'));
|
||||
s = out.state;
|
||||
expect(types(out.events)).toEqual(['cardPlayed', 'cardDrawn', 'noCard', 'eliminated', 'noCard', 'eliminated']);
|
||||
expect(out.events.filter((e) => e.type === 'eliminated').map((e) => e.player)).toEqual(['B', 'C']);
|
||||
expect(s.eliminationOrder).toEqual(['B', 'C']);
|
||||
expect([s.total, s.current]).toEqual([98, 'D']);
|
||||
expect(cardTotal(s)).toBe(91);
|
||||
});
|
||||
|
||||
test('reset after an elimination: total back to 0, everyone redealt, the next player starts', () => {
|
||||
let s = rig(make({ afterElimination: 'reset' }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
|
||||
s = act(s, 'A', play(s, 'A', '4'));
|
||||
expect(s.alive.B).toBe(false);
|
||||
expect([s.total, s.current, s.round]).toEqual([0, 'C', 2]);
|
||||
expect(s.hands.A).toHaveLength(3);
|
||||
expect(s.hands.C).toHaveLength(3);
|
||||
expect(cardTotal(s)).toBe(91);
|
||||
});
|
||||
|
||||
test('last one standing wins: ranking by elimination order, Korean summary, lives lost in the result', () => {
|
||||
let s = rig(make({}, ['A', 'B']), { hands: { A: ['4'], B: ['5', '6'] }, total: 95 });
|
||||
s = act(s, 'A', play(s, 'A', '4'));
|
||||
expect(s.phase).toBe('finished');
|
||||
const r = ninetyNine.result(s)!;
|
||||
expect(r.ranking).toEqual([['A'], ['B']]);
|
||||
expect(r.summary).toBe('끝까지 살아남은 사람이 이겼어요! 마지막 합계 99');
|
||||
expect(s.final!.ranking.map((x) => [x.player, x.rank, x.eliminatedAtRound])).toEqual([
|
||||
['A', 1, null],
|
||||
['B', 2, 1],
|
||||
]);
|
||||
expect(ninetyNine.activePlayers(s)).toEqual([]);
|
||||
expect(ninetyNine.deadline!(s)).toBeNull();
|
||||
expect(reject(s, 'A', { type: 'timeout' })).toBe('게임이 이미 끝났어요');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- piles
|
||||
|
||||
describe('더미 소진 (§5.1)', () => {
|
||||
test('refill with an empty draw pile: the discards are shuffled into a new pile; the total stays', () => {
|
||||
let s = rig(make(), { hands: { A: ['7', '1', '2'], B: ['3'], C: ['4'] }, total: 40, emptyDraw: true });
|
||||
const n = s.discard.length;
|
||||
const out = actE(s, 'A', play(s, 'A', '7'));
|
||||
s = out.state;
|
||||
expect(types(out.events)).toEqual(['cardPlayed', 'reshuffled', 'cardDrawn']);
|
||||
expect(out.events[1]!.count).toBe(n + 1);
|
||||
expect(s.total).toBe(47);
|
||||
expect(s.hands.A).toHaveLength(3);
|
||||
expect(s.discard).toEqual([]);
|
||||
expect(s.drawPile).toHaveLength(n);
|
||||
expect(cardTotal(s)).toBe(91);
|
||||
// Only the card just played is left: it is shuffled back and drawn again.
|
||||
let dry = rig(make(), { hands: { A: ['7'], B: ['3'], C: ['4'] }, total: 40, emptyDraw: true });
|
||||
dry.discard = [];
|
||||
dry = act(dry, 'A', play(dry, 'A', '7'));
|
||||
expect(labelsOf(dry, 'A')).toEqual(['7']);
|
||||
expect([dry.discard.length, dry.drawPile.length, dry.total]).toEqual([0, 0, 47]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- timer / away / leave
|
||||
|
||||
describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => {
|
||||
test('timeout plays the smallest resulting total: [10, 3, 0] at 50 → 0; [±9, 3, 0] at 50 → −9 (41)', () => {
|
||||
let s = rig(make(), { hands: { A: ['10', '3', '0'], B: ['1'], C: ['1'] }, total: 50 });
|
||||
expect(ninetyNine.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
|
||||
expect(ninetyNine.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
|
||||
const out = actE(s, 'A', { type: 'timeout' });
|
||||
expect(labelsOf(out.state, 'A')).not.toContain('0');
|
||||
expect([out.state.total, out.state.direction, out.state.current]).toEqual([50, -1, 'C']);
|
||||
expect(out.state.log.at(-1)).toMatchObject({ t: 'play', face: { label: '0' }, auto: true });
|
||||
s = rig(make(), { hands: { A: ['±9', '3', '0'], B: ['1'], C: ['1'] }, total: 50 });
|
||||
expect(act(s, 'A', { type: 'timeout' }).total).toBe(41);
|
||||
// Joker only → the lowest joker number; tie at the same total → the no-change card first.
|
||||
s = rig(make(), { hands: { A: ['조커'], B: ['1'], C: ['1'] }, total: 30 });
|
||||
expect(act(s, 'A', { type: 'timeout' }).total).toBe(60);
|
||||
s = rig(make(), { hands: { A: ['조커', '0'], B: ['1'], C: ['1'] }, total: 60 });
|
||||
const t = act(s, 'A', { type: 'timeout' });
|
||||
expect([t.total, t.direction]).toEqual([60, -1]);
|
||||
const cls = rig(make({ mode: 'classic' }), { hands: { A: ['K', 'A', '9'], B: ['2'], C: ['2'] }, total: 95 });
|
||||
expect(safestPlay(cls.hands.A!, 95, cls.options)).toEqual({ type: 'play', cardId: idOf(cls, 'A', '9') });
|
||||
});
|
||||
|
||||
test('a disconnected player keeps being auto-played; after 3 in a row the limit is 3 s; the game never stalls', () => {
|
||||
let s = make({ turnSeconds: 15 }, ['A', 'B', 'C'], 3);
|
||||
const first = s.current;
|
||||
expect(ninetyNine.deadline!(s)).toBe(s.turnStartedAt + 15_000);
|
||||
let steps = 0;
|
||||
while (!ninetyNine.result(s) && steps < 2000) {
|
||||
const p = ninetyNine.activePlayers(s)[0]!;
|
||||
s = act(s, p, ninetyNine.onTimeout(s, p));
|
||||
steps++;
|
||||
if (steps === 9 && s.phase === 'playing' && s.timeoutsInRow[s.current] === 3) {
|
||||
expect(s.away[s.current]).toBe(true);
|
||||
expect(ninetyNine.deadline!(s)).toBe(s.turnStartedAt + 3000);
|
||||
}
|
||||
}
|
||||
expect(ninetyNine.result(s)).not.toBeNull();
|
||||
expect(first).toBeTruthy();
|
||||
// A real play clears the away flag.
|
||||
let t = rig(make(), { hands: { A: ['1', '2'], B: ['1'], C: ['1'] }, total: 10 });
|
||||
t.away.A = true;
|
||||
t.timeoutsInRow.A = 4;
|
||||
t = act(t, 'A', play(t, 'A', '1'));
|
||||
expect([t.away.A, t.timeoutsInRow.A]).toEqual([false, 0]);
|
||||
});
|
||||
|
||||
test('leaving: the current player leaving passes the turn with the same total; 2 players → the other wins', () => {
|
||||
let s = rig(make({ lives: 3 }), { hands: { A: ['1'], B: ['2'], C: ['3'] }, total: 40 });
|
||||
expect(ninetyNine.onLeave!(s, 'A')).toEqual({ type: 'leave' });
|
||||
s = act(s, 'A', { type: 'leave' });
|
||||
expect([s.alive.A, s.current, s.total, s.round]).toEqual([false, 'B', 40, 1]);
|
||||
expect(ninetyNine.onLeave!(s, 'A')).toBeNull();
|
||||
s = act(s, 'C', { type: 'leave' });
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(ninetyNine.result(s)!.ranking).toEqual([['B'], ['C'], ['A']]);
|
||||
expect(s.final!.summary).toBe('다른 사람이 모두 나가서 남은 사람이 이겼어요');
|
||||
expect(ninetyNine.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- validation and leaks
|
||||
|
||||
describe('검증과 정보 유출 (§8.3, §10)', () => {
|
||||
test('not my turn, ± without a sign, joker number out of range, resent packet: refused and nothing changes', () => {
|
||||
const s = rig(make(), { hands: { A: ['±9', '조커', '3'], B: ['1', '2'], C: ['4'] }, total: 30 });
|
||||
const snap = JSON.stringify(s);
|
||||
expect(reject(s, 'B', play(s, 'B', '1'))).toBe('지금은 내 차례가 아니에요');
|
||||
expect(reject(s, 'A', play(s, 'A', '±9'))).toBe('더하기와 빼기 중 하나를 골라 주세요');
|
||||
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 40 }))).toBe('조커는 60부터 99 사이 숫자만 고를 수 있어요');
|
||||
expect(reject(s, 'A', { type: 'play', cardId: s.hands.B![0]!.id })).toBe('손에 없는 카드예요');
|
||||
expect(reject(s, 'A', play(s, 'A', '3', { sign: '+' }))).toBe('이 카드는 고를 것 없이 바로 내요');
|
||||
expect(reject(s, 'X', play(s, 'A', '3'))).toBe('이 게임의 플레이어가 아니에요');
|
||||
const a = play(s, 'A', '3');
|
||||
const next = act(s, 'A', a);
|
||||
expect(reject(next, 'A', a)).toBe('지금은 내 차례가 아니에요');
|
||||
expect(JSON.stringify(s)).toBe(snap);
|
||||
const dead = rig(make(), { hands: { A: ['4'], B: ['5', '6'], C: ['0', '2'] }, total: 95 });
|
||||
const after = act(dead, 'A', play(dead, 'A', '4'));
|
||||
expect(reject(after, 'B', { type: 'timeout' })).toBe('탈락해서 카드를 낼 수 없어요');
|
||||
});
|
||||
|
||||
test('views: opponents and spectators see counts only; refills and eliminated hands are never revealed', () => {
|
||||
let s = rig(make(), { hands: { A: ['4', '1', '2'], B: ['5', '6', '7'], C: ['0', '3', '8'] }, total: 95 });
|
||||
const bHand = s.hands.B!.map((c) => c.id);
|
||||
const out = actE(s, 'A', play(s, 'A', '4'));
|
||||
s = out.state;
|
||||
const drawn = s.hands.A!.at(-1)!.id;
|
||||
for (const viewer of ['B', 'C', null]) {
|
||||
const json = JSON.stringify(ninetyNine.view(s, viewer));
|
||||
expect(json).not.toContain(`"${drawn}"`);
|
||||
for (const id of bHand) expect(json).not.toContain(`"${id}"`);
|
||||
for (const c of s.drawPile.slice(0, 20)) expect(json).not.toContain(`"${c.id}"`);
|
||||
checkNoLeak(ninetyNine, s, viewer ?? 'C', s.seats);
|
||||
}
|
||||
expect(JSON.stringify(ninetyNine.view(s, 'A'))).toContain(`"${drawn}"`);
|
||||
expect(JSON.stringify(out.events)).not.toContain('"id"');
|
||||
expect(out.events.find((e) => e.type === 'cardDrawn')).toEqual({ type: 'cardDrawn', player: 'A' });
|
||||
const v = ninetyNine.view(s, null);
|
||||
expect(v.my).toBeNull();
|
||||
expect(v.seats.map((x) => [x.id, x.cards, x.alive])).toEqual([
|
||||
['A', 3, true],
|
||||
['B', 0, false],
|
||||
['C', 3, true],
|
||||
]);
|
||||
expect(v.recent.map((e) => [e.face.label, e.total])).toEqual([['4', 99]]);
|
||||
expect(ninetyNine.view(s, 'B').my!.hand).toEqual([]);
|
||||
});
|
||||
|
||||
test('options: defaults, Korean metadata on every option; client actions only in the schema', () => {
|
||||
const d = ninetyNine.defaultOptions;
|
||||
expect(d).toMatchObject({ mode: 'kr-product', handSize: 3, refill: true, lives: 'auto', onTheBoard: false, afterElimination: 'continue', floorAtZero: 'forbid', specialCounts: 4, jokerRange: '60-99', aceHigh: 11, deckCount: 'auto', turnSeconds: 15 });
|
||||
const shape = (ninetyNine.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string; labels?: Record<string, string> } | undefined }> }).shape;
|
||||
for (const [k, f] of Object.entries(shape)) {
|
||||
const m = f.meta();
|
||||
expect(m?.title, k).toMatch(/[가-힣]/);
|
||||
expect(m?.labels, k).toBeTruthy();
|
||||
}
|
||||
expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', sign: '+' }).success).toBe(true);
|
||||
expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', aceValue: 5 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- random playouts
|
||||
|
||||
interface Report {
|
||||
steps: number;
|
||||
finished: boolean;
|
||||
trace: string;
|
||||
final: NinetyNineState;
|
||||
}
|
||||
|
||||
const deckSizeOf = (s: NinetyNineState): number => (s.mode === 'classic' ? 52 * s.decks : 79 + 3 * s.options.specialCounts);
|
||||
|
||||
function invariants(s: NinetyNineState): void {
|
||||
const size = deckSizeOf(s);
|
||||
if (cardTotal(s) !== size) throw new Error(`card count ${cardTotal(s)} != ${size}`);
|
||||
const ids = new Set<string>();
|
||||
for (const c of [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p]!)]) {
|
||||
if (ids.has(c.id)) throw new Error(`duplicate card ${c.id}`);
|
||||
ids.add(c.id);
|
||||
}
|
||||
if (s.total < 0 || s.total > 99) throw new Error(`total out of range ${s.total}`);
|
||||
for (const p of s.seats) {
|
||||
if (!s.alive[p] && s.hands[p]!.length) throw new Error(`eliminated ${p} holds cards`);
|
||||
if (s.lives[p]! < 0) throw new Error('negative lives');
|
||||
}
|
||||
if (s.phase === 'playing') {
|
||||
if (!s.alive[s.current]) throw new Error('current player is out');
|
||||
if (!ninetyNine.legalActions!(s, s.current).length) throw new Error('current player has no legal move');
|
||||
}
|
||||
const alive = s.seats.filter((p) => s.alive[p]).length;
|
||||
if (s.phase === 'finished' ? alive !== 1 : alive < 2) throw new Error(`alive=${alive} in ${s.phase}`);
|
||||
}
|
||||
|
||||
function checkLeaks(s: NinetyNineState, players: PlayerId[]): void {
|
||||
for (const p of players) checkNoLeak(ninetyNine, s, p, players);
|
||||
}
|
||||
|
||||
/** Cheap per-step version: the only card ids in a view are the viewer's own hand (no discard, pile or other hands). */
|
||||
const CARD_ID = /"(k\d+|t[SHDC]\d+b?)"/g;
|
||||
function checkIds(s: NinetyNineState, players: PlayerId[]): void {
|
||||
for (const p of [...players, null]) {
|
||||
const own = new Set(p ? s.hands[p]!.map((c) => c.id) : []);
|
||||
for (const m of JSON.stringify(ninetyNine.view(s, p)).matchAll(CARD_ID)) {
|
||||
if (!own.has(m[1]!)) throw new Error(`view for ${p} shows card ${m[1]} it does not hold`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fastPlayout(players: PlayerId[], options: NNOptions, seed: RngState, maxSteps: number): Report {
|
||||
const r = SeededRng.fromSeed(seed);
|
||||
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
|
||||
let now = 1_700_000_000_000;
|
||||
let s = ninetyNine.setup({ players, options, rng: r, now });
|
||||
const trace: string[] = [];
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
invariants(s);
|
||||
if (step % 3 === 0) checkIds(s, players);
|
||||
if (step % 200 === 100) checkLeaks(s, players);
|
||||
if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
|
||||
if (ninetyNine.result(s)) {
|
||||
checkIds(s, players);
|
||||
return { steps: step, finished: true, trace: trace.join(';'), final: s };
|
||||
}
|
||||
const actor = ninetyNine.activePlayers(s)[0]!;
|
||||
const v0 = ninetyNine.validate(s, actor, ninetyNine.onTimeout(s, actor));
|
||||
if (!v0.ok) throw new Error(`onTimeout rejected: ${v0.reason}`);
|
||||
const legal = ninetyNine.legalActions!(s, actor);
|
||||
// Mostly random legal plays, sometimes the timer move or someone leaving.
|
||||
const roll = chooser.int(100);
|
||||
let who = actor;
|
||||
let action: NNAction;
|
||||
if (roll < 8) action = { type: 'timeout' };
|
||||
else if (roll === 99) {
|
||||
const alive = s.seats.filter((p) => s.alive[p]);
|
||||
who = alive[chooser.int(alive.length)]!;
|
||||
action = ninetyNine.onLeave!(s, who)!;
|
||||
} else action = legal[chooser.int(legal.length)]!;
|
||||
const v = ninetyNine.validate(s, who, action);
|
||||
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
|
||||
now += 1000;
|
||||
const out = ninetyNine.apply(s, who, action, { rng: r, now });
|
||||
if (JSON.stringify(out.events).includes('"id"')) throw new Error('event carries a card id');
|
||||
s = out.state;
|
||||
trace.push(`${who}:${JSON.stringify(action)}`);
|
||||
}
|
||||
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
|
||||
}
|
||||
|
||||
const PRESETS: Partial<NNOptions>[] = [
|
||||
{},
|
||||
{ lives: 3 },
|
||||
{ afterElimination: 'reset', floorAtZero: 'clamp', specialCounts: 6 },
|
||||
{ refill: false, handSize: 5 },
|
||||
{ jokerRange: '0-99', handSize: 4, lives: 2, onTheBoard: true },
|
||||
{ mode: 'classic' },
|
||||
{ mode: 'classic', lives: 0, aceHigh: 10 },
|
||||
{ mode: 'classic', lives: 1, onTheBoard: true, floorAtZero: 'clamp', deckCount: 2 },
|
||||
{ mode: 'classic', refill: false, handSize: 5, lives: 2 },
|
||||
{ mode: 'classic', deckCount: 1, handSize: 5, lives: 5 },
|
||||
];
|
||||
|
||||
describe('random playouts', () => {
|
||||
test('1,000 seeded games finish: cards conserved, no leaks, onTimeout always legal, deterministic', () => {
|
||||
let finished = 0;
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
const n = 2 + (i % 9);
|
||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||
const options = ninetyNine.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
|
||||
const a = fastPlayout(players, options, seedN(i), 20_000);
|
||||
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 20,000 steps`);
|
||||
finished++;
|
||||
const res = ninetyNine.result(a.final)!;
|
||||
expect(res.ranking.flat().sort()).toEqual([...players].sort());
|
||||
if (i % 25 === 0) {
|
||||
const b = fastPlayout(players, options, seedN(i), 20_000);
|
||||
expect(b.trace).toBe(a.trace);
|
||||
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||
}
|
||||
}
|
||||
expect(finished).toBe(1000);
|
||||
}, 60_000);
|
||||
|
||||
test.each([
|
||||
[2, {}],
|
||||
[3, { lives: 3 }],
|
||||
[5, { afterElimination: 'reset' }],
|
||||
[10, {}],
|
||||
[4, { mode: 'classic' }],
|
||||
[8, { mode: 'classic', lives: 1, onTheBoard: true }],
|
||||
] as const)('randomPlayout with the engine’s per-step checks: %i players %o', (n, opts) => {
|
||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||
const options = ninetyNine.optionsSchema.parse(opts);
|
||||
const check = (st: unknown) => invariants(st as NinetyNineState);
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const a = randomPlayout(ninetyNine, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: check, maxApplyMs: 30 });
|
||||
expect(a.finished).toBe(true);
|
||||
if (i === 1) {
|
||||
const b = randomPlayout(ninetyNine, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, maxApplyMs: 30 });
|
||||
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
203
packages/games/src/ninety-nine/types.ts
Normal file
203
packages/games/src/ninety-nine/types.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/** 구십구 options, actions, state and view types (docs/games/ninety-nine.md §2, §8). */
|
||||
import { z } from 'zod';
|
||||
import type { PlayerId } from '@bg/engine';
|
||||
|
||||
const lit = <T extends string | number>(...vals: T[]) =>
|
||||
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||
|
||||
export const ninetyNineOptionsSchema = z.object({
|
||||
mode: z
|
||||
.enum(['kr-product', 'classic'])
|
||||
.default('kr-product')
|
||||
.meta({ title: '규칙', labels: { 'kr-product': '전용 카드(±9·±10·0·조커)', classic: '트럼프 카드(전통 99)' } }),
|
||||
handSize: lit(3, 4, 5).default(3).meta({ title: '손패 장수', labels: { 3: '3장', 4: '4장', 5: '5장' } }),
|
||||
refill: z.boolean().default(true).meta({ title: '카드를 낸 뒤 1장 보충', labels: { true: '보충해요', false: '보충 안 해요' } }),
|
||||
lives: lit<'auto' | 0 | 1 | 2 | 3 | 5>('auto', 0, 1, 2, 3, 5)
|
||||
.default('auto')
|
||||
.meta({
|
||||
title: '목숨 수',
|
||||
labels: { auto: '규칙 기본(전용 카드 없음, 트럼프 3개)', 0: '없음(바로 탈락)', 1: '1개', 2: '2개', 3: '3개', 5: '5개' },
|
||||
}),
|
||||
onTheBoard: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.meta({ title: '목숨을 다 잃어도 한 번 더 질 때까지 남기', labels: { true: '켜기', false: '끄기' } }),
|
||||
afterElimination: z
|
||||
.enum(['continue', 'reset'])
|
||||
.default('continue')
|
||||
.meta({ title: '(목숨 없음) 탈락 뒤 합계', labels: { continue: '그대로 이어 가기', reset: '0으로 하고 다시 나누기' } }),
|
||||
floorAtZero: z
|
||||
.enum(['forbid', 'clamp'])
|
||||
.default('forbid')
|
||||
.meta({ title: '빼서 0보다 작아질 때', labels: { forbid: '그렇게는 못 빼요', clamp: '0으로 맞춰요' } }),
|
||||
specialCounts: lit(2, 3, 4, 5, 6)
|
||||
.default(4)
|
||||
.meta({ title: '(전용 카드) ±9·±10·0 카드 장수(각각)', labels: { 2: '2장', 3: '3장', 4: '4장', 5: '5장', 6: '6장' } }),
|
||||
jokerRange: z
|
||||
.enum(['60-99', '0-99'])
|
||||
.default('60-99')
|
||||
.meta({ title: '(전용 카드) 조커로 부를 수 있는 수', labels: { '60-99': '60~99', '0-99': '0~99(어린이용)' } }),
|
||||
aceHigh: lit(10, 11).default(11).meta({ title: '(트럼프) A의 큰 값', labels: { 10: '10', 11: '11' } }),
|
||||
deckCount: lit<'auto' | 1 | 2>('auto', 1, 2)
|
||||
.default('auto')
|
||||
.meta({ title: '(트럼프) 덱 수', labels: { auto: '자동(7명부터 2벌)', 1: '1벌', 2: '2벌' } }),
|
||||
turnSeconds: lit(10, 15, 20, 30)
|
||||
.default(15)
|
||||
.meta({ title: '한 턴 제한 시간', labels: { 10: '10초', 15: '15초', 20: '20초', 30: '30초' } }),
|
||||
});
|
||||
export type NNOptions = z.infer<typeof ninetyNineOptionsSchema>;
|
||||
export type NNMode = NNOptions['mode'];
|
||||
|
||||
export const ninetyNineActionSchema = z.object({
|
||||
type: z.literal('play'),
|
||||
cardId: z.string().max(12),
|
||||
sign: z.enum(['+', '-']).optional(),
|
||||
aceValue: lit(1, 10, 11).optional(),
|
||||
jokerValue: z.number().int().min(0).max(99).optional(),
|
||||
});
|
||||
export type PlayAction = z.infer<typeof ninetyNineActionSchema>;
|
||||
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
|
||||
export type NNAction = PlayAction | { type: 'timeout' } | { type: 'leave' };
|
||||
|
||||
export type Suit = 'S' | 'H' | 'D' | 'C';
|
||||
|
||||
/** What is printed on the card (no id: safe to show in logs). */
|
||||
export interface CardFace {
|
||||
/** kr: "7", "10", "±9", "0", "조커"; classic: "A", "2".."10", "J", "Q", "K". */
|
||||
label: string;
|
||||
/** Classic only. */
|
||||
suit?: Suit;
|
||||
}
|
||||
|
||||
export type NNCard =
|
||||
| { id: string; kind: 'add'; v: number; face: CardFace }
|
||||
| { id: string; kind: 'plusMinus'; v: 9 | 10; face: CardFace }
|
||||
| { id: string; kind: 'reverse'; face: CardFace }
|
||||
| { id: string; kind: 'hold'; face: CardFace }
|
||||
| { id: string; kind: 'minus'; v: 10; face: CardFace }
|
||||
| { id: string; kind: 'ace'; face: CardFace }
|
||||
| { id: string; kind: 'to99'; face: CardFace }
|
||||
| { id: string; kind: 'joker'; face: CardFace };
|
||||
export type CardKind = NNCard['kind'];
|
||||
|
||||
/** Public log line (structured; the screen turns it into Korean with nicknames). */
|
||||
export type LogEntry =
|
||||
| { seq: number; t: 'play'; p: PlayerId; face: CardFace; kind: CardKind; from: number; total: number; choice?: string; auto?: boolean }
|
||||
| { seq: number; t: 'life'; p: PlayerId; left: number }
|
||||
| { seq: number; t: 'out'; p: PlayerId; why: 'noCard' | 'left' }
|
||||
| { seq: number; t: 'noCard'; p: PlayerId }
|
||||
| { seq: number; t: 'round'; round: number; starter: PlayerId }
|
||||
| { seq: number; t: 'reshuffle'; n: number }
|
||||
| { seq: number; t: 'timeout'; p: PlayerId };
|
||||
|
||||
export interface PlayerResult {
|
||||
player: PlayerId;
|
||||
rank: number;
|
||||
livesLost: number;
|
||||
eliminatedAtRound: number | null;
|
||||
left: boolean;
|
||||
}
|
||||
|
||||
export interface NNFinal {
|
||||
ranking: PlayerResult[];
|
||||
winner: PlayerId[];
|
||||
lastTotal: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface NinetyNineState {
|
||||
mode: NNMode;
|
||||
options: NNOptions;
|
||||
/** Lives per player resolved from the `auto` option (0 = no lives). */
|
||||
livesStart: number;
|
||||
decks: 1 | 2;
|
||||
seats: PlayerId[];
|
||||
hands: Record<PlayerId, NNCard[]>;
|
||||
/** Secret; [0] is the top. */
|
||||
drawPile: NNCard[];
|
||||
/** Only the count is public (faces are in the log). */
|
||||
discard: NNCard[];
|
||||
total: number;
|
||||
direction: 1 | -1;
|
||||
current: PlayerId;
|
||||
alive: Record<PlayerId, boolean>;
|
||||
lives: Record<PlayerId, number>;
|
||||
livesLost: Record<PlayerId, number>;
|
||||
onBoard: Record<PlayerId, boolean>;
|
||||
eliminationOrder: PlayerId[];
|
||||
eliminatedAtRound: Record<PlayerId, number | null>;
|
||||
leftOrder: PlayerId[];
|
||||
round: number;
|
||||
phase: 'playing' | 'finished';
|
||||
turnStartedAt: number;
|
||||
/** Applied actions. */
|
||||
seq: number;
|
||||
/** Changes whenever a new turn starts. */
|
||||
turnNo: number;
|
||||
timeoutsInRow: Record<PlayerId, number>;
|
||||
away: Record<PlayerId, boolean>;
|
||||
log: LogEntry[];
|
||||
final: NNFinal | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- view
|
||||
|
||||
/** One way to play a card (e.g. "+9" or "−9") and the total it would make. */
|
||||
export interface ChoiceView {
|
||||
label: string;
|
||||
sign?: '+' | '-';
|
||||
aceValue?: 1 | 10 | 11;
|
||||
/** Total after playing (may be over 99 or below 0 when not legal). */
|
||||
total: number;
|
||||
legal: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface HandCardView {
|
||||
id: string;
|
||||
kind: CardKind;
|
||||
face: CardFace;
|
||||
/** Ways to play it; the joker has none (pick a number in `joker`). */
|
||||
choices: ChoiceView[];
|
||||
joker: { min: number; max: number } | null;
|
||||
legal: boolean;
|
||||
/** Short badge for an unplayable card, e.g. "99 초과". */
|
||||
badge: string | null;
|
||||
}
|
||||
|
||||
export interface SeatView {
|
||||
id: PlayerId;
|
||||
cards: number;
|
||||
lives: number;
|
||||
livesLost: number;
|
||||
alive: boolean;
|
||||
onBoard: boolean;
|
||||
left: boolean;
|
||||
away: boolean;
|
||||
isTurn: boolean;
|
||||
}
|
||||
|
||||
export interface NinetyNineView {
|
||||
mode: NNMode;
|
||||
options: NNOptions;
|
||||
livesStart: number;
|
||||
jokerRange: { min: number; max: number };
|
||||
me: PlayerId | null;
|
||||
seats: SeatView[];
|
||||
total: number;
|
||||
direction: 1 | -1;
|
||||
current: PlayerId | null;
|
||||
phase: NinetyNineState['phase'];
|
||||
turnNo: number;
|
||||
round: number;
|
||||
drawPileCount: number;
|
||||
discardCount: number;
|
||||
/** Plays of this round, newest last (up to 6). */
|
||||
recent: Extract<LogEntry, { t: 'play' }>[];
|
||||
log: LogEntry[];
|
||||
eliminationOrder: PlayerId[];
|
||||
my: { hand: HandCardView[]; myTurn: boolean } | null;
|
||||
turnStartedAt: number;
|
||||
deadlineTotalMs: number;
|
||||
result: NNFinal | null;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
||||
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
|
||||
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
|
||||
{ id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true },
|
||||
];
|
||||
|
||||
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||
|
||||
Reference in New Issue
Block a user