구십구: 코리아보드게임즈 공식 규칙으로 맞춤

- 공식 상품 페이지 규칙 설명 이미지 기준: 5장씩, 시작 카드(2명 40·3명 20·4명 이상 0),
  0 카드는 방향 유지/반대 선택, 칩(목숨)은 공식 규칙이 아니라 하우스 룰
- handSize 'auto'(전용 카드 5, 트럼프 3), startTotal 옵션, 카드 종류 'zero'와 dir 액션
- stateVersion 2, 진행 중인 v1 게임은 옛 규칙 그대로 이어서 하도록 migrate
- 특수 카드 장수(±9·±10·0)는 공개 자료가 없어 각 4장 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 22:16:32 +09:00
parent d8b8cdd8f3
commit 7da06d1b27
10 changed files with 325 additions and 119 deletions

View File

@@ -1,6 +1,6 @@
/**
* 구십구 computer player: plays plain number cards while they still fit (biggest first, they go dead as the total
* climbs) and saves the "safe" cards — 0/reverse, 9/hold, −10, ±9/±10, K, joker — for when the total is high.
* climbs) and saves the "safe" cards — 0, 4/reverse, 9/hold, −10, ±9/±10, K, joker — for when the total is high.
* A forced safe card picks the cheapest one; ± goes down near 99, and K / the joker push the next player to 99 only
* when I still hold another safe card myself.
*/
@@ -9,13 +9,13 @@ import { best, pick, playable } from '../bots/policy';
import type { CardKind, HandCardView, NinetyNineView, NNAction, PlayAction } from './types';
/** How much a card is worth keeping: it can be played on (almost) any total. */
const KEEP: Record<CardKind, number> = { add: 0, ace: 0, reverse: 4, hold: 4, minus: 5, plusMinus: 5, to99: 6, joker: 8 };
const KEEP: Record<CardKind, number> = { add: 0, ace: 0, reverse: 4, zero: 4, hold: 4, minus: 5, plusMinus: 5, to99: 6, joker: 8 };
const isSafe = (c: HandCardView) => KEEP[c.kind] > 0;
/** Total after `a` (null if it cannot be worked out from the view). */
function totalAfter(a: PlayAction, c: HandCardView): number | null {
if (c.kind === 'joker') return a.jokerValue ?? null;
const ch = c.choices.find((x) => x.legal && x.sign === a.sign && x.aceValue === a.aceValue);
const ch = c.choices.find((x) => x.legal && x.sign === a.sign && x.aceValue === a.aceValue && x.dir === a.dir);
return ch ? ch.total : null;
}

View File

