- 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>
109 lines
3.8 KiB
TypeScript
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;
|
|
}
|