/** 고스톱 패 정보 (docs/games/gostop.md §3.1): 화투 48장 + 보너스패. */ import { HWATU, hwatuBonus } from '@bg/engine'; export type CardId = number; export type CardKind = 'gwang' | 'yeol' | 'tti' | 'pi'; export type DanKind = 'hong' | 'cheong' | 'cho'; export interface CardInfo { id: CardId; /** 1..12, 0 for bonus cards. */ month: number; kind: CardKind; /** 피 값: 일반 피 1, 쌍피 2, 2피 보너스 2, 3피 보너스 3, 그 밖 0. */ piValue: 0 | 1 | 2 | 3; bi?: true; godori?: true; dan?: DanKind; gukjin?: true; bonus?: true; name: string; } export type BonusSetting = 'none' | '2+1' | '3+1'; export const BONUS_COUNT: Record = { none: 0, '2+1': 3, '3+1': 4 }; export const GUKJIN: CardId = 32; export const BI_GWANG: CardId = 44; function base(id: CardId): CardInfo { const c = HWATU[id]!; const kind: CardKind = c.kind === 'ssangpi' ? 'pi' : (c.kind as CardKind); const info: CardInfo = { id, month: c.month, kind, piValue: c.piValue as 0 | 1 | 2, name: c.name }; if (c.tags.includes('rain')) info.bi = true; if (c.tags.includes('godori')) info.godori = true; if (c.tags.includes('gukjin')) { info.gukjin = true; info.name = '9월 열끗·국진(국화)'; } if (c.tti && c.tti !== 'plain') info.dan = c.tti; return info; } const REGULAR: CardInfo[] = HWATU.map((c) => base(c.id)); const BONUS_TABLES: CardInfo[][] = [0, 1, 2, 3, 4].map((n) => hwatuBonus(n).map((b) => ({ id: b.id, month: 0, kind: 'pi' as const, piValue: b.piValue as 2 | 3, bonus: true as const, name: b.name })), ); /** Card info; `nb` = number of bonus cards in play (the last bonus id is the 3피). */ export function cardInfo(id: CardId, nb: number): CardInfo { if (id < 48) { const c = REGULAR[id]; if (!c) throw new Error(`gostop: unknown card ${id}`); return c; } const c = BONUS_TABLES[nb]?.[id - 48]; if (!c) throw new Error(`gostop: unknown bonus card ${id} (bonus ${nb})`); return c; } export const isBonus = (id: CardId): boolean => id >= 48; export const monthOf = (id: CardId): number => (id >= 48 ? 0 : Math.floor(id / 4) + 1); export function fullDeck(nb: number): CardId[] { return Array.from({ length: 48 + nb }, (_, i) => i); } /** Card value for automatic choices (§8.5): 광 > 열끗 > 띠 > 쌍피(피 값 2 이상) > 피. */ export function cardValue(id: CardId, nb: number): number { const c = cardInfo(id, nb); if (c.kind === 'gwang') return 5; if (c.kind === 'yeol') return 4; if (c.kind === 'tti') return 3; return c.piValue >= 2 ? 2 : 1; } /** Two floor cards of a month are interchangeable when kind and 피 값 are the same (§5.2). */ export function sameKind(a: CardId, b: CardId, nb: number): boolean { const x = cardInfo(a, nb); const y = cardInfo(b, nb); return x.kind === y.kind && x.piValue === y.piValue; } /** Best of two (or more) cards by value, ties → lower id. */ export function bestCard(ids: CardId[], nb: number): CardId { return [...ids].sort((a, b) => cardValue(b, nb) - cardValue(a, nb) || a - b)[0]!; } /** Display kind for the client (matches the shared HwatuCard component). */ export type ViewKind = 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus'; export interface CardView { /** Unique per round so leak tests can spot hidden cards. */ key: string; id: CardId; month: number; kind: ViewKind; tti?: 'hong' | 'cheong' | 'cho' | 'plain'; name: string; piValue: number; } export function cardView(id: CardId, nb: number, keyPrefix: string): CardView { const c = cardInfo(id, nb); const kind: ViewKind = c.bonus ? 'bonus' : c.kind === 'pi' && c.piValue >= 2 ? 'ssangpi' : c.kind; const out: CardView = { key: `${keyPrefix}c${id}`, id, month: c.month, kind, name: c.name, piValue: c.piValue }; if (c.kind === 'tti') out.tti = c.dan ?? 'plain'; return out; }