M7: 구십구 규칙·화면·테스트

- 규칙 모듈 packages/games/src/ninety-nine: 전용 카드(1~8·10, ±9·±10·0, 조커)와
  트럼프(전통 99) 두 규칙, 모든 옵션 한국어 메타. 낼 카드가 없으면 같은 apply 안에서
  탈락/목숨 감소/새 판을 연쇄 처리, 시간 초과는 결과 합계가 가장 작은 카드를 자동으로 냄,
  연속 3번 시간 초과면 마감 3초, 나가면 탈락 처리. view는 남의 손패·더미를 숨기고
  카드별 결과 합계 미리보기와 선택지(+/−, A 1/11, 조커 범위)를 계산해 줌.
- 테스트: 문서 10절 시나리오 전부, 무작위 1,000판(보존·유출·onTimeout·결정성)과
  엔진 randomPlayout 18판.
- 화면 apps/web/src/games/ninety-nine: 큰 합계와 게이지 링, 방향 표시, 목숨 하트,
  카드 위 결과 미리보기, ± 두 버튼, 조커 숫자 다이얼, 키보드·스크린리더 지원.
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 1 + 휴대폰 2, 끝까지 진행).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:55:36 +09:00
parent 559fb8507c
commit c34497a445
10 changed files with 2379 additions and 0 deletions

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