구십구: 코리아보드게임즈 공식 규칙으로 맞춤
- 공식 상품 페이지 규칙 설명 이미지 기준: 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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user