Files
joke-app/packages/games/src/ninety-nine/cards.ts
EJClaw c34497a445 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>
2026-10-05 14:55:36 +09:00

140 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 구십구 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;
}