/** 구십구 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 = { 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): { 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 = {}, 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 = { 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; }