- 규칙 모듈 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>
140 lines
7.3 KiB
TypeScript
140 lines
7.3 KiB
TypeScript
/** 구십구 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;
|
||
}
|