Merge branch 'm7/ninety-nine' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-05 14:56:13 +09:00
10 changed files with 2379 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ import { splendor } from './splendor';
import { quoridor } from './quoridor';
import { lasVegas } from './las-vegas';
import { cockroachPoker } from './cockroach-poker';
import { ninetyNine } from './ninety-nine';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -45,4 +46,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[quoridor.id]: quoridor,
[lasVegas.id]: lasVegas,
[cockroachPoker.id]: cockroachPoker,
[ninetyNine.id]: ninetyNine,
};

View 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;
}

View 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)];
},
};

View 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);
});

View 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;
}