@@ -12,7 +12,7 @@ export function krDeck(specialCounts: number): NNCard[] {
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 < specialCounts; i++) out.push({ id: id(), kind: 'zero', face: { label: '0' } });
for (let i = 0; i < 2; i++) out.push({ id: id(), kind: 'joker', face: { label: '조커' } });
return out;
}
@@ -43,6 +43,8 @@ export const jokerRange = (o: Pick<NNOptions, 'jokerRange'>): { min: number; max
export const OVER = '99를 넘어서 낼 수 없어요';
export const UNDER = '0보다 작아져서 뺄 수 없어요';
export const PICK_DIR = '0 카드는 방향을 그대로 둘지 바꿀지 골라 주세요';
export const DIR_ONLY_ZERO = '방향은 0 카드를 낼 때만 고를 수 있어요';
function choice(label: string, total: number, o: NNOptions, extra: Partial<ChoiceView> = {}, minus = false): ChoiceView {
let t = total;
@@ -63,6 +65,8 @@ export function choicesFor(c: NNCard, s: number, o: NNOptions): ChoiceView[] {
return [choice(`−${c.v}`, s - c.v, o, {}, true)];
case 'reverse':
return [choice('방향 바꾸기', s, o)];
case 'zero':
return [choice('방향 그대로', s, o, { dir: 'keep' }), choice('방향 바꾸기', s, o, { dir: 'reverse' })];
case 'hold':
return [choice('그대로', s, o)];
case 'to99':
@@ -81,15 +85,16 @@ export function playable(c: NNCard, s: number, o: NNOptions): boolean {
/** 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;
const extra = (k: 'sign' | 'aceValue' | 'jokerValue' | 'dir') => a[k] !== undefined;
if (c.kind === 'joker') {
if (extra('sign') || extra('aceValue')) return { ok: false, reason: '조커는 숫자만 골라 주세요' };
if (extra('sign') || extra('aceValue') || extra('dir')) 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: '숫자는 조커를 낼 때만 고를 수 있어요' };
if (c.kind !== 'zero' && extra('dir')) return { ok: false, reason: DIR_ONLY_ZERO };
const options = choicesFor(c, s, o);
let pick: ChoiceView | undefined;
if (c.kind === 'plusMinus') {
@@ -101,6 +106,9 @@ export function resolvePlay(c: NNCard, s: number, o: NNOptions, a: PlayAction):
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 (c.kind === 'zero') {
if (extra('sign') || extra('aceValue') || !a.dir) return { ok: false, reason: PICK_DIR };
pick = options.find((x) => x.dir === a.dir);
} else {
if (extra('sign') || extra('aceValue')) return { ok: false, reason: '이 카드는 고를 것 없이 바로 내요' };
pick = options[0];
@@ -111,11 +119,21 @@ export function resolvePlay(c: NNCard, s: number, o: NNOptions, a: PlayAction):
}
/** §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 };
const GROUP: Record<NNCard['kind'], number> = { reverse: 0, zero: 0, hold: 0, add: 1, minus: 1, ace: 1, plusMinus: 2, to99: 3, joker: 3 };
/** The `play` action for one of `choicesFor`'s choices. */
export const actionFor = (cardId: string, x: ChoiceView): PlayAction => ({
type: 'play',
cardId,
...(x.sign ? { sign: x.sign } : {}),
...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}),
...(x.dir ? { dir: x.dir } : {}),
});
/**
* 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.
* then card id, then the choice order (so a 0 card keeps the direction). 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;
@@ -127,7 +145,7 @@ export function safestPlay(hand: NNCard[], s: number, o: NNOptions): PlayAction
} 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 });
cands.push({ a: actionFor(c.id, x), total: x.total });
}
}
for (const k of cands) {

View File

@@ -1,6 +1,6 @@
/** 구십구 (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 { actionFor, choicesFor, classicDeck, jokerRange, krDeck, playable, resolvePlay, safestPlay } from './cards';
import {
ninetyNineActionSchema,
ninetyNineOptionsSchema,
@@ -56,6 +56,14 @@ function cloneState(s: NinetyNineState): NinetyNineState {
}
export const resolvedLives = (o: Pick<NNOptions, 'mode' | 'lives'>): number => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
/** Cards dealt to each player: `auto` is 5 with the dedicated cards (official rules), 3 with trump cards. */
export const resolvedHandSize = (o: Pick<NNOptions, 'mode' | 'handSize'>): number => (o.handSize === 'auto' ? (o.mode === 'classic' ? 3 : 5) : o.handSize);
/**
* Starting total of a round dealt to `players` people. The dedicated game's start card shows 40 for 2 players,
* 20 for 3 and 0 for 4 or more; trump cards (and `startTotal = zero`) always start at 0.
*/
export const resolvedStartTotal = (o: Pick<NNOptions, 'mode' | 'startTotal'>, players: number): number =>
o.mode === 'kr-product' && o.startTotal === 'auto' ? (players <= 2 ? 40 : players === 3 ? 20 : 0) : 0;
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[] =>
@@ -84,23 +92,24 @@ function nextAlive(s: NinetyNineState, from: PlayerId, dir: 1 | -1): PlayerId {
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). */
/** Collects every card, shuffles, deals the hand size to everyone still in from `starter` clockwise; total back to the start number (§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;
const n = aliveSeats(s).length;
s.total = resolvedStartTotal(s.options, n);
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) {
for (let k = 0, p = starter; k < n; k++, p = nextAlive(s, p, 1)) order.push(p);
for (let r = 0, h = resolvedHandSize(s.options); r < h; 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 });
ctx.events.push({ type: 'roundStarted', round: s.round, starter, total: s.total });
log(s, { t: 'round', round: s.round, starter, total: s.total });
}
function eliminate(s: NinetyNineState, p: PlayerId, why: 'noCard' | 'left', ctx: Ctx): void {
@@ -207,10 +216,10 @@ function playCard(s: NinetyNineState, p: PlayerId, a: PlayAction, ctx: Ctx, auto
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;
const choice = card.kind === 'plusMinus' || card.kind === 'ace' || card.kind === 'joker' || card.kind === 'zero' ? 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') {
if (card.kind === 'reverse' || (card.kind === 'zero' && a.dir === 'reverse')) {
s.direction = s.direction === 1 ? -1 : 1;
ctx.events.push({ type: 'directionChanged', direction: s.direction });
}
@@ -272,7 +281,8 @@ export const ninetyNine: Def = {
nameKo: '구십구',
minPlayers: 2,
maxPlayers: 10,
stateVersion: 1,
// 2: handSize `auto`, startTotal, and the dedicated 0 card (`zero`) with a chosen direction.
stateVersion: 2,
defaultOptions: ninetyNineOptionsSchema.parse({}),
optionsSchema: ninetyNineOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: ninetyNineActionSchema as unknown as Def['actionSchema'],
@@ -290,7 +300,7 @@ export const ninetyNine: Def = {
hands: {},
drawPile: [],
discard: [],
total: 0,
total: resolvedStartTotal(options, players.length),
direction: 1,
current: players[0]!,
alive: rec(true),
@@ -318,7 +328,7 @@ export const ninetyNine: Def = {
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()!);
for (let r = 0, h = resolvedHandSize(options); r < h; 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;
@@ -421,6 +431,16 @@ export const ninetyNine: Def = {
return { type: 'timeout' };
},
/**
* v1 → v2 for games in progress: they keep playing by the rules they started with — the hand size they were
* dealt, start number 0 for later rounds, and 0 cards that always reverse (their kind stays `reverse`).
*/
migrate(old, fromVersion) {
const s = old as NinetyNineState;
if (fromVersion >= 2) return s;
return { ...s, options: ninetyNineOptionsSchema.parse({ ...s.options, startTotal: 'zero' }) };
},
onLeave(s, player) {
if (s.phase !== 'playing' || !s.seats.includes(player) || !s.alive[player]) return null;
return { type: 'leave' };
@@ -444,10 +464,7 @@ export const ninetyNine: Def = {
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 } : {}) });
}
for (const x of choicesFor(c, s.total, s.options)) if (x.legal) out.push(actionFor(c.id, x));
}
return out;
},

View File

@@ -6,6 +6,8 @@ import {
fullDeck,
krDeck,
ninetyNine,
resolvedHandSize,
resolvedStartTotal,
safestPlay,
type NNAction,
type NNCard,
@@ -82,7 +84,8 @@ describe('덱 구성과 셋업 (§3, §4)', () => {
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 === 'zero' && c.face.label === '0')).toBe(4);
expect(count((c) => c.kind === 'reverse')).toBe(0);
expect(count((c) => c.kind === 'joker')).toBe(2);
expect(krDeck(2)).toHaveLength(85);
expect(new Set(d.map((c) => c.id)).size).toBe(91);
@@ -103,19 +106,50 @@ describe('덱 구성과 셋업 (§3, §4)', () => {
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', () => {
test('same seed → same deal and first player; 5 cards each (auto); 4 players start at 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);
for (const p of a.seats) expect(a.hands[p]).toHaveLength(5);
expect(a.total).toBe(0);
expect(cardTotal(a)).toBe(91);
expect(a.drawPile).toHaveLength(91 - 4 * 5);
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);
expect(make({ handSize: 3 }).hands.A).toHaveLength(3);
});
test('handSize auto: 5 with the dedicated cards (official rules), 3 with trump cards; 3/4/5 still selectable', () => {
expect(ninetyNine.defaultOptions.handSize).toBe('auto');
expect(resolvedHandSize(ninetyNine.defaultOptions)).toBe(5);
expect(resolvedHandSize({ mode: 'classic', handSize: 'auto' })).toBe(3);
for (const p of ['A', 'B', 'C']) {
expect(make().hands[p]).toHaveLength(5);
expect(make({ mode: 'classic' }).hands[p]).toHaveLength(3);
expect(make({ handSize: 4 }).hands[p]).toHaveLength(4);
expect(make({ mode: 'classic', handSize: 5 }).hands[p]).toHaveLength(5);
}
expect(make({}, ['A', 'B']).drawPile).toHaveLength(91 - 10);
expect(make({ mode: 'classic' }, ['A', 'B']).drawPile).toHaveLength(52 - 6);
});
test('start card: 2 players start at 40, 3 at 20, 4 or more at 0; startTotal=zero and trump cards always 0', () => {
expect(make({}, ['A', 'B']).total).toBe(40);
expect(make({}, ['A', 'B', 'C']).total).toBe(20);
expect(make({}, ['A', 'B', 'C', 'D']).total).toBe(0);
expect(make({}, Array.from({ length: 10 }, (_, i) => `P${i}`)).total).toBe(0);
expect(make({ startTotal: 'zero' }, ['A', 'B']).total).toBe(0);
expect(make({ startTotal: 'zero' }, ['A', 'B', 'C']).total).toBe(0);
expect(make({ mode: 'classic' }, ['A', 'B']).total).toBe(0);
expect(make({ mode: 'classic' }, ['A', 'B', 'C']).total).toBe(0);
expect([2, 3, 4, 7].map((n) => resolvedStartTotal(ninetyNine.defaultOptions, n))).toEqual([40, 20, 0, 0]);
// The view and the first turn use it: nobody has played yet.
const v = ninetyNine.view(make({}, ['A', 'B']), 'A');
expect([v.total, v.recent.length]).toEqual([40, 0]);
});
});
@@ -176,20 +210,53 @@ describe('낼 수 있는 카드 (§5.2, §10)', () => {
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'));
test('0 card: 3 players, B’s 0 keeping the direction passes to C; reversing it gives A the next turn', () => {
const base = () => rig(make(), { hands: { A: ['1', '2'], B: ['0', '3'], C: ['4', '5'] }, total: 30, current: 'B' });
let s = base();
expect(ninetyNine.view(s, 'B').my!.hand[0]!.choices).toEqual([
{ label: '방향 그대로', total: 30, legal: true, dir: 'keep' },
{ label: '방향 바꾸기', total: 30, legal: true, dir: 'reverse' },
]);
const keep = actE(s, 'B', play(s, 'B', '0', { dir: 'keep' }));
expect([keep.state.total, keep.state.direction, keep.state.current]).toEqual([30, 1, 'C']);
expect(types(keep.events)).not.toContain('directionChanged');
expect(keep.events[0]).toMatchObject({ type: 'cardPlayed', kind: 'zero', choice: '방향 그대로' });
expect(keep.state.log.at(-1)).toMatchObject({ t: 'play', face: { label: '0' }, kind: 'zero', from: 30, total: 30, choice: '방향 그대로' });
s = base();
const out = actE(s, 'B', play(s, 'B', '0', { dir: 'reverse' }));
s = out.state;
expect(s.total).toBe(30);
expect(s.direction).toBe(-1);
expect(s.current).toBe('A');
expect([s.total, s.direction, s.current]).toEqual([30, -1, 'A']);
expect(types(out.events)).toContain('directionChanged');
expect(s.log.at(-1)).toMatchObject({ t: 'play', kind: 'zero', choice: '방향 바꾸기' });
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'));
two = act(two, 'B', play(two, 'B', '0', { dir: 'reverse' }));
expect(two.direction).toBe(-1);
expect(two.current).toBe('A');
// Both ways are offered to the test helper (and so to bots).
const legal = ninetyNine.legalActions!(base(), 'B').filter((a) => a.type === 'play' && a.cardId === idOf(base(), 'B', '0'));
expect(legal.map((a) => (a as PlayAction).dir)).toEqual(['keep', 'reverse']);
});
test('0 card needs a direction; a direction on any other card is refused; trump 4 still always reverses', () => {
const s = rig(make(), { hands: { A: ['0', '3', '±9', '조커'], B: ['1'], C: ['2'] }, total: 30 });
const snap = JSON.stringify(s);
expect(reject(s, 'A', play(s, 'A', '0'))).toBe('0 카드는 방향을 그대로 둘지 바꿀지 골라 주세요');
expect(reject(s, 'A', play(s, 'A', '0', { sign: '+' }))).toBe('0 카드는 방향을 그대로 둘지 바꿀지 골라 주세요');
expect(reject(s, 'A', play(s, 'A', '3', { dir: 'keep' }))).toBe('방향은 0 카드를 낼 때만 고를 수 있어요');
expect(reject(s, 'A', play(s, 'A', '±9', { sign: '+', dir: 'reverse' }))).toBe('방향은 0 카드를 낼 때만 고를 수 있어요');
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 70, dir: 'keep' }))).toBe('조커는 숫자만 골라 주세요');
expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', dir: 'left' }).success).toBe(false);
expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', dir: 'keep' }).success).toBe(true);
expect(JSON.stringify(s)).toBe(snap);
const o = { mode: 'classic' as const, lives: 0 as const };
const four = rig(make(o), { hands: { A: ['4', '2'], B: ['3'], C: ['3'] }, total: 50 });
expect(reject(four, 'A', play(four, 'A', '4', { dir: 'keep' }))).toBe('방향은 0 카드를 낼 때만 고를 수 있어요');
const f = actE(four, 'A', play(four, 'A', '4'));
expect([f.state.total, f.state.direction, f.state.current]).toEqual([50, -1, 'C']);
expect(f.state.log.at(-1)).not.toHaveProperty('choice');
expect(ninetyNine.view(four, 'A').my!.hand[0]!.choices).toEqual([{ label: '방향 바꾸기', total: 50, legal: true }]);
});
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', () => {
@@ -235,7 +302,7 @@ describe('낼 수 있는 카드 (§5.2, §10)', () => {
// ---------------------------------------------------------------- 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', () => {
test('lives=3: the loser loses one life, a new round starts at the start number 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;
@@ -243,8 +310,11 @@ describe('패배 처리 (§5.3, §10)', () => {
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);
// Still 3 players: the new round starts at 20 (start card), 5 cards each.
expect([s.total, s.round, s.current, s.direction]).toEqual([20, 2, 'B', 1]);
expect(out.events.at(-1)).toEqual({ type: 'roundStarted', round: 2, starter: 'B', total: 20 });
expect(s.log.at(-1)).toMatchObject({ t: 'round', round: 2, starter: 'B', total: 20 });
for (const p of s.seats) expect(s.hands[p]).toHaveLength(5);
expect(s.discard).toEqual([]);
expect(cardTotal(s)).toBe(91);
// B on the last life loses again → out; C (next after B) starts round 3.
@@ -254,9 +324,10 @@ describe('패배 처리 (§5.3, §10)', () => {
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']);
// Two left: round 3 starts at 40.
expect([s.total, s.round, s.current]).toEqual([40, 3, 'C']);
expect(s.hands.B).toEqual([]);
expect(s.hands.A).toHaveLength(3);
expect(s.hands.A).toHaveLength(5);
expect(s.eliminatedAtRound.B).toBe(2);
});
@@ -281,14 +352,20 @@ describe('패배 처리 (§5.3, §10)', () => {
expect(cardTotal(s)).toBe(91);
});
test('reset after an elimination: total back to 0, everyone redealt, the next player starts', () => {
test('reset after an elimination: total back to the start number for those left, 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([s.total, s.current, s.round]).toEqual([40, 'C', 2]);
expect(s.hands.A).toHaveLength(5);
expect(s.hands.C).toHaveLength(5);
expect(cardTotal(s)).toBe(91);
let z = rig(make({ afterElimination: 'reset', startTotal: 'zero', handSize: 3 }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
z = act(z, 'A', play(z, 'A', '4'));
expect([z.total, z.hands.A!.length]).toEqual([0, 3]);
let cls = rig(make({ mode: 'classic', lives: 0, afterElimination: 'reset' }), { hands: { A: ['2'], B: ['5', '6', '7'], C: ['3'] }, total: 95 });
cls = act(cls, 'A', play(cls, 'A', '2'));
expect([cls.alive.B, cls.total, cls.hands.A!.length]).toEqual([false, 0, 3]);
});
test('last one standing wins: ranking by elimination order, Korean summary, lives lost in the result', () => {
@@ -335,14 +412,21 @@ describe('더미 소진 (§5.1)', () => {
// ---------------------------------------------------------------- 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)', () => {
test('timeout plays the smallest resulting total: [10, 3, 0] at 50 → 0 keeping the direction; [±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 auto = safestPlay(s.hands.A!, 50, s.options)!;
expect(auto).toEqual({ type: 'play', cardId: idOf(s, 'A', '0'), dir: 'keep' });
expect(reject(s, 'A', auto)).toBeNull();
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 });
expect([out.state.total, out.state.direction, out.state.current]).toEqual([50, 1, 'B']);
expect(out.state.log.at(-1)).toMatchObject({ t: 'play', face: { label: '0' }, kind: 'zero', choice: '방향 그대로', auto: true });
// Same move from a reversed direction: it stays reversed.
s = rig(make(), { hands: { A: ['10', '3', '0'], B: ['1'], C: ['1'] }, total: 50, direction: -1 });
const back = act(s, 'A', { type: 'timeout' });
expect([back.direction, back.current]).toEqual([-1, 'C']);
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.
@@ -350,7 +434,7 @@ describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => {
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]);
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') });
});
@@ -443,7 +527,7 @@ describe('검증과 정보 유출 (§8.3, §10)', () => {
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 });
expect(d).toMatchObject({ mode: 'kr-product', handSize: 'auto', startTotal: 'auto', 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();
@@ -455,6 +539,29 @@ describe('검증과 정보 유출 (§8.3, §10)', () => {
});
});
// ---------------------------------------------------------------- saved games
describe('saved games (stateVersion 1 → 2)', () => {
test('a v1 game in progress keeps its rules: dealt hand size, later rounds from 0, its 0 cards always reverse', () => {
expect(ninetyNine.stateVersion).toBe(2);
// A v1 save: options without startTotal and a numeric handSize, the dedicated 0 card stored as `reverse`.
const cur = rig(make({ lives: 3, handSize: 3 }), { hands: { A: ['4', '0'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
const { startTotal: _drop, ...oldOptions } = cur.options;
const asV1 = (c: NNCard) => (c.kind === 'zero' ? { ...c, kind: 'reverse' } : c);
const v1 = JSON.parse(JSON.stringify({ ...cur, options: oldOptions, hands: Object.fromEntries(Object.entries(cur.hands).map(([p, h]) => [p, h.map(asV1)])), drawPile: cur.drawPile.map(asV1) }));
const m = ninetyNine.migrate!(v1, 1);
expect(m.options).toEqual({ ...cur.options, startTotal: 'zero' });
expect(ninetyNine.migrate!(cur, 2)).toBe(cur);
// Its old 0 card still reverses without a direction.
const zero = m.hands.A!.find((c) => c.face.label === '0')!;
expect(zero.kind).toBe('reverse');
expect(act(m, 'A', { type: 'play', cardId: zero.id }).direction).toBe(-1);
// B loses a life: the new round starts at 0 with 3 cards, as the game began.
const next = act(m, 'A', play(m, 'A', '4'));
expect([next.round, next.total, next.hands.B!.length]).toEqual([2, 0, 3]);
});
});
// ---------------------------------------------------------------- random playouts
interface Report {
@@ -547,6 +654,7 @@ const PRESETS: Partial<NNOptions>[] = [
{ lives: 3 },
{ afterElimination: 'reset', floorAtZero: 'clamp', specialCounts: 6 },
{ refill: false, handSize: 5 },
{ handSize: 3, startTotal: 'zero', lives: 2 },
{ jokerRange: '0-99', handSize: 4, lives: 2, onTheBoard: true },
{ mode: 'classic' },
{ mode: 'classic', lives: 0, aceHigh: 10 },

View File

@@ -10,7 +10,13 @@ export const ninetyNineOptionsSchema = z.object({
.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장' } }),
handSize: lit<'auto' | 3 | 4 | 5>('auto', 3, 4, 5)
.default('auto')
.meta({ title: '손패 장수', labels: { auto: '규칙 기본(전용 카드 5장, 트럼프 3장)', 3: '3장', 4: '4장', 5: '5장' } }),
startTotal: z
.enum(['auto', 'zero'])
.default('auto')
.meta({ title: '(전용 카드) 시작 숫자', labels: { auto: '시작 카드(2명 40 · 3명 20 · 4명 이상 0)', zero: '항상 0' } }),
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')
@@ -25,7 +31,7 @@ export const ninetyNineOptionsSchema = z.object({
afterElimination: z
.enum(['continue', 'reset'])
.default('continue')
.meta({ title: '(목숨 없음) 탈락 뒤 합계', labels: { continue: '그대로 이어 가기', reset: '0으로 하고 다시 나누기' } }),
.meta({ title: '(목숨 없음) 탈락 뒤 합계', labels: { continue: '그대로 이어 가기', reset: '시작 숫자로 하고 다시 나누기' } }),
floorAtZero: z
.enum(['forbid', 'clamp'])
.default('forbid')
@@ -54,6 +60,8 @@ export const ninetyNineActionSchema = z.object({
sign: z.enum(['+', '-']).optional(),
aceValue: lit(1, 10, 11).optional(),
jokerValue: z.number().int().min(0).max(99).optional(),
/** Dedicated 0 card only: keep the turn direction or reverse it. */
dir: z.enum(['keep', 'reverse']).optional(),
});
export type PlayAction = z.infer<typeof ninetyNineActionSchema>;
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
@@ -72,7 +80,10 @@ export interface CardFace {
export type NNCard =
| { id: string; kind: 'add'; v: number; face: CardFace }
| { id: string; kind: 'plusMinus'; v: 9 | 10; face: CardFace }
/** Classic 4: always reverses. */
| { id: string; kind: 'reverse'; face: CardFace }
/** Dedicated 0: no change; the player keeps or reverses the direction. */
| { id: string; kind: 'zero'; face: CardFace }
| { id: string; kind: 'hold'; face: CardFace }
| { id: string; kind: 'minus'; v: 10; face: CardFace }
| { id: string; kind: 'ace'; face: CardFace }
@@ -86,7 +97,8 @@ export type LogEntry =
| { 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 }
/** `total` is the round's starting number (missing in logs saved before stateVersion 2, where it was 0). */
| { seq: number; t: 'round'; round: number; starter: PlayerId; total?: number }
| { seq: number; t: 'reshuffle'; n: number }
| { seq: number; t: 'timeout'; p: PlayerId };
@@ -147,6 +159,7 @@ export interface ChoiceView {
label: string;
sign?: '+' | '-';
aceValue?: 1 | 10 | 11;
dir?: 'keep' | 'reverse';
/** Total after playing (may be over 99 or below 0 when not legal). */
total: number;
legal: boolean;