Files
joke-app/packages/games/src/gostop/cards.ts
EJClaw f5e4a7ce59 M5: 고스톱·맞고 규칙·화면·테스트
- packages/games/src/gostop: 화투 48장 + 보너스패(cards), 족보·국진·최종 공식·박·독박 정산(scoring),
  패 돌리기·바닥 정리·광팔기·총통·먹기 판정(쪽/뻑/따닥/쓸/폭탄/흔들기/보너스패)·피 뺏기·고/스톱·나가리·
  첫뻑/연뻑/삼뻑(round), validate·view(남의 손패·더미 숨김, 매칭/점수 미리 계산)·시간 초과·나가기(index).
  2인 맞고, 3인 고스톱, 4~5인 광팔기, 여러 판 세트(방 점수 제로섬).
- 테스트: 문서 §10 체크리스트 전부, 1,000 시드 무작위 진행(카드·점수 보존, onTimeout 합법),
  engine randomPlayout(누출·JSON·결정성), 정보 누출 속성 테스트.
- 화면 apps/web/src/games/gostop: 월별 바닥, 매칭 하이라이트, 두 장 중 고르기, 흔들기·폭탄·고/스톱·총통·
  국진·광팔기 선택, 먹은 패 종류별 묶음과 족보 진행, 이벤트 알림, 판 결과 계산 과정, PC·휴대폰·다크 모드.
- HwatuCard: 보너스패(2피·3피) 그림 추가. 게임 목록·레지스트리 등록, 홈 "준비 중"에서 고스톱 제거.
- E2E: 맞고 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 고·스톱까지 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:35:21 +09:00

109 lines
3.8 KiB
TypeScript

/** 고스톱 패 정보 (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<BonusSetting, number> = { 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;
}