Merge branch 'm5/gostop' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # packages/games/src/index.ts
This commit is contained in:
@@ -145,7 +145,13 @@ export function HwatuCard({
|
|||||||
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}>
|
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}>
|
||||||
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
|
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
|
||||||
<rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
|
<rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
|
||||||
<Motif month={card.month} color={color} />
|
{card.kind === 'bonus' ? (
|
||||||
|
<text x="50" y="102" fontSize="34" fontWeight="900" fill="#b45309" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
|
||||||
|
{card.piValue ?? 2}피
|
||||||
|
</text>
|
||||||
|
) : (
|
||||||
|
<Motif month={card.month} color={color} />
|
||||||
|
)}
|
||||||
{card.kind === 'tti' && (
|
{card.kind === 'tti' && (
|
||||||
<g>
|
<g>
|
||||||
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
|
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
|
||||||
@@ -170,7 +176,7 @@ export function HwatuCard({
|
|||||||
{card.month || '+'}
|
{card.month || '+'}
|
||||||
</text>
|
</text>
|
||||||
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
|
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
|
||||||
{MONTH_NAME[card.month]} · {card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
|
{MONTH_NAME[card.month]} · {card.kind === 'bonus' ? `${card.piValue ?? 2}피` : card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
1174
apps/web/src/games/gostop/index.tsx
Normal file
1174
apps/web/src/games/gostop/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
rummikub: () => import('./rummikub'),
|
rummikub: () => import('./rummikub'),
|
||||||
'davinci-code': () => import('./davinci-code'),
|
'davinci-code': () => import('./davinci-code'),
|
||||||
mahjong: () => import('./mahjong'),
|
mahjong: () => import('./mahjong'),
|
||||||
|
gostop: () => import('./gostop'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export const GAME_ICON: Record<string, string> = {
|
|||||||
'one-card': '♣',
|
'one-card': '♣',
|
||||||
rummikub: '🔢',
|
rummikub: '🔢',
|
||||||
'davinci-code': '🔐',
|
'davinci-code': '🔐',
|
||||||
|
gostop: '🌸',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
export const UPCOMING = ['고스톱', '뱅', '할리갈리'];
|
export const UPCOMING = ['뱅', '할리갈리'];
|
||||||
|
|||||||
105
e2e/games.e2e.ts
105
e2e/games.e2e.ts
@@ -150,7 +150,112 @@ async function yutStep(p: Page): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고스톱: one UI step for `p` if it has something to do — answer an open decision sheet (고/스톱: 고 once, then 스톱;
|
||||||
|
* choose a floor card; 흔들기/폭탄; 총통 → 그냥 치기; 국진) or play a hand card (green-bordered match first; phones
|
||||||
|
* use tap-to-lift, so a lifted card is tapped again). Returns what it did, or null.
|
||||||
|
*/
|
||||||
|
async function gostopStep(p: Page, state: { go: number; stop: number; picks: number; plays: number; sheets: number }): Promise<string | null> {
|
||||||
|
const dialog = p.getByRole('dialog');
|
||||||
|
if (await dialog.count()) {
|
||||||
|
const name = (await dialog.first().getAttribute('aria-label')) ?? '';
|
||||||
|
if (name === '게임 결과') return null;
|
||||||
|
if (name === '고 또는 스톱') {
|
||||||
|
const goFirst = state.go === 0;
|
||||||
|
if (goFirst && state.stop === 0) await p.screenshot({ path: join(SHOTS, 'gostop-gostop-dialog.png') });
|
||||||
|
await dialog.getByRole('button', { name: goFirst ? /^고/ : /^스톱/ }).click({ timeout: 1500 });
|
||||||
|
if (goFirst) state.go++;
|
||||||
|
else state.stop++;
|
||||||
|
return goFirst ? 'go' : 'stop';
|
||||||
|
}
|
||||||
|
if (name === '총통') {
|
||||||
|
await dialog.getByRole('button', { name: /^그냥 치기/ }).click({ timeout: 1500 });
|
||||||
|
return 'chongtong';
|
||||||
|
}
|
||||||
|
const pick = dialog.locator('.gs-pick button, .gs-big .btn').first();
|
||||||
|
if (await pick.count()) {
|
||||||
|
if (state.sheets === 0) await p.screenshot({ path: join(SHOTS, 'gostop-choice-dialog.png') });
|
||||||
|
await pick.click({ timeout: 1500 });
|
||||||
|
state.sheets++;
|
||||||
|
if (name.includes('고르기')) state.picks++;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
|
||||||
|
if (!status.includes('내 차례예요')) return null;
|
||||||
|
const card = p.locator('.gs-hcard[data-match="true"]:not([disabled])').first();
|
||||||
|
const any = p.locator('.gs-hcard:not([disabled])').first();
|
||||||
|
const target = (await card.count()) ? card : any;
|
||||||
|
if (!(await target.count())) {
|
||||||
|
const dummy = p.getByRole('button', { name: /^빈패 내기/ });
|
||||||
|
if (await dummy.isEnabled().catch(() => false)) {
|
||||||
|
await dummy.click({ timeout: 1500 });
|
||||||
|
state.plays++;
|
||||||
|
return 'dummy';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = await target.getAttribute('aria-label');
|
||||||
|
await target.click({ timeout: 1500 });
|
||||||
|
const lifted = p.locator(`.gs-hcard[data-lifted="true"][aria-label="${label}"]`);
|
||||||
|
if (await lifted.count()) await lifted.click({ timeout: 1500 });
|
||||||
|
state.plays++;
|
||||||
|
return 'play';
|
||||||
|
}
|
||||||
|
|
||||||
const PLANS: Record<string, Plan> = {
|
const PLANS: Record<string, Plan> = {
|
||||||
|
gostop: {
|
||||||
|
// 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices,
|
||||||
|
// 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows.
|
||||||
|
lobby: async (host) => {
|
||||||
|
await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click();
|
||||||
|
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||||
|
await host.getByText(/^1판 · /).first().waitFor();
|
||||||
|
},
|
||||||
|
drive: async (pages) => {
|
||||||
|
const [host, guest] = pages as [Page, Page];
|
||||||
|
const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 };
|
||||||
|
let shot = false;
|
||||||
|
const until = Date.now() + 240_000;
|
||||||
|
const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0;
|
||||||
|
while (Date.now() < until && !(await over())) {
|
||||||
|
let acted = false;
|
||||||
|
for (const p of pages) {
|
||||||
|
const did = await gostopStep(p, state).catch(() => null);
|
||||||
|
if (did) {
|
||||||
|
acted = true;
|
||||||
|
await p.waitForTimeout(250);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.plays >= 8 && !shot) {
|
||||||
|
shot = true;
|
||||||
|
await host.waitForTimeout(1500);
|
||||||
|
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true });
|
||||||
|
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await host.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true });
|
||||||
|
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') });
|
||||||
|
await guest.emulateMedia({ colorScheme: 'light' });
|
||||||
|
await host.emulateMedia({ colorScheme: 'light' });
|
||||||
|
}
|
||||||
|
if (!acted) await host.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`);
|
||||||
|
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||||
|
await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') });
|
||||||
|
await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') });
|
||||||
|
// Close the result sheet to see the round breakdown underneath.
|
||||||
|
await host.keyboard.press('Escape');
|
||||||
|
await host.getByText(/1판 결과/).first().waitFor();
|
||||||
|
await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') });
|
||||||
|
if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`);
|
||||||
|
console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
rummikub: {
|
rummikub: {
|
||||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||||
lobby: async (host) => {
|
lobby: async (host) => {
|
||||||
|
|||||||
108
packages/games/src/gostop/cards.ts
Normal file
108
packages/games/src/gostop/cards.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/** 고스톱 패 정보 (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;
|
||||||
|
}
|
||||||
956
packages/games/src/gostop/gostop.test.ts
Normal file
956
packages/games/src/gostop/gostop.test.ts
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { SeededRng, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine';
|
||||||
|
import {
|
||||||
|
autoPlay,
|
||||||
|
bestScore,
|
||||||
|
cardInfo,
|
||||||
|
cardsInRound,
|
||||||
|
gostop,
|
||||||
|
isBonus,
|
||||||
|
scoreOf,
|
||||||
|
settle,
|
||||||
|
type FloorGroup,
|
||||||
|
type GostopAction,
|
||||||
|
type GostopOptions,
|
||||||
|
type GostopState,
|
||||||
|
type SettleInput,
|
||||||
|
} from './index';
|
||||||
|
import { resolveRules } from './round';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- helpers
|
||||||
|
// Card ids (§3.1): month m → (m-1)*4 + slot. 1월 0광 1홍단 2·3피, 2월 4열(고도리) 5홍단, 3월 8광 9홍단,
|
||||||
|
// 4월 12열(고도리) 13초단, 5월 16열 17초단, 6월 20열 21청단, 7월 24열 25초단, 8월 28광 29열(고도리),
|
||||||
|
// 9월 32국진 33청단, 10월 36열 37청단, 11월 40광 41쌍피, 12월 44비광 45열 46띠 47쌍피.
|
||||||
|
|
||||||
|
const NAMES = ['A', 'B', 'C', 'D', 'E'];
|
||||||
|
const players = (n: number) => NAMES.slice(0, n);
|
||||||
|
let clock = 1000;
|
||||||
|
const rng = () => SeededRng.fromSeed([9, 9, 9, 9]);
|
||||||
|
const parse = (o: Partial<GostopOptions> = {}) => gostop.optionsSchema.parse(o);
|
||||||
|
|
||||||
|
/** Rng whose first shuffles return the given prefixes (rest appended in id order), then falls back to a seeded rng. */
|
||||||
|
function scripted(orders: number[][], dealer = 0): Rng {
|
||||||
|
const fallback = SeededRng.fromSeed([5, 6, 7, 8]);
|
||||||
|
let si = 0;
|
||||||
|
let first = true;
|
||||||
|
return {
|
||||||
|
next: () => fallback.next(),
|
||||||
|
int: (n) => {
|
||||||
|
if (first) {
|
||||||
|
first = false;
|
||||||
|
return dealer % n;
|
||||||
|
}
|
||||||
|
return fallback.int(n);
|
||||||
|
},
|
||||||
|
shuffle: <T,>(arr: readonly T[]): T[] => {
|
||||||
|
const o = orders[si++] as unknown as T[] | undefined;
|
||||||
|
if (!o) return fallback.shuffle(arr);
|
||||||
|
return [...o, ...arr.filter((x) => !o.includes(x))];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Spec {
|
||||||
|
hands: number[][];
|
||||||
|
floor?: number[];
|
||||||
|
piles?: Record<number, { ppeokBy?: number | null; natural?: boolean }>;
|
||||||
|
deck: number[];
|
||||||
|
captured?: number[][];
|
||||||
|
turn?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A play-phase state with exactly these cards (no conservation; dealer = A). */
|
||||||
|
function rigged(n: number, spec: Spec, opts: Partial<GostopOptions> = {}): GostopState {
|
||||||
|
const s = gostop.setup({ players: players(n), options: parse({ chongtong: 'off', ...opts }), rng: SeededRng.fromSeed(seedN(1)), now: 0 });
|
||||||
|
s.dealer = 0;
|
||||||
|
const r = s.round!;
|
||||||
|
const zeros = () => Array.from({ length: n }, () => 0);
|
||||||
|
const lists = () => Array.from({ length: n }, (): number[] => []);
|
||||||
|
Object.assign(r, {
|
||||||
|
participants: Array.from({ length: Math.min(n, 3) }, (_, i) => i),
|
||||||
|
sellOrder: [],
|
||||||
|
sellCursor: 0,
|
||||||
|
sellDecisions: [],
|
||||||
|
sellers: [],
|
||||||
|
step: 'play',
|
||||||
|
turn: spec.turn ?? 0,
|
||||||
|
pending: null,
|
||||||
|
stopBy: null,
|
||||||
|
chongtongBy: null,
|
||||||
|
bombDummies: zeros(),
|
||||||
|
revealed: lists(),
|
||||||
|
shakenMonths: lists(),
|
||||||
|
goCount: zeros(),
|
||||||
|
lastGoScore: zeros(),
|
||||||
|
goOrder: [],
|
||||||
|
shakes: zeros(),
|
||||||
|
bombs: zeros(),
|
||||||
|
ppeokCount: zeros(),
|
||||||
|
ppeokTurns: lists(),
|
||||||
|
turnsTaken: zeros(),
|
||||||
|
instant: zeros(),
|
||||||
|
});
|
||||||
|
r.hands = Array.from({ length: n }, (_, i) => [...(spec.hands[i] ?? [])]);
|
||||||
|
r.deck = [...spec.deck];
|
||||||
|
r.captured = Array.from({ length: n }, (_, i) => [...(spec.captured?.[i] ?? [])]);
|
||||||
|
const groups: FloorGroup[] = [];
|
||||||
|
for (const c of spec.floor ?? []) {
|
||||||
|
const m = Math.floor(c / 4) + 1;
|
||||||
|
const g = groups.find((x) => x.month === m);
|
||||||
|
if (g) g.cards.push(c);
|
||||||
|
else groups.push({ month: m, cards: [c], ppeokBy: null, natural: false });
|
||||||
|
}
|
||||||
|
for (const g of groups) Object.assign(g, spec.piles?.[g.month] ?? {});
|
||||||
|
r.floor = groups.sort((a, b) => a.month - b.month);
|
||||||
|
s.waitKey = '';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actE(s: GostopState, p: string, a: GostopAction, now = (clock += 1000)): { state: GostopState; events: GameEvent[] } {
|
||||||
|
const v = gostop.validate(s, p, a);
|
||||||
|
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||||
|
return gostop.apply(s, p, a, { rng: rng(), now });
|
||||||
|
}
|
||||||
|
const act = (s: GostopState, p: string, a: GostopAction, now?: number) => actE(s, p, a, now).state;
|
||||||
|
const reject = (s: GostopState, p: string, a: GostopAction) => {
|
||||||
|
const v = gostop.validate(s, p, a);
|
||||||
|
return v.ok ? null : v.reason;
|
||||||
|
};
|
||||||
|
const play = (card: number | 'dummy', extra: Partial<Extract<GostopAction, { type: 'play' }>> = {}): GostopAction => ({ type: 'play', card, ...extra });
|
||||||
|
const sorted = (xs: number[]) => [...xs].sort((a, b) => a - b);
|
||||||
|
const cap = (s: GostopState, i: number) => sorted(s.round!.captured[i]!);
|
||||||
|
const floorIds = (s: GostopState) => sorted(s.round!.floor.flatMap((g) => g.cards));
|
||||||
|
|
||||||
|
function settleOf(over: Partial<SettleInput> & { captured: number[][] }, opts: Partial<GostopOptions> = {}, n = over.captured.length): ReturnType<typeof settle> {
|
||||||
|
const zeros = () => Array.from({ length: n }, () => 0);
|
||||||
|
return settle({
|
||||||
|
rules: resolveRules(parse(opts), n),
|
||||||
|
mode: n === 2 ? 'matgo' : 'gostop',
|
||||||
|
winner: 0,
|
||||||
|
participants: Array.from({ length: n }, (_, i) => i),
|
||||||
|
goCount: zeros(),
|
||||||
|
goOrder: [],
|
||||||
|
shakes: zeros(),
|
||||||
|
bombs: zeros(),
|
||||||
|
nagariMult: 1,
|
||||||
|
gukjinChoice: Array.from({ length: n }, () => null),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pay = (st: ReturnType<typeof settle>, from: number) => st.payments.find((x) => x.from === from)!;
|
||||||
|
|
||||||
|
// pi helpers
|
||||||
|
const PIS = [2, 3, 6, 7, 10, 11, 14, 15, 18, 19, 22, 23, 26, 27, 30, 31, 34, 35, 38, 39, 42, 43];
|
||||||
|
const pis = (n: number, from = 0) => PIS.slice(from, from + n);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- cards & scoring
|
||||||
|
|
||||||
|
describe('패 구성 (§3.1)', () => {
|
||||||
|
test('48장 + 보너스: 광 5, 열끗 9, 띠 10, 피 24(쌍피 41·47), 보너스 2피·3피', () => {
|
||||||
|
const all = Array.from({ length: 48 }, (_, i) => cardInfo(i, 0));
|
||||||
|
const count = (k: string) => all.filter((c) => c.kind === k).length;
|
||||||
|
expect([count('gwang'), count('yeol'), count('tti'), count('pi')]).toEqual([5, 9, 10, 24]);
|
||||||
|
expect(all.filter((c) => c.piValue === 2).map((c) => c.id)).toEqual([41, 47]);
|
||||||
|
expect(all.filter((c) => c.godori).map((c) => c.id)).toEqual([4, 12, 29]);
|
||||||
|
expect(cardInfo(32, 0).gukjin).toBe(true);
|
||||||
|
expect(cardInfo(44, 0).bi).toBe(true);
|
||||||
|
expect(cardInfo(46, 0).dan).toBeUndefined();
|
||||||
|
expect([48, 49, 50].map((id) => cardInfo(id, 3).piValue)).toEqual([2, 2, 3]);
|
||||||
|
expect([48, 49, 50, 51].map((id) => cardInfo(id, 4).piValue)).toEqual([2, 2, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('점수 (§6.1)', () => {
|
||||||
|
const R = resolveRules(parse(), 2);
|
||||||
|
test('비삼광 2점, 삼광 3점, 사광 4점(비광 포함), 오광 15점(옵션 30)', () => {
|
||||||
|
expect(scoreOf([0, 8, 44], R).total).toBe(2);
|
||||||
|
expect(scoreOf([0, 8, 28], R).total).toBe(3);
|
||||||
|
expect(scoreOf([0, 8, 28, 44], R).total).toBe(4);
|
||||||
|
expect(scoreOf([0, 8, 28, 40, 44], R).total).toBe(15);
|
||||||
|
expect(scoreOf([0, 8, 28, 40, 44], resolveRules(parse({ ogwangPoints: 30 }), 2)).total).toBe(30);
|
||||||
|
expect(scoreOf([0, 8, 44], resolveRules(parse({ biGwangThree: 0 }), 2)).total).toBe(0);
|
||||||
|
expect(scoreOf([0, 8], R).total).toBe(0);
|
||||||
|
});
|
||||||
|
test('고도리 5 + 열끗 장수 점수 중복', () => {
|
||||||
|
expect(scoreOf([4, 12, 29], R).total).toBe(5);
|
||||||
|
expect(scoreOf([4, 12, 29, 16, 20], R).items).toEqual([
|
||||||
|
{ name: '열끗 5장', points: 1 },
|
||||||
|
{ name: '고도리', points: 5 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test('띠 5장 1점 + 홍단 3점 중복, 청단·초단, 비띠는 장수에만', () => {
|
||||||
|
expect(scoreOf([1, 5, 9, 13, 46], R).total).toBe(4);
|
||||||
|
expect(scoreOf([21, 33, 37], R).total).toBe(3);
|
||||||
|
expect(scoreOf([13, 17, 25], R).total).toBe(3);
|
||||||
|
expect(scoreOf([1, 5, 46], R).total).toBe(0);
|
||||||
|
});
|
||||||
|
test('피 값 10에 1점, 쌍피·보너스 반영', () => {
|
||||||
|
expect(scoreOf(pis(9), R).total).toBe(0);
|
||||||
|
expect(scoreOf(pis(10), R).total).toBe(1);
|
||||||
|
expect(scoreOf([...pis(6), 41, 47], R).total).toBe(1);
|
||||||
|
const R3 = resolveRules(parse({ bonusCards: '2+1' }), 2);
|
||||||
|
expect(scoreOf([...pis(5), 48, 50], R3).total).toBe(1);
|
||||||
|
expect(scoreOf([...pis(5), 48, 50], R3).pi).toBe(10);
|
||||||
|
});
|
||||||
|
test('국진 auto (예시 6): 열끗 5장(국진 포함) + 피 값 9 → 쌍피로 계산해 2점', () => {
|
||||||
|
const cards = [4, 12, 16, 20, 32, ...pis(9)];
|
||||||
|
expect(scoreOf(cards, R, 'yeol').total).toBe(1);
|
||||||
|
expect(scoreOf(cards, R, 'pi').total).toBe(2);
|
||||||
|
expect(bestScore(cards, R)).toMatchObject({ as: 'pi', calc: { total: 2 } });
|
||||||
|
expect(bestScore(cards, resolveRules(parse({ gukjinMode: 'yeolOnly' }), 2))).toMatchObject({ as: 'yeol', calc: { total: 1 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('최종 공식 (§6.2, §6.3)', () => {
|
||||||
|
// 예시 1: 삼광 3 + 피 12(3) + 띠 5장(1) = 7. 상대 광 0, 피 6 → 피박·광박 28.
|
||||||
|
const ex1 = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46];
|
||||||
|
test('예시 1: 맞고 7점 스톱, 피박·광박 → 28점', () => {
|
||||||
|
const st = settleOf({ captured: [ex1, pis(6, 12)] });
|
||||||
|
expect(st.baseScore).toBe(7);
|
||||||
|
expect(pay(st, 1)).toEqual({ from: 1, amount: 28, baks: ['pi', 'gwang'] });
|
||||||
|
});
|
||||||
|
test('예시 3: 9점·3고·흔들기 1번 → 48점, 상대 피박이면 96점', () => {
|
||||||
|
const w = [0, 8, 28, ...pis(13), 41];
|
||||||
|
expect(scoreOf(w, resolveRules(parse(), 2)).total).toBe(9);
|
||||||
|
const base = { goCount: [3, 0], goOrder: [0, 0, 0], shakes: [1, 0] };
|
||||||
|
expect(pay(settleOf({ ...base, captured: [w, [40, ...pis(8, 13)]] }), 1).amount).toBe(48);
|
||||||
|
expect(pay(settleOf({ ...base, captured: [w, [40, ...pis(7, 13)]] }), 1)).toMatchObject({ amount: 96, baks: ['pi'] });
|
||||||
|
});
|
||||||
|
test('예시 5: 3인 독박 — A가 18점, C 0점, B +18', () => {
|
||||||
|
const B = [1, 5, 9, 13, 21, ...pis(11)];
|
||||||
|
const st = settleOf({ winner: 1, captured: [pis(8, 11), B, pis(4, 19).concat([43])], goCount: [1, 0, 0], goOrder: [0] }, {}, 3);
|
||||||
|
expect(st.baseScore).toBe(6);
|
||||||
|
expect(pay(st, 0)).toMatchObject({ amount: 18, baks: ['go'] });
|
||||||
|
expect(pay(st, 2)).toMatchObject({ amount: 0, coveredBy: 0 });
|
||||||
|
// Without 고박 each loser pays their own share.
|
||||||
|
const off = settleOf({ winner: 1, captured: [pis(8, 11), B, pis(4, 19).concat([43])], goCount: [1, 0, 0], goOrder: [0] }, { goBak: false }, 3);
|
||||||
|
expect([pay(off, 0).amount, pay(off, 2).amount]).toEqual([6, 12]);
|
||||||
|
});
|
||||||
|
test('3인 독박: 고를 부른 패자가 2명이면 마지막에 부른 사람', () => {
|
||||||
|
const B = [1, 5, 9, 13, 21, ...pis(11)];
|
||||||
|
const st = settleOf({ winner: 1, captured: [pis(8, 11), B, [40, ...pis(8)]], goCount: [1, 0, 1], goOrder: [2, 0] }, {}, 3);
|
||||||
|
expect(pay(st, 0).amount).toBe(12);
|
||||||
|
expect(pay(st, 2).amount).toBe(0);
|
||||||
|
});
|
||||||
|
test('예시 7: 직전 판 나가리 → 7 x 2 = 14점', () => {
|
||||||
|
const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46];
|
||||||
|
expect(pay(settleOf({ nagariMult: 2, captured: [w, [40, ...pis(8, 12)]] }), 1).amount).toBe(14);
|
||||||
|
});
|
||||||
|
test('예시 8: 멍따 — 열끗 7장(고도리 포함) 8점 x2 = 16점; 멍박 옵션이면 패자 열끗 0장 x2', () => {
|
||||||
|
const w = [4, 12, 29, 16, 20, 24, 36];
|
||||||
|
expect(pay(settleOf({ captured: [w, [45, ...pis(3)]] }), 1).amount).toBe(16);
|
||||||
|
expect(pay(settleOf({ captured: [w, [45, ...pis(3)]] }, { meongTta: false }), 1).amount).toBe(8);
|
||||||
|
expect(pay(settleOf({ captured: [w, pis(3)] }, { meongBak: true }), 1)).toMatchObject({ amount: 32, baks: ['meong'] });
|
||||||
|
});
|
||||||
|
test('고 배수: 1·2고는 가산만, 3고 x2, 4고 x4, 5고 x8', () => {
|
||||||
|
const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46];
|
||||||
|
const amt = (go: number) => pay(settleOf({ goCount: [go, 0], goOrder: Array(go).fill(0), captured: [w, [40, ...pis(8, 12)]] }), 1).amount;
|
||||||
|
expect([0, 1, 2, 3, 4, 5].map(amt)).toEqual([7, 8, 9, 20, 44, 96]);
|
||||||
|
});
|
||||||
|
test('흔들기 2번 x4, 폭탄 x2, 패자의 흔들기는 배수 없음', () => {
|
||||||
|
const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46];
|
||||||
|
const L = [40, ...pis(8, 12)];
|
||||||
|
expect(pay(settleOf({ shakes: [2, 0], captured: [w, L] }), 1).amount).toBe(28);
|
||||||
|
expect(pay(settleOf({ shakes: [1, 0], bombs: [1, 0], captured: [w, L] }), 1).amount).toBe(28);
|
||||||
|
expect(pay(settleOf({ shakes: [0, 2], bombs: [0, 1], captured: [w, L] }), 1).amount).toBe(7);
|
||||||
|
});
|
||||||
|
test('피박·광박·고박이 모두 겹치면 모두 곱함(맞고)', () => {
|
||||||
|
const st = settleOf({ captured: [ex1, pis(6, 12)], goCount: [0, 1], goOrder: [1] });
|
||||||
|
expect(pay(st, 1)).toEqual({ from: 1, amount: 56, baks: ['pi', 'gwang', 'go'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('박 경계 (§6.2)', () => {
|
||||||
|
// Winner: 피 값 12 (3점) + 열끗 5장 (1점) + 고도리 (5) = 9, no 광.
|
||||||
|
const W = [4, 12, 29, 16, 20, ...pis(12)];
|
||||||
|
test('맞고 피박: 7 피박, 8 아님; 피 0장은 면제(옵션으로 끄면 피박)', () => {
|
||||||
|
expect(pay(settleOf({ captured: [W, pis(7, 12)] }), 1).baks).toEqual(['pi']);
|
||||||
|
expect(pay(settleOf({ captured: [W, pis(8, 12)] }), 1).baks).toEqual([]);
|
||||||
|
expect(pay(settleOf({ captured: [W, [36]] }), 1).baks).toEqual([]);
|
||||||
|
expect(pay(settleOf({ captured: [W, [36]] }, { piBakZeroExempt: false }), 1).baks).toEqual(['pi']);
|
||||||
|
});
|
||||||
|
test('3인 피박: 5 피박, 6 아님', () => {
|
||||||
|
const st = settleOf({ captured: [W, pis(5, 12), pis(6, 12).map((c) => c)] }, {}, 3);
|
||||||
|
expect(pay(st, 1).baks).toEqual(['pi']);
|
||||||
|
expect(pay(st, 2).baks).toEqual([]);
|
||||||
|
});
|
||||||
|
test('승자 피 점수가 없으면 피박 없음', () => {
|
||||||
|
expect(pay(settleOf({ captured: [[4, 12, 29, 16, 20, 24, 36, 45], pis(2, 12)] }, { meongTta: false }), 1).baks).toEqual([]);
|
||||||
|
});
|
||||||
|
test('광박은 승자가 광 점수를 받았을 때만', () => {
|
||||||
|
expect(pay(settleOf({ captured: [[0, 8, ...W], pis(8, 12)] }), 1).baks).toEqual([]);
|
||||||
|
expect(pay(settleOf({ captured: [[0, 8, 28, ...W], pis(8, 12)] }), 1).baks).toEqual(['gwang']);
|
||||||
|
expect(pay(settleOf({ captured: [[0, 8, 28, ...W], pis(8, 12)] }, { gwangBak: false }), 1).baks).toEqual([]);
|
||||||
|
});
|
||||||
|
test('국진: 패자는 피박을 피하는 쪽으로(3인 5→7 면제, 맞고 기준 7이면 여전히 피박)', () => {
|
||||||
|
const loser = [32, ...pis(5, 12)];
|
||||||
|
const g3 = settleOf({ captured: [W, loser, pis(8, 12)] }, {}, 3);
|
||||||
|
expect(g3.gukjinAs).toBe('pi');
|
||||||
|
expect(pay(g3, 1).baks).toEqual([]);
|
||||||
|
const g2 = settleOf({ captured: [W, loser] });
|
||||||
|
expect(pay(g2, 1).baks).toEqual(['pi']);
|
||||||
|
// ask mode with an explicit choice is honoured.
|
||||||
|
expect(settleOf({ captured: [W, loser, pis(8, 12)], gukjinChoice: [null, 'yeol', null] }, { gukjinMode: 'ask' }, 3).gukjinAs).toBe('yeol');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- setup
|
||||||
|
|
||||||
|
describe('셋업 (§4)', () => {
|
||||||
|
test('같은 시드면 같은 배분; 맞고 10/8/20(+보너스), 3인 7/6/21', () => {
|
||||||
|
for (let seed = 1; seed <= 40; seed++) {
|
||||||
|
const a = gostop.setup({ players: players(2), options: parse(), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||||
|
const b = gostop.setup({ players: players(2), options: parse(), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||||
|
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
||||||
|
const r = a.round!;
|
||||||
|
expect(r.hands.map((h) => h.length)).toEqual([10, 10]);
|
||||||
|
expect(r.floor.reduce((x, g) => x + g.cards.length, 0)).toBe(8);
|
||||||
|
const floorBonus = r.captured[a.dealer]!.length;
|
||||||
|
expect(r.deck.length + floorBonus).toBe(23);
|
||||||
|
expect(cardsInRound(a)).toBe(51);
|
||||||
|
const s3 = gostop.setup({ players: players(3), options: parse({ chongtong: 'off' }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||||
|
expect(s3.round!.hands.map((h) => h.length)).toEqual([7, 7, 7]);
|
||||||
|
expect(s3.round!.deck).toHaveLength(21);
|
||||||
|
expect(s3.rules).toMatchObject({ nb: 0, winScore: 3, piBakLimit: 5, chongtongPoints: 5 });
|
||||||
|
expect(a.rules).toMatchObject({ nb: 3, winScore: 7, piBakLimit: 7, chongtongPoints: 10 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('바닥에 같은 월 4장 → 다시 섞기(선·판 번호 유지)', () => {
|
||||||
|
const hands = [[4, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]];
|
||||||
|
const rngS = scripted([[...hands.flat(), 0, 1, 2, 3, 32, 33, 36, 37]], 1);
|
||||||
|
const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: rngS, now: 0 });
|
||||||
|
expect(s.round!.redeals).toBe(1);
|
||||||
|
expect(s.roundNo).toBe(1);
|
||||||
|
expect(s.dealer).toBe(1);
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const g of s.round!.floor) counts.set(g.month, g.cards.length);
|
||||||
|
expect([...counts.values()].every((v) => v < 4)).toBe(true);
|
||||||
|
expect(cardsInRound(s)).toBe(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('바닥 3장 같은 월 → 한 무더기(자연 뻑); 4번째로 먹으면 피 1장씩', () => {
|
||||||
|
const hands = [[3, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]];
|
||||||
|
const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: scripted([[...hands.flat(), 0, 1, 2, 32, 33, 36, 37, 40]]), now: 0 });
|
||||||
|
const pile = s.round!.floor.find((g) => g.month === 1)!;
|
||||||
|
expect(pile).toMatchObject({ cards: [0, 1, 2], natural: true, ppeokBy: null });
|
||||||
|
s.round!.captured[1] = [41, 7];
|
||||||
|
const t = act(s, 'A', play(3));
|
||||||
|
expect(cap(t, 0)).toEqual(expect.arrayContaining([0, 1, 2, 3, 7]));
|
||||||
|
expect(t.round!.lastTurn!.tags).toContain('ppeokEat');
|
||||||
|
// naturalThreeSteal off → no 피
|
||||||
|
const s2 = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', naturalThreeSteal: false }), rng: scripted([[...hands.flat(), 0, 1, 2, 32, 33, 36, 37, 40]]), now: 0 });
|
||||||
|
s2.round!.captured[1] = [41, 7];
|
||||||
|
expect(cap(act(s2, 'A', play(3)), 1)).toEqual([7, 41]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('바닥 보너스패 → 선이 가져가고 더미에서 보충, 보충패도 보너스면 반복', () => {
|
||||||
|
const hands = [[4, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]];
|
||||||
|
const s = gostop.setup({ players: players(2), options: parse(), rng: scripted([[...hands.flat(), 48, 0, 1, 32, 33, 36, 37, 40, 49, 44, 45]]), now: 0 });
|
||||||
|
const r = s.round!;
|
||||||
|
expect(sorted(r.captured[0]!)).toEqual([48, 49]);
|
||||||
|
expect(floorIds(s)).toEqual([0, 1, 32, 33, 36, 37, 40, 44]);
|
||||||
|
expect(r.deck).toHaveLength(51 - 20 - 8 - 2);
|
||||||
|
expect(r.deck[0]).toBe(45);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- turn mechanics
|
||||||
|
|
||||||
|
describe('먹기 판정 (§5.2)', () => {
|
||||||
|
test('쪽: 바닥에 없는 월을 냈는데 같은 월이 뒤집힘 → 2장 먹고 상대마다 피 1장', () => {
|
||||||
|
const s = rigged(2, { hands: [[2, 40], [10, 11]], floor: [4, 12], deck: [3, 20, 21], captured: [[], [6]] });
|
||||||
|
const { state: t, events } = actE(s, 'A', play(2));
|
||||||
|
expect(cap(t, 0)).toEqual([2, 3, 6]);
|
||||||
|
expect(cap(t, 1)).toEqual([]);
|
||||||
|
expect(t.round!.lastTurn!.tags).toEqual(['jjok']);
|
||||||
|
expect(events.some((e) => e.type === 'jjok')).toBe(true);
|
||||||
|
expect(t.round!.turn).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('뻑: 바닥 1장과 짝 + 같은 월 뒤집힘 → 3장 무더기, ppeokBy 기록; 남이 먹으면 1장, 자뻑은 2장씩', () => {
|
||||||
|
const base = () => rigged(2, { hands: [[2, 40], [1, 11]], floor: [0, 4], deck: [3, 20, 21, 22], captured: [[6, 7, 41], [10, 47, 14]] }, { ppeokBonus: false });
|
||||||
|
let s = act(base(), 'A', play(2));
|
||||||
|
const pile = s.round!.floor.find((g) => g.month === 1)!;
|
||||||
|
expect(pile).toMatchObject({ cards: [0, 2, 3], ppeokBy: 0, natural: false });
|
||||||
|
expect(cap(s, 0)).toEqual([6, 7, 41]);
|
||||||
|
expect(s.round!.ppeokCount[0]).toBe(1);
|
||||||
|
// B eats the pile with the 4th card → 1 피 from A (lowest value first: 6).
|
||||||
|
const t = act(s, 'B', play(1));
|
||||||
|
expect(cap(t, 1)).toEqual(expect.arrayContaining([0, 1, 2, 3, 6]));
|
||||||
|
expect(cap(t, 0)).toEqual([7, 41]);
|
||||||
|
expect(t.round!.lastTurn!.tags).toContain('ppeokEat');
|
||||||
|
// 자뻑: A eats its own pile → 2 피 from B.
|
||||||
|
s = rigged(2, { hands: [[1, 40], [11, 15]], floor: [0, 2, 3, 4], piles: { 1: { ppeokBy: 0 } }, deck: [20, 21, 22], captured: [[], [10, 47, 14]] });
|
||||||
|
const u = act(s, 'A', play(1));
|
||||||
|
expect(u.round!.lastTurn!.tags).toContain('jaPpeok');
|
||||||
|
expect(cap(u, 1)).toEqual([47]);
|
||||||
|
// jaPpeokSteal = 1
|
||||||
|
s = rigged(2, { hands: [[1, 40], [11, 15]], floor: [0, 2, 3, 4], piles: { 1: { ppeokBy: 0 } }, deck: [20, 21, 22], captured: [[], [10, 47, 14]] }, { jaPpeokSteal: 1 });
|
||||||
|
expect(cap(act(s, 'A', play(1)), 1)).toEqual([14, 47]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('따닥: 바닥 2장 + 손 1장 + 같은 월 뒤집힘 → 4장 먹고 피 1장씩', () => {
|
||||||
|
const s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2, 4], deck: [3, 20], captured: [[], [6, 7]] });
|
||||||
|
const t = act(s, 'A', play(1, { target: 0 }));
|
||||||
|
expect(cap(t, 0)).toEqual([0, 1, 2, 3, 6]);
|
||||||
|
expect(t.round!.lastTurn!.tags).toEqual(['ttadak']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('쓸: 턴 끝에 바닥이 비면 피 1장씩; 따닥 + 쓸이면 2장씩', () => {
|
||||||
|
let s = rigged(2, { hands: [[2, 40], [11]], floor: [0, 20], deck: [21, 5], captured: [[], [6, 7, 10]] });
|
||||||
|
let t = act(s, 'A', play(2));
|
||||||
|
expect(t.round!.lastTurn!.tags).toEqual(['sseul']);
|
||||||
|
expect(cap(t, 1)).toEqual([7, 10]);
|
||||||
|
s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2], deck: [3, 5], captured: [[], [6, 7, 10]] });
|
||||||
|
t = act(s, 'A', play(1, { target: 2 }));
|
||||||
|
expect(t.round!.lastTurn!.tags).toEqual(['ttadak', 'sseul']);
|
||||||
|
expect(cap(t, 1)).toEqual([10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마지막 턴 쓸·쪽은 lastTurnSweepBonus=false면 피 뺏기 없음(따닥은 인정)', () => {
|
||||||
|
// 쪽 on the last flip, nobody has cards left → 나가리 afterwards.
|
||||||
|
let t = act(rigged(2, { hands: [[2], []], floor: [4], deck: [3], captured: [[], [6]] }), 'A', play(2));
|
||||||
|
expect(t.round!.lastTurn).toMatchObject({ tags: ['jjok'], stolen: [] });
|
||||||
|
expect(t.round!.outcome!.type).toBe('nagari');
|
||||||
|
t = act(rigged(2, { hands: [[2], []], floor: [4], deck: [3], captured: [[], [6]] }, { lastTurnSweepBonus: true }), 'A', play(2));
|
||||||
|
expect(t.round!.lastTurn!.stolen).toEqual([[1, [6]]]);
|
||||||
|
// 쓸 on the last turn
|
||||||
|
t = act(rigged(2, { hands: [[2], []], floor: [0, 20], deck: [21], captured: [[], [6]] }), 'A', play(2));
|
||||||
|
expect(t.round!.lastTurn).toMatchObject({ tags: ['sseul'], stolen: [] });
|
||||||
|
// 따닥 on the last turn still steals
|
||||||
|
t = act(rigged(2, { hands: [[1], []], floor: [0, 2, 4], deck: [3], captured: [[], [6]] }), 'A', play(1, { target: 0 }));
|
||||||
|
expect(t.round!.lastTurn!.stolen).toEqual([[1, [6]]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('두 장 중 고르기: 광/피면 target 없이 내면 거부; 같은 종류면 자동', () => {
|
||||||
|
const s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2, 20], deck: [21, 22] });
|
||||||
|
expect(reject(s, 'A', play(1))).toBe('가져올 패를 골라 주세요.');
|
||||||
|
expect(reject(s, 'A', play(1, { target: 3 }))).toBe('바닥에 있는 같은 월 패 중에서 골라 주세요.');
|
||||||
|
expect(reject(s, 'A', play(40, { target: 0 }))).toBe('고를 패가 없어요.');
|
||||||
|
const t = act(s, 'A', play(1, { target: 0 }));
|
||||||
|
expect(cap(t, 0)).toEqual([0, 1, 20, 21]);
|
||||||
|
expect(floorIds(t)).toEqual([2]);
|
||||||
|
const same = rigged(2, { hands: [[0, 40], [11]], floor: [2, 3, 20], deck: [21, 22] });
|
||||||
|
expect(reject(same, 'A', play(0))).toBeNull();
|
||||||
|
expect(cap(act(same, 'A', play(0)), 0)).toEqual([0, 2, 20, 21]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('뒤집은 패가 바닥 2장과 맞고 종류가 다르면 chooseFlip 단계; 같으면 자동', () => {
|
||||||
|
const s = rigged(2, { hands: [[4, 40], [11]], floor: [28, 30, 12], deck: [31, 22] });
|
||||||
|
const t = act(s, 'A', play(4));
|
||||||
|
expect(t.round!.step).toBe('chooseFlip');
|
||||||
|
expect(gostop.activePlayers(t)).toEqual(['A']);
|
||||||
|
expect(reject(t, 'A', play(40))).toBe('먼저 뒤집은 패로 가져올 패를 골라 주세요.');
|
||||||
|
expect(reject(t, 'A', { type: 'chooseFlip', target: 12 })).toBeTruthy();
|
||||||
|
expect(cardsInRound(t)).toBe(cardsInRound(s));
|
||||||
|
const u = act(t, 'A', { type: 'chooseFlip', target: 28 });
|
||||||
|
expect(cap(u, 0)).toEqual([28, 31]);
|
||||||
|
expect(floorIds(u)).toEqual([4, 12, 30]);
|
||||||
|
expect(u.round!.turn).toBe(1);
|
||||||
|
const same = act(rigged(2, { hands: [[4, 40], [11]], floor: [30, 31, 12], deck: [28, 22] }), 'A', play(4));
|
||||||
|
expect(same.round!.step).toBe('play');
|
||||||
|
expect(cap(same, 0)).toEqual([28, 30]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('피 뺏기 순서 (§5.3)', () => {
|
||||||
|
test('일반 피 → 쌍피·2피 → 3피, 같은 값이면 낮은 ID; 피가 없으면 안 줌; 국진은 안 뺏김', () => {
|
||||||
|
const go = (b: number[], opts: Partial<GostopOptions> = {}) => cap(act(rigged(2, { hands: [[2, 40], [11]], floor: [4], deck: [3, 20], captured: [[], b] }, opts), 'A', play(2)), 1);
|
||||||
|
expect(go([41])).toEqual([]);
|
||||||
|
expect(go([41, 43])).toEqual([41]);
|
||||||
|
expect(go([32, 36])).toEqual([32, 36]);
|
||||||
|
expect(go([50, 48], { bonusCards: '2+1' })).toEqual([50]);
|
||||||
|
expect(go([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('폭탄·흔들기·보너스패 (§5.4–5.6)', () => {
|
||||||
|
test('폭탄: 3장 + 바닥 1장 → 4장 먹고 피 1장씩, 빈패 2장, 빈패 내면 더미만 뒤집음', () => {
|
||||||
|
const s = rigged(2, { hands: [[0, 1, 2, 40], [11, 15]], floor: [3, 20, 12], deck: [21, 5, 16, 17], captured: [[], [6, 7]] });
|
||||||
|
expect(reject(s, 'A', play(40, { bomb: true }))).toBe('폭탄은 같은 월 3장과 바닥 1장이 있어야 해요.');
|
||||||
|
let t = act(s, 'A', play(1, { bomb: true }));
|
||||||
|
const r = t.round!;
|
||||||
|
expect(cap(t, 0)).toEqual([0, 1, 2, 3, 6, 20, 21]);
|
||||||
|
expect(r.hands[0]).toEqual([40]);
|
||||||
|
expect(r.bombDummies[0]).toBe(2);
|
||||||
|
expect(r.bombs[0]).toBe(1);
|
||||||
|
expect(r.lastTurn!.tags).toEqual(['bomb']);
|
||||||
|
t = act(t, 'B', play(11));
|
||||||
|
expect(gostop.view(t, 'A').myDummies).toBe(2);
|
||||||
|
expect(reject(t, 'A', play('dummy', { shake: true }))).toBeTruthy();
|
||||||
|
const deckBefore = t.round!.deck.length;
|
||||||
|
t = act(t, 'A', play('dummy'));
|
||||||
|
expect(t.round!.hands[0]).toEqual([40]);
|
||||||
|
expect(t.round!.bombDummies[0]).toBe(1);
|
||||||
|
expect(t.round!.deck.length).toBe(deckBefore - 1);
|
||||||
|
expect(reject(rigged(2, { hands: [[0, 1, 2], []], floor: [3], deck: [5] }, { bomb: false }), 'A', play(0, { bomb: true }))).toBe('이 방은 폭탄을 쓰지 않아요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('흔들기: 공개된 패는 상대 view에 보이고 나머지 손패는 안 보임; 같은 월 두 번은 안 됨', () => {
|
||||||
|
const s = rigged(2, { hands: [[0, 1, 2, 40, 44], [11, 15]], floor: [20], deck: [21, 5, 16, 17] });
|
||||||
|
expect(reject(s, 'A', play(40, { shake: true }))).toContain('흔들기는');
|
||||||
|
const t = act(s, 'A', play(0, { shake: true }));
|
||||||
|
expect(t.round!.shakes[0]).toBe(1);
|
||||||
|
expect(sorted(t.round!.revealed[0]!)).toEqual([1, 2]);
|
||||||
|
const bView = JSON.stringify(gostop.view(t, 'B'));
|
||||||
|
expect(bView).toContain('"r1c1"');
|
||||||
|
expect(bView).toContain('"r1c2"');
|
||||||
|
expect(bView).not.toContain('"r1c40"');
|
||||||
|
expect(bView).not.toContain('"r1c44"');
|
||||||
|
expect(gostop.secretsOf!(t, 'A')).toEqual(['r1c40', 'r1c44', ...t.round!.deck.map((c) => `r1c${c}`)]);
|
||||||
|
expect(reject(rigged(2, { hands: [[0, 1, 2], []], floor: [], deck: [5] }, { shake: false }), 'A', play(0, { shake: true }))).toBe('이 방은 흔들기를 쓰지 않아요.');
|
||||||
|
const again = rigged(2, { hands: [[0, 1, 2], []], floor: [], deck: [5] });
|
||||||
|
again.round!.shakenMonths[0] = [1];
|
||||||
|
expect(reject(again, 'A', play(0, { shake: true }))).toBe('이 월은 이미 흔들었어요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('보너스패: 손에서 내면 먹고 1장 보충 후 같은 턴 계속; 뒤집힌 보너스패는 먹고 1장 더', () => {
|
||||||
|
const opts = { bonusCards: '2+1' as const };
|
||||||
|
let s = rigged(2, { hands: [[48, 2], [11]], floor: [0], deck: [20, 49, 21, 22, 23], captured: [[], []] }, opts);
|
||||||
|
expect(reject(s, 'A', play(48, { target: 0 }))).toBe('보너스패는 그냥 내기만 할 수 있어요.');
|
||||||
|
s = act(s, 'A', play(48));
|
||||||
|
expect(s.round!.step).toBe('play');
|
||||||
|
expect(s.round!.turn).toBe(0);
|
||||||
|
expect(sorted(s.round!.hands[0]!)).toEqual([2, 20]);
|
||||||
|
expect(cap(s, 0)).toEqual([48]);
|
||||||
|
expect(s.round!.lastTurn!.tags).toEqual(['bonus']);
|
||||||
|
// Flip: 49 is a bonus → taken, then 21 flips.
|
||||||
|
s = act(s, 'A', play(2));
|
||||||
|
expect(cap(s, 0)).toEqual([0, 2, 48, 49]);
|
||||||
|
expect(s.round!.lastTurn!.flipped).toEqual([49, 21]);
|
||||||
|
expect(floorIds(s)).toEqual([21]);
|
||||||
|
expect(s.round!.turn).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('총통 (§5.7)', () => {
|
||||||
|
const hands = [[0, 1, 2, 3, 4, 5, 6, 8, 9, 10], [12, 13, 14, 16, 17, 18, 20, 21, 22, 24]];
|
||||||
|
const order = [...hands.flat(), 25, 26, 28, 29, 32, 33, 36, 37];
|
||||||
|
test('손에 같은 월 4장 → 선언 시 맞고 10점(나가리 배수만 적용); 미선언 시 진행·흔들기 가능', () => {
|
||||||
|
const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: scripted([order]), now: 0 });
|
||||||
|
expect(s.round!.step).toBe('chongtong');
|
||||||
|
expect(gostop.activePlayers(s)).toEqual(['A']);
|
||||||
|
expect(gostop.view(s, 'A').chongtongAsk).toEqual({ player: 'A', months: [1] });
|
||||||
|
expect(gostop.view(s, 'B').chongtongAsk).toEqual({ player: 'A', months: null });
|
||||||
|
const t = act(s, 'A', { type: 'chongtong', declare: true });
|
||||||
|
expect(t.round!.outcome).toMatchObject({ type: 'chongtong', winner: 0, shown: [0, 1, 2, 3] });
|
||||||
|
expect(t.totals).toEqual([10, -10]);
|
||||||
|
const doubled = structuredClone(s);
|
||||||
|
doubled.round!.nagariMult = 2;
|
||||||
|
expect(act(doubled, 'A', { type: 'chongtong', declare: true }).totals).toEqual([20, -20]);
|
||||||
|
const u = act(s, 'A', { type: 'chongtong', declare: false });
|
||||||
|
expect(u.round!.step).toBe('play');
|
||||||
|
expect(reject(u, 'A', play(0, { shake: true }))).toBeNull();
|
||||||
|
// timeout → declare
|
||||||
|
expect(act(s, 'A', { type: 'timeout' }).round!.outcome!.type).toBe('chongtong');
|
||||||
|
});
|
||||||
|
test('redeal: 다시 돌림(선 유지); off: 그냥 진행', () => {
|
||||||
|
const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', chongtong: 'redeal' }), rng: scripted([order]), now: 0 });
|
||||||
|
expect(s.round!.redeals).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(s.round!.step).toBe('play');
|
||||||
|
expect(s.dealer).toBe(0);
|
||||||
|
const off = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', chongtong: 'off' }), rng: scripted([order]), now: 0 });
|
||||||
|
expect(off.round!.step).toBe('play');
|
||||||
|
expect(sorted(off.round!.hands[0]!)).toEqual(sorted(hands[0]!));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('고 / 스톱 (§5.9)', () => {
|
||||||
|
// 삼광 3 + 홍단 3 + 띠 5장 1 = 7점, B has a 광 (no 광박).
|
||||||
|
const seven = [0, 8, 28, 1, 5, 9, 13, 21];
|
||||||
|
test('마지막 고 점수와 같으면 선택 없음, 커지면 선택; 스톱하면 (8 + 1) = 9점', () => {
|
||||||
|
let s = rigged(2, { hands: [[2, 25, 42], [11, 15, 43]], floor: [3, 24, 40], deck: [36, 37, 38, 39], captured: [seven, [44]] });
|
||||||
|
s.round!.goCount[0] = 1;
|
||||||
|
s.round!.lastGoScore[0] = 7;
|
||||||
|
s.round!.goOrder = [0];
|
||||||
|
s = act(s, 'A', play(2)); // captures a 피 only → still 7
|
||||||
|
expect(s.round!.step).toBe('play');
|
||||||
|
expect(s.round!.turn).toBe(1);
|
||||||
|
s = act(s, 'B', play(11));
|
||||||
|
s = act(s, 'A', play(25)); // 7월 초단 + 열끗 → 띠 6장 → 8점
|
||||||
|
expect(s.round!.step).toBe('goStop');
|
||||||
|
const v = gostop.view(s, 'A').goStop!;
|
||||||
|
expect(v).toMatchObject({ score: 8, goCount: 1, goNext: { goCount: 2, plus: 2, factor: 1 } });
|
||||||
|
expect(v.stopNow.total).toBe(9);
|
||||||
|
s = act(s, 'A', { type: 'goStop', decision: 'stop' });
|
||||||
|
expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0, baseScore: 8, goCount: 1 });
|
||||||
|
expect(s.totals).toEqual([9, -9]);
|
||||||
|
});
|
||||||
|
test('고: 횟수 +1, 마지막 고 점수 기록, 다음 사람 차례', () => {
|
||||||
|
let s = rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [seven.slice(0, -1).concat([33]), [44]] });
|
||||||
|
s = act(s, 'A', play(25));
|
||||||
|
expect(s.round!.step).toBe('goStop');
|
||||||
|
s = act(s, 'A', { type: 'goStop', decision: 'go' });
|
||||||
|
expect(s.round!.goCount[0]).toBe(1);
|
||||||
|
expect(s.round!.lastGoScore[0]).toBe(8);
|
||||||
|
expect(s.round!.turn).toBe(1);
|
||||||
|
expect(s.stats.goCalls[0]).toBe(1);
|
||||||
|
});
|
||||||
|
test('마지막 턴에 조건이 되면 자동 스톱; goStop 단계에서 손패 0장이면 고 거부', () => {
|
||||||
|
const s = act(rigged(2, { hands: [[25], [11]], floor: [24], deck: [36, 37], captured: [seven.slice(0, -1).concat([33]), [44]] }), 'A', play(25));
|
||||||
|
expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0 });
|
||||||
|
const g = rigged(2, { hands: [[], [11]], deck: [36] });
|
||||||
|
g.round!.step = 'goStop';
|
||||||
|
expect(reject(g, 'A', { type: 'goStop', decision: 'go' })).toBe('마지막 턴에는 고를 할 수 없어요.');
|
||||||
|
expect(reject(g, 'A', { type: 'goStop', decision: 'stop' })).toBeNull();
|
||||||
|
});
|
||||||
|
test('시간 초과 → 스톱', () => {
|
||||||
|
let s = rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [seven.slice(0, -1).concat([33]), [44]] });
|
||||||
|
s = act(s, 'A', play(25));
|
||||||
|
s = act(s, 'A', { type: 'timeout' });
|
||||||
|
expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0 });
|
||||||
|
});
|
||||||
|
test('국진 ask: 판 끝 정산 직전 본인이 고름, 시간 초과면 auto', () => {
|
||||||
|
const cards = [4, 12, 16, 20, 32, ...pis(9)];
|
||||||
|
const mk = () => rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [[0, 8, 28, ...cards.slice(0, -2)], [44]] }, { gukjinMode: 'ask', winScore: 3 });
|
||||||
|
let s = act(mk(), 'A', play(25));
|
||||||
|
expect(s.round!.step).toBe('goStop');
|
||||||
|
s = act(s, 'A', { type: 'goStop', decision: 'stop' });
|
||||||
|
expect(s.round!.step).toBe('gukjin');
|
||||||
|
expect(gostop.view(s, 'A').gukjinAsk).toMatchObject({ player: 'A' });
|
||||||
|
const y = act(s, 'A', { type: 'gukjin', as: 'yeol' });
|
||||||
|
expect(y.round!.outcome!.gukjinAs).toBe('yeol');
|
||||||
|
const auto = act(s, 'A', { type: 'timeout' });
|
||||||
|
expect(auto.round!.outcome!.gukjinAs).toBe(settle({ ...autoInput(s) }).gukjinAs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function autoInput(s: GostopState): SettleInput {
|
||||||
|
const r = s.round!;
|
||||||
|
return { rules: s.rules, mode: s.mode, winner: r.stopBy!, participants: r.participants, captured: r.captured, goCount: r.goCount, goOrder: r.goOrder, shakes: r.shakes, bombs: r.bombs, nagariMult: r.nagariMult, gukjinChoice: r.gukjinChoice };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('나가리 (§5.10)', () => {
|
||||||
|
test('아무도 스톱 못 함 → 나가리, 선 유지, 다음 판 x2, 연속 x4, 상한 x8', () => {
|
||||||
|
let s = rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36] });
|
||||||
|
s.dealer = 1;
|
||||||
|
s.round!.participants = [1, 0];
|
||||||
|
s.round!.turn = 1;
|
||||||
|
s = act(s, 'B', play(11));
|
||||||
|
s = act(s, 'A', play(2));
|
||||||
|
expect(s.round!.outcome!.type).toBe('nagari');
|
||||||
|
expect(s.phase).toBe('roundResult');
|
||||||
|
expect(s.dealer).toBe(1);
|
||||||
|
expect(s.nagariStreak).toBe(1);
|
||||||
|
expect(s.totals).toEqual([0, 0]);
|
||||||
|
s = act(s, 'A', { type: 'ready' });
|
||||||
|
expect(s.phase).toBe('roundResult');
|
||||||
|
s = act(s, 'B', { type: 'ready' });
|
||||||
|
expect(s.roundNo).toBe(2);
|
||||||
|
expect(s.round!.nagariMult).toBe(2);
|
||||||
|
expect(s.round!.participants[0]).toBe(1);
|
||||||
|
const t = structuredClone(s);
|
||||||
|
t.nagariStreak = 2;
|
||||||
|
t.phase = 'roundResult';
|
||||||
|
t.ready = [];
|
||||||
|
expect(act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(4);
|
||||||
|
t.nagariStreak = 5;
|
||||||
|
expect(act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(8);
|
||||||
|
expect(act(act({ ...structuredClone(t), rules: { ...t.rules, nagariMaxMultiplier: 4 } }, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(4);
|
||||||
|
expect(act(act({ ...structuredClone(t), rules: { ...t.rules, nagariMultiplier: 'none' } }, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(1);
|
||||||
|
});
|
||||||
|
test('고를 부른 사람이 끝까지 점수를 더 못 내면 나가리', () => {
|
||||||
|
let s = rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36], captured: [[0, 8, 28, 1, 5, 9, 13, 21], [44]] });
|
||||||
|
s.round!.goCount[0] = 1;
|
||||||
|
s.round!.lastGoScore[0] = 7;
|
||||||
|
s = act(act(s, 'A', play(2)), 'B', play(11));
|
||||||
|
expect(s.round!.outcome!.type).toBe('nagari');
|
||||||
|
});
|
||||||
|
test('승리하면 승자가 다음 판 선, 나가리 연속 수 초기화', () => {
|
||||||
|
const s = rigged(2, { hands: [[11], [25]], floor: [24], deck: [36, 37], captured: [[44], [0, 8, 28, 1, 5, 9, 13, 33]], turn: 1 });
|
||||||
|
s.nagariStreak = 3;
|
||||||
|
s.round!.nagariMult = 8;
|
||||||
|
const t = act(s, 'B', play(25));
|
||||||
|
expect(t.round!.outcome).toMatchObject({ type: 'stop', winner: 1, perLoser: 64 });
|
||||||
|
expect(t.dealer).toBe(1);
|
||||||
|
expect(t.nagariStreak).toBe(0);
|
||||||
|
const u = act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' });
|
||||||
|
expect(u.round!.participants[0]).toBe(1);
|
||||||
|
expect(u.round!.nagariMult).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('첫뻑·연뻑·삼뻑 (§5.11)', () => {
|
||||||
|
test('첫뻑 3점 즉시, 연뻑 6점 더, 삼뻑 즉시 승리(10점), 나가리여도 첫뻑 점수 유지', () => {
|
||||||
|
let s = rigged(2, { hands: [[2, 6, 10, 40], [11, 15, 19]], floor: [0, 4, 8, 20], deck: [3, 22, 7, 23, 11, 26, 30], captured: [[], []] });
|
||||||
|
s = act(s, 'A', play(2));
|
||||||
|
expect(s.round!.lastTurn!.tags).toEqual(['ppeok', 'firstPpeok']);
|
||||||
|
expect(s.totals).toEqual([3, -3]);
|
||||||
|
s = act(s, 'B', play(15));
|
||||||
|
s = act(s, 'A', play(6));
|
||||||
|
expect(s.round!.lastTurn!.tags).toEqual(['ppeok', 'yeonPpeok']);
|
||||||
|
expect(s.totals).toEqual([9, -9]);
|
||||||
|
s = act(s, 'B', play(19));
|
||||||
|
// third 뻑 → 삼뻑: game over for this round, 10 × 나가리 배수.
|
||||||
|
s.round!.deck = [11, 26, 30];
|
||||||
|
s.round!.hands[1] = [27];
|
||||||
|
const t = act(s, 'A', play(10));
|
||||||
|
expect(t.round!.outcome).toMatchObject({ type: 'samppeok', winner: 0, perLoser: 10 });
|
||||||
|
expect(t.totals).toEqual([19, -19]);
|
||||||
|
// 나가리: first-뻑 points stay.
|
||||||
|
const n = act(rigged(2, { hands: [[2], []], floor: [0, 20], deck: [3] }), 'A', play(2));
|
||||||
|
expect(n.round!.outcome!.type).toBe('nagari');
|
||||||
|
expect(n.totals).toEqual([3, -3]);
|
||||||
|
// option off
|
||||||
|
expect(act(rigged(2, { hands: [[2, 40], [11]], floor: [0, 20], deck: [3, 22] }, { ppeokBonus: false }), 'A', play(2)).totals).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('광팔기 (§5.8)', () => {
|
||||||
|
const H = [[2, 3, 6, 7, 10, 11, 14], [0, 8, 15, 18, 19, 22, 23], [26, 27, 30, 31, 34, 35, 38], [39, 42, 43, 44, 47, 1, 5]];
|
||||||
|
const F = [4, 9, 13, 16, 17, 20];
|
||||||
|
const mk = (n: number, opts: Partial<GostopOptions> = {}, extra: number[][] = []) =>
|
||||||
|
gostop.setup({ players: players(n), options: parse({ chongtong: 'off', ...opts }), rng: scripted([[...H.flat(), ...extra.flat(), ...F]]), now: 0 });
|
||||||
|
test('4인: 선 다음 사람부터, 선은 sell 불가, 광 2장 → 선 제외 참가자 2명이 각 2점, 더미 21장', () => {
|
||||||
|
const s = mk(4);
|
||||||
|
expect(s.round!.step).toBe('sell');
|
||||||
|
expect(gostop.activePlayers(s)).toEqual(['B']);
|
||||||
|
expect(reject(s, 'A', { type: 'sell', sell: true })).toBe('선은 광을 팔 수 없어요.');
|
||||||
|
expect(reject(s, 'C', { type: 'sell', sell: true })).toBe('지금은 내가 정할 차례가 아니에요.');
|
||||||
|
expect(gostop.view(s, 'B').sell!.mySellable!.map((c) => c.id)).toEqual([0, 8]);
|
||||||
|
expect(gostop.view(s, 'C').sell!.mySellable).toBeNull();
|
||||||
|
const t = act(s, 'B', { type: 'sell', sell: true });
|
||||||
|
const r = t.round!;
|
||||||
|
expect(r.participants).toEqual([0, 2, 3]);
|
||||||
|
expect(r.sellers).toEqual([{ idx: 1, shown: [0, 8], paid: 4 }]);
|
||||||
|
expect(t.totals).toEqual([0, 4, -2, -2]);
|
||||||
|
expect(r.hands[1]).toEqual([]);
|
||||||
|
expect(r.deck).toHaveLength(21);
|
||||||
|
expect(r.step).toBe('play');
|
||||||
|
expect(r.sellDecisions.map((d) => [d.idx, d.sell, d.auto])).toEqual([[1, true, false], [2, false, true], [3, false, true]]);
|
||||||
|
expect(cardsInRound(t)).toBe(48);
|
||||||
|
// Seller's unshown cards and the deck stay secret; the shown 광 are public.
|
||||||
|
const v = JSON.stringify(gostop.view(t, 'A'));
|
||||||
|
for (const c of [15, 18, 19]) expect(v).not.toContain(`"r1c${c}"`);
|
||||||
|
expect(v).toContain('"r1sc0"');
|
||||||
|
});
|
||||||
|
test('치기 3명 확정 시 나머지 자동 죽기; allPlayers 지불; 시간 초과 → 죽기', () => {
|
||||||
|
let s = act(act(mk(4), 'B', { type: 'sell', sell: false }), 'C', { type: 'sell', sell: false });
|
||||||
|
expect(s.round!.participants).toEqual([0, 1, 2]);
|
||||||
|
expect(s.round!.sellers).toEqual([{ idx: 3, shown: [44], paid: 2 }]);
|
||||||
|
s = act(mk(4, { gwangPayer: 'allPlayers', gwangPrice: 2 }), 'B', { type: 'timeout' });
|
||||||
|
expect(s.round!.sellers).toEqual([{ idx: 1, shown: [0, 8], paid: 12 }]);
|
||||||
|
expect(s.totals).toEqual([-4, 12, -4, -4]);
|
||||||
|
s = act(mk(4, { gwangSellCards: 'gwangPlus' }), 'B', { type: 'sell', sell: false });
|
||||||
|
expect(s.round!.step).toBe('sell');
|
||||||
|
s = act(s, 'C', { type: 'sell', sell: true });
|
||||||
|
expect(s.round!.sellers[0]!.shown).toEqual([]);
|
||||||
|
});
|
||||||
|
test('5인: 2명 죽으면 나머지는 자동 치기, 더미 21장', () => {
|
||||||
|
const s = act(act(mk(5, {}, [[12, 24, 25, 28, 29, 32, 33]]), 'B', { type: 'sell', sell: true }), 'C', { type: 'sell', sell: true });
|
||||||
|
expect(s.round!.participants).toEqual([0, 3, 4]);
|
||||||
|
expect(s.round!.deck).toHaveLength(21);
|
||||||
|
expect(s.totals.reduce((a, b) => a + b, 0)).toBe(0);
|
||||||
|
expect(gostop.playersFor!(parse({ gwangPalgi: false }))).toEqual({ min: 2, max: 3 });
|
||||||
|
expect(gostop.playerCountError!(parse({ gwangPalgi: false }), 4)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- timeouts, leaving, info
|
||||||
|
|
||||||
|
describe('시간 초과·연결 끊김 (§8.5)', () => {
|
||||||
|
test('play 자동 행동: 빈패 → 보너스패 → 가장 값진 바닥 패 → 가장 싼 패 (결정적)', () => {
|
||||||
|
const s = rigged(2, { hands: [[30, 18, 0], [11]], floor: [28, 16], deck: [36, 37] });
|
||||||
|
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 30 });
|
||||||
|
expect(autoPlay(structuredClone(s), 0)).toEqual(autoPlay(s, 0));
|
||||||
|
s.round!.hands[0] = [0, 3, 44];
|
||||||
|
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 3 });
|
||||||
|
s.round!.floor = [{ month: 1, cards: [1, 2], ppeokBy: null, natural: false }];
|
||||||
|
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 0, target: 1 });
|
||||||
|
s.round!.hands[0] = [0, 3, 49];
|
||||||
|
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 49 });
|
||||||
|
s.round!.bombDummies[0] = 1;
|
||||||
|
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 'dummy' });
|
||||||
|
});
|
||||||
|
test('2회 연속 시간 초과 → autoPilot, 마감 2초; 직접 행동하면 해제', () => {
|
||||||
|
let s = rigged(2, { hands: [[2, 6, 10], [11, 15, 19]], floor: [20], deck: [24, 36, 37, 38, 39, 42, 43] });
|
||||||
|
s = act(s, 'A', { type: 'timeout' }, 10_000);
|
||||||
|
expect(s.autoPilot[0]).toBe(false);
|
||||||
|
expect(s.deadline).toBe(10_000 + 15_000);
|
||||||
|
s = act(s, 'B', play(11), 11_000);
|
||||||
|
s = act(s, 'A', { type: 'timeout' }, 12_000);
|
||||||
|
expect(s.autoPilot[0]).toBe(true);
|
||||||
|
s = act(s, 'B', play(15), 13_000);
|
||||||
|
expect(s.deadline).toBe(13_000 + 2_000);
|
||||||
|
s = act(s, 'A', play(gostop.view(s, 'A').myHand![0]!.card.id), 14_000);
|
||||||
|
expect(s.autoPilot[0]).toBe(false);
|
||||||
|
});
|
||||||
|
test('나가기: autoPilot, 끊긴 사람 차례 마감 2초, 결과 화면 준비에서 빠짐', () => {
|
||||||
|
let s = rigged(2, { hands: [[2, 6], [11, 15]], floor: [20], deck: [24, 36, 37, 38] });
|
||||||
|
s = act(s, 'A', { type: 'leave' }, 5000);
|
||||||
|
expect(s.left[0]).toBe(true);
|
||||||
|
expect(s.deadline).toBe(7000);
|
||||||
|
expect(gostop.onLeave!(s, 'A')).toBeNull();
|
||||||
|
expect(reject(s, 'A', play(2))).toBe('방을 나간 플레이어예요.');
|
||||||
|
s = act(s, 'A', { type: 'timeout' });
|
||||||
|
s = act(s, 'B', play(11));
|
||||||
|
s = act(s, 'A', { type: 'timeout' });
|
||||||
|
s = act(s, 'B', play(15));
|
||||||
|
expect(s.phase).toBe('roundResult');
|
||||||
|
expect(gostop.activePlayers(s)).toEqual(['B']);
|
||||||
|
s = act(s, 'B', { type: 'ready' });
|
||||||
|
expect(s.roundNo).toBe(2);
|
||||||
|
});
|
||||||
|
test('onTimeout는 시스템 전용 행동이고 actionSchema에 없음', () => {
|
||||||
|
expect(gostop.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
|
||||||
|
expect(gostop.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
|
||||||
|
expect(gostop.actionSchema.safeParse({ type: 'play', card: 'dummy' }).success).toBe(true);
|
||||||
|
expect(gostop.actionSchema.safeParse({ type: 'play', card: 3, target: 2, shake: true }).success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('options', () => {
|
||||||
|
test('defaults by mode and Korean metadata', () => {
|
||||||
|
expect(gostop.defaultOptions).toMatchObject({ rounds: 10, bonusCards: 'auto', winScore: 'auto', turnSeconds: 15, decisionSeconds: 10 });
|
||||||
|
const shape = (gostop.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
|
||||||
|
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
|
||||||
|
expect(() => gostop.setup({ players: players(4), options: parse({ gwangPalgi: false }), rng: rng(), now: 0 })).toThrow();
|
||||||
|
expect(gostop.playersFor!(gostop.defaultOptions)).toEqual({ min: 2, max: 5 });
|
||||||
|
});
|
||||||
|
test('rounds 판을 마치면 끝, 방 점수 순위(동점 공동), 요약은 점수만', () => {
|
||||||
|
let s = rigged(2, { hands: [[25], [11]], floor: [24], deck: [36, 37], captured: [[0, 8, 28, 1, 5, 9, 13, 33], [44]] }, { rounds: 1 });
|
||||||
|
s = act(s, 'A', play(25));
|
||||||
|
expect(s.phase).toBe('finished');
|
||||||
|
const res = gostop.result(s)!;
|
||||||
|
expect(res.ranking).toEqual([['A'], ['B']]);
|
||||||
|
expect(res.scores).toEqual({ A: 8, B: -8 });
|
||||||
|
expect(res.summary).toBe('1판 끝. 1위 +8점');
|
||||||
|
expect(gostop.activePlayers(s)).toEqual([]);
|
||||||
|
expect(gostop.deadline!(s)).toBeNull();
|
||||||
|
const tie = act(rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36] }, { rounds: 1 }), 'A', play(2));
|
||||||
|
const t2 = act(tie, 'B', play(11));
|
||||||
|
expect(gostop.result(t2)!.ranking).toEqual([['A', 'B']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- properties
|
||||||
|
|
||||||
|
/** Per-step invariants: card conservation, no duplicates, zero-sum points, hand/deck balance. */
|
||||||
|
function invariants(s: GostopState): void {
|
||||||
|
const r = s.round;
|
||||||
|
const sum = s.totals.reduce((a, b) => a + b, 0);
|
||||||
|
if (sum !== 0) throw new Error(`points not zero-sum: ${sum}`);
|
||||||
|
if (!r) return;
|
||||||
|
const all = [...r.hands.flat(), ...r.deck, ...r.floor.flatMap((g) => g.cards), ...r.captured.flat(), ...(r.pending ? [r.pending.flipped] : [])];
|
||||||
|
if (all.length !== 48 + s.rules.nb) throw new Error(`card count ${all.length}`);
|
||||||
|
if (new Set(all).size !== all.length) throw new Error('duplicate card');
|
||||||
|
for (const g of r.floor) if (g.cards.length < 1 || g.cards.length > 3 || g.cards.some((c) => Math.floor(c / 4) + 1 !== g.month || isBonus(c))) throw new Error('bad floor group');
|
||||||
|
if (r.step === 'play' || r.step === 'goStop' || r.step === 'chooseFlip') {
|
||||||
|
// Deck = Σ(regular hand cards + 빈패) + 2 × hand bonus + deck bonus (§5.5, §5.6). A pending turn already flipped.
|
||||||
|
let need = 0;
|
||||||
|
for (const p of r.participants) need += r.hands[p]!.filter((c) => !isBonus(c)).length + r.bombDummies[p]! + 2 * r.hands[p]!.filter(isBonus).length;
|
||||||
|
need += r.deck.filter(isBonus).length;
|
||||||
|
if (r.deck.length !== need) throw new Error(`deck ${r.deck.length} vs needed ${need} (step ${r.step})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIGS: [string, Partial<GostopOptions>][] = [
|
||||||
|
['기본', {}],
|
||||||
|
['보너스 3+1·국진 ask·총통 다시', { bonusCards: '3+1', gukjinMode: 'ask', chongtong: 'redeal' }],
|
||||||
|
['마지막 쓸 인정·자뻑 1·멍박', { lastTurnSweepBonus: true, jaPpeokSteal: 1, meongBak: true, winScore: 3 }],
|
||||||
|
['광팔기 전부·모두 지불·총통 off', { gwangSellCards: 'gwangPlus', gwangPayer: 'allPlayers', chongtong: 'off', gwangPrice: 3 }],
|
||||||
|
['흔들기·폭탄 끔·고박 끔', { shake: false, bomb: false, goBak: false, ppeokBonus: false, nagariMultiplier: 'none' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('random playouts', () => {
|
||||||
|
test('1,000 seeds: invariants, every legal action validates, onTimeout legal in every state', () => {
|
||||||
|
let rounds = 0;
|
||||||
|
let stops = 0;
|
||||||
|
for (let seed = 1; seed <= 1000; seed++) {
|
||||||
|
const n = 2 + (seed % 4);
|
||||||
|
const ps = players(n);
|
||||||
|
const [, opts] = CONFIGS[seed % CONFIGS.length]!;
|
||||||
|
const options = parse({ rounds: seed % 7 === 0 ? 3 : 1, ...opts });
|
||||||
|
const r = SeededRng.fromSeed(seedN(seed));
|
||||||
|
const chooser = SeededRng.fromSeed(seedN(seed + 9999));
|
||||||
|
let s = gostop.setup({ players: ps, options, rng: r, now: 0 });
|
||||||
|
let steps = 0;
|
||||||
|
for (; steps < 2000 && !gostop.result(s); steps++) {
|
||||||
|
invariants(s);
|
||||||
|
const actives = gostop.activePlayers(s);
|
||||||
|
if (actives.length === 0) throw new Error(`seed ${seed}: nobody to act`);
|
||||||
|
for (const p of actives) {
|
||||||
|
const v = gostop.validate(s, p, gostop.onTimeout(s, p));
|
||||||
|
if (!v.ok) throw new Error(`seed ${seed}: timeout rejected ${v.reason}`);
|
||||||
|
}
|
||||||
|
const actor = actives[chooser.int(actives.length)]!;
|
||||||
|
const legal = gostop.legalActions!(s, actor);
|
||||||
|
for (const a of legal) {
|
||||||
|
const v = gostop.validate(s, actor, a);
|
||||||
|
if (!v.ok) throw new Error(`seed ${seed}: legal action rejected ${JSON.stringify(a)}: ${v.reason}`);
|
||||||
|
}
|
||||||
|
const a = chooser.int(6) === 0 ? gostop.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
|
||||||
|
const before = s.roundNo;
|
||||||
|
s = gostop.apply(s, actor, a, { rng: r, now: steps * 1000 }).state;
|
||||||
|
if (s.roundNo !== before || s.phase !== 'playing') {
|
||||||
|
if (s.round?.outcome || s.phase === 'finished') {
|
||||||
|
rounds++;
|
||||||
|
if (s.round?.outcome?.type === 'stop') stops++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!gostop.result(s)) throw new Error(`seed ${seed}: did not finish`);
|
||||||
|
invariants(s);
|
||||||
|
expect(gostop.result(s)!.ranking.flat().sort()).toEqual([...ps].sort());
|
||||||
|
}
|
||||||
|
expect(rounds).toBeGreaterThan(900);
|
||||||
|
expect(stops).toBeGreaterThan(100);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test('engine randomPlayout (leaks, JSON round-trip, determinism) in every mode', () => {
|
||||||
|
for (const n of [2, 3, 4, 5]) {
|
||||||
|
for (let i = 0; i < CONFIGS.length; i++) {
|
||||||
|
const options = parse({ rounds: 1, ...CONFIGS[i]![1] });
|
||||||
|
const ps = players(n);
|
||||||
|
const a = randomPlayout(gostop, { players: ps, options, seed: seedN(100 * n + i), maxSteps: 3000, maxApplyMs: 50, invariant: (st) => invariants(st as GostopState) });
|
||||||
|
expect(a.finished).toBe(true);
|
||||||
|
if (i === 0) {
|
||||||
|
const b = randomPlayout(gostop, { players: ps, options, seed: seedN(100 * n + i), maxSteps: 3000, maxApplyMs: 50 });
|
||||||
|
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
|
||||||
|
// Same seed + same action log → same result.
|
||||||
|
const rr = SeededRng.fromSeed(seedN(100 * n + i));
|
||||||
|
let s = gostop.setup({ players: ps, options, rng: rr, now: 1_700_000_000_000 });
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
for (const t of a.trace) s = gostop.apply(s, t.actor, t.action as GostopAction, { rng: rr, now: (now += 1000) }).state;
|
||||||
|
expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test('leak property: views never contain hidden hands / deck, events only reveal public cards', () => {
|
||||||
|
for (let seed = 1; seed <= 300; seed++) {
|
||||||
|
const n = 2 + (seed % 4);
|
||||||
|
const ps = players(n);
|
||||||
|
const options = parse({ ...CONFIGS[seed % CONFIGS.length]![1], rounds: 1 });
|
||||||
|
const r = SeededRng.fromSeed(seedN(seed + 5));
|
||||||
|
const chooser = SeededRng.fromSeed(seedN(seed + 77));
|
||||||
|
let s = gostop.setup({ players: ps, options, rng: r, now: 0 });
|
||||||
|
for (let step = 0; step < 120 && !gostop.result(s); step++) {
|
||||||
|
const views = new Map([...ps, null].map((v) => [v, JSON.stringify(gostop.view(s, v))]));
|
||||||
|
for (const owner of ps)
|
||||||
|
for (const secret of gostop.secretsOf!(s, owner))
|
||||||
|
for (const [viewer, json] of views)
|
||||||
|
if (viewer !== owner && json.includes(JSON.stringify(secret))) throw new Error(`seed ${seed}: leak ${secret} of ${owner} to ${viewer}`);
|
||||||
|
const actives = gostop.activePlayers(s);
|
||||||
|
const actor = actives[chooser.int(actives.length)]!;
|
||||||
|
const legal = gostop.legalActions!(s, actor);
|
||||||
|
const a = chooser.int(5) === 0 ? gostop.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
|
||||||
|
const out = gostop.apply(s, actor, a, { rng: r, now: step * 1000 });
|
||||||
|
const hidden = new Set<number>();
|
||||||
|
const nr = out.state.round!;
|
||||||
|
for (let p = 0; p < n; p++) for (const c of nr.hands[p]!) if (!nr.revealed[p]!.includes(c)) hidden.add(c);
|
||||||
|
for (const c of nr.deck) hidden.add(c);
|
||||||
|
for (const e of out.events) {
|
||||||
|
if (e.type === 'gwangSold') continue; // shown 광 are public even though they go back into the deck
|
||||||
|
const ids = [e.card, ...((e.cards as number[] | undefined) ?? [])].filter((x): x is number => typeof x === 'number');
|
||||||
|
for (const id of ids) if (hidden.has(id)) throw new Error(`seed ${seed}: event ${e.type} leaks ${id}`);
|
||||||
|
}
|
||||||
|
s = out.state;
|
||||||
|
}
|
||||||
|
const deck = s.round!.deck;
|
||||||
|
if (deck.length > 3) for (const v of [...ps, null]) expect(JSON.stringify(gostop.view(s, v))).not.toContain(JSON.stringify(deck).slice(1, -1));
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
612
packages/games/src/gostop/index.ts
Normal file
612
packages/games/src/gostop/index.ts
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
/** 고스톱 / 맞고 / 광팔기 (docs/games/gostop.md). Points are room-only play points (§12). */
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
|
||||||
|
import { GUKJIN, bestCard, cardInfo, cardView, isBonus, monthOf, type CardId, type CardView } from './cards';
|
||||||
|
import {
|
||||||
|
autoGukjin,
|
||||||
|
autoPlay,
|
||||||
|
chooseFlip,
|
||||||
|
currentScore,
|
||||||
|
decideChongtong,
|
||||||
|
decideGoStop,
|
||||||
|
decideGukjin,
|
||||||
|
decideSell,
|
||||||
|
floorCount,
|
||||||
|
floorGroup,
|
||||||
|
handMonthCount,
|
||||||
|
hasChongtong,
|
||||||
|
needsTarget,
|
||||||
|
playCard,
|
||||||
|
resolveRules,
|
||||||
|
sellDecider,
|
||||||
|
sellableCards,
|
||||||
|
settleInput,
|
||||||
|
startRound,
|
||||||
|
type Ctx,
|
||||||
|
} from './round';
|
||||||
|
import { bestScore, goFactor, scoreOf, settle } from './scoring';
|
||||||
|
import {
|
||||||
|
gostopActionSchema,
|
||||||
|
gostopOptionsSchema,
|
||||||
|
type GostopAction,
|
||||||
|
type GostopOptions,
|
||||||
|
type GostopState,
|
||||||
|
type GostopView,
|
||||||
|
type HandCardView,
|
||||||
|
type OutcomeView,
|
||||||
|
type RoundState,
|
||||||
|
type ScoreView,
|
||||||
|
type SeatView,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export * from './cards';
|
||||||
|
export * from './types';
|
||||||
|
export { bestScore, goFactor, scoreOf, settle, type ScoreCalc, type SettleInput, type Settlement } from './scoring';
|
||||||
|
export { autoPlay, resolveRules } from './round';
|
||||||
|
|
||||||
|
const RESULT_MS = 10_000;
|
||||||
|
const AUTO_MS = 2_000;
|
||||||
|
|
||||||
|
export function playerRange(o: Pick<GostopOptions, 'gwangPalgi'>): { min: number; max: number } {
|
||||||
|
return { min: 2, max: o.gwangPalgi ? 5 : 3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyOf = (s: GostopState) => `r${s.roundNo}`;
|
||||||
|
const cv = (s: GostopState, id: CardId): CardView => cardView(id, s.rules.nb, keyOf(s));
|
||||||
|
const idx = (s: GostopState, p: PlayerId): number => s.players.indexOf(p);
|
||||||
|
|
||||||
|
/** Total cards in a round (hands + deck + floor + captured + pending flip) — 48 + bonus. */
|
||||||
|
export function cardsInRound(s: GostopState): number {
|
||||||
|
const r = s.round;
|
||||||
|
if (!r) return 0;
|
||||||
|
return (
|
||||||
|
r.hands.reduce((a, h) => a + h.length, 0) +
|
||||||
|
r.deck.length +
|
||||||
|
r.floor.reduce((a, g) => a + g.cards.length, 0) +
|
||||||
|
r.captured.reduce((a, c) => a + c.length, 0) +
|
||||||
|
(r.pending ? 1 : 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Players who must act now (§8.2 activePlayers). */
|
||||||
|
function waiting(s: GostopState): number[] {
|
||||||
|
if (s.phase === 'finished') return [];
|
||||||
|
if (s.phase === 'roundResult') return s.players.map((_, i) => i).filter((i) => !s.left[i] && !s.ready.includes(i));
|
||||||
|
const r = s.round;
|
||||||
|
if (!r) return [];
|
||||||
|
if (r.step === 'sell') {
|
||||||
|
const d = sellDecider(s);
|
||||||
|
return d === null ? [] : [d];
|
||||||
|
}
|
||||||
|
if (r.step === 'done') return [];
|
||||||
|
return [r.turn];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deadlineMs(s: GostopState): number | null {
|
||||||
|
if (s.phase === 'finished') return null;
|
||||||
|
if (s.phase === 'roundResult') return RESULT_MS;
|
||||||
|
const w = waiting(s);
|
||||||
|
if (w.length === 0) return null;
|
||||||
|
if (w.every((i) => s.autoPilot[i])) return AUTO_MS;
|
||||||
|
const step = s.round!.step;
|
||||||
|
return (step === 'play' ? s.rules.turnSeconds : s.rules.decisionSeconds) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDeadline(s: GostopState, now: number): void {
|
||||||
|
const r = s.round;
|
||||||
|
const w = waiting(s);
|
||||||
|
const key = s.phase === 'roundResult' ? `rr|${s.roundNo}` : `${s.phase}|${s.roundNo}|${r?.step}|${w.join(',')}|${s.actionSeq}|${w.every((i) => s.autoPilot[i])}`;
|
||||||
|
const ms = deadlineMs(s);
|
||||||
|
if (key !== s.waitKey) {
|
||||||
|
s.waitKey = key;
|
||||||
|
s.deadline = ms === null ? null : now + ms;
|
||||||
|
}
|
||||||
|
if (ms === null) s.deadline = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the next round once everybody still at the table is ready. */
|
||||||
|
function progress(s: GostopState, ctx: Ctx): void {
|
||||||
|
if (s.phase === 'roundResult' && waiting(s).length === 0) startRound(s, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- legality
|
||||||
|
|
||||||
|
function validatePlay(s: GostopState, p: number, a: Extract<GostopAction, { type: 'play' }>) {
|
||||||
|
const r = s.round!;
|
||||||
|
if (a.card === 'dummy') {
|
||||||
|
if (r.bombDummies[p]! <= 0) return fail('폭탄 빈패가 없어요.');
|
||||||
|
if (a.target !== undefined || a.shake || a.bomb) return fail('빈패는 그냥 내기만 할 수 있어요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
if (!r.hands[p]!.includes(a.card)) return fail('손에 없는 패예요.');
|
||||||
|
if (isBonus(a.card)) {
|
||||||
|
if (a.target !== undefined || a.shake || a.bomb) return fail('보너스패는 그냥 내기만 할 수 있어요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
const m = monthOf(a.card);
|
||||||
|
const k = floorCount(r, m);
|
||||||
|
const inHand = handMonthCount(r, p, m);
|
||||||
|
if (a.bomb) {
|
||||||
|
if (!s.rules.bomb) return fail('이 방은 폭탄을 쓰지 않아요.');
|
||||||
|
if (a.shake) return fail('폭탄과 흔들기는 함께 할 수 없어요.');
|
||||||
|
if (inHand !== 3 || k !== 1) return fail('폭탄은 같은 월 3장과 바닥 1장이 있어야 해요.');
|
||||||
|
if (a.target !== undefined) return fail('폭탄은 가져올 패를 고르지 않아요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
if (a.shake) {
|
||||||
|
if (!s.rules.shake) return fail('이 방은 흔들기를 쓰지 않아요.');
|
||||||
|
if (inHand < 3 || k !== 0) return fail('흔들기는 같은 월 3장을 가지고 있고 바닥에 그 월이 없을 때만 할 수 있어요.');
|
||||||
|
if (r.shakenMonths[p]!.includes(m)) return fail('이 월은 이미 흔들었어요.');
|
||||||
|
}
|
||||||
|
if (k === 2) {
|
||||||
|
const g = floorGroup(r, m)!;
|
||||||
|
if (a.target !== undefined && !g.cards.includes(a.target)) return fail('바닥에 있는 같은 월 패 중에서 골라 주세요.');
|
||||||
|
if (a.target === undefined && needsTarget(s, m)) return fail('가져올 패를 골라 주세요.');
|
||||||
|
} else if (a.target !== undefined) return fail('고를 패가 없어요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legalFor(s: GostopState, p: number): GostopAction[] {
|
||||||
|
if (s.phase === 'finished' || s.left[p]) return [];
|
||||||
|
if (s.phase === 'roundResult') return s.ready.includes(p) ? [] : [{ type: 'ready' }];
|
||||||
|
const r = s.round!;
|
||||||
|
if (!waiting(s).includes(p)) return [];
|
||||||
|
switch (r.step) {
|
||||||
|
case 'sell':
|
||||||
|
return [
|
||||||
|
{ type: 'sell', sell: true },
|
||||||
|
{ type: 'sell', sell: false },
|
||||||
|
];
|
||||||
|
case 'chongtong':
|
||||||
|
return [
|
||||||
|
{ type: 'chongtong', declare: true },
|
||||||
|
{ type: 'chongtong', declare: false },
|
||||||
|
];
|
||||||
|
case 'chooseFlip':
|
||||||
|
return r.pending!.options.map((target) => ({ type: 'chooseFlip', target }));
|
||||||
|
case 'goStop':
|
||||||
|
return [
|
||||||
|
{ type: 'goStop', decision: 'go' },
|
||||||
|
{ type: 'goStop', decision: 'stop' },
|
||||||
|
];
|
||||||
|
case 'gukjin':
|
||||||
|
return [
|
||||||
|
{ type: 'gukjin', as: 'yeol' },
|
||||||
|
{ type: 'gukjin', as: 'pi' },
|
||||||
|
];
|
||||||
|
case 'play': {
|
||||||
|
const out: GostopAction[] = [];
|
||||||
|
if (r.bombDummies[p]! > 0) out.push({ type: 'play', card: 'dummy' });
|
||||||
|
const bombed = new Set<number>();
|
||||||
|
for (const c of [...r.hands[p]!].sort((a, b) => a - b)) {
|
||||||
|
if (isBonus(c)) {
|
||||||
|
out.push({ type: 'play', card: c });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = monthOf(c);
|
||||||
|
const g = floorGroup(r, m);
|
||||||
|
if (g && g.cards.length === 2 && needsTarget(s, m)) for (const target of g.cards) out.push({ type: 'play', card: c, target });
|
||||||
|
else out.push({ type: 'play', card: c });
|
||||||
|
const n = handMonthCount(r, p, m);
|
||||||
|
if (s.rules.shake && n >= 3 && !g && !r.shakenMonths[p]!.includes(m)) out.push({ type: 'play', card: c, shake: true });
|
||||||
|
if (s.rules.bomb && n === 3 && g?.cards.length === 1 && !bombed.has(m)) {
|
||||||
|
bombed.add(m);
|
||||||
|
out.push({ type: 'play', card: c, bomb: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- view helpers
|
||||||
|
|
||||||
|
function scoreView(s: GostopState, cards: CardId[], as?: 'yeol' | 'pi'): ScoreView {
|
||||||
|
const { gwangPoints: _g, ...rest } = as ? scoreOf(cards, s.rules, as) : bestScore(cards, s.rules).calc;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grouped(s: GostopState, cards: CardId[], gukjinAsPi: boolean): SeatView['captured'] {
|
||||||
|
const out: SeatView['captured'] = { gwang: [], yeol: [], tti: [], pi: [] };
|
||||||
|
const sorted = [...cards].sort((a, b) => cardInfo(a, s.rules.nb).piValue - cardInfo(b, s.rules.nb).piValue || a - b);
|
||||||
|
for (const c of sorted) {
|
||||||
|
const k = c === GUKJIN && gukjinAsPi ? 'pi' : cardInfo(c, s.rules.nb).kind;
|
||||||
|
out[k].push(cv(s, c));
|
||||||
|
}
|
||||||
|
for (const k of ['gwang', 'yeol', 'tti'] as const) out[k].sort((a, b) => a.id - b.id);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handHint(s: GostopState, p: number, c: CardId): string | null {
|
||||||
|
const r = s.round!;
|
||||||
|
const g = floorGroup(r, monthOf(c));
|
||||||
|
if (!g) return null;
|
||||||
|
const take = g.cards.length === 2 ? [bestCard(g.cards, s.rules.nb)] : g.cards;
|
||||||
|
const before = bestScore(r.captured[p]!, s.rules).calc;
|
||||||
|
const after = bestScore([...r.captured[p]!, c, ...take], s.rules).calc;
|
||||||
|
const base = (n: string) => n.replace(/ .*$/, '');
|
||||||
|
const had = new Set(before.items.map((x) => base(x.name)));
|
||||||
|
const fresh = after.items.find((x) => !had.has(base(x.name)));
|
||||||
|
if (fresh) return `${base(fresh.name)} 완성`;
|
||||||
|
if (after.total > before.total) return `+${after.total - before.total}점`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goStopPreview(s: GostopState, p: number): GostopView['goStop'] {
|
||||||
|
const r = s.round!;
|
||||||
|
const st = settle(settleInput(s, p));
|
||||||
|
const score = currentScore(s, p);
|
||||||
|
const notes = st.multipliers.map((m) => `${m.name} x${m.factor}`);
|
||||||
|
const next = r.goCount[p]! + 1;
|
||||||
|
return {
|
||||||
|
player: s.players[p]!,
|
||||||
|
score,
|
||||||
|
goCount: r.goCount[p]!,
|
||||||
|
stopNow: {
|
||||||
|
total: st.payments.reduce((a, x) => a + x.amount, 0),
|
||||||
|
perLoser: st.payments.map((x) => ({ player: s.players[x.from]!, amount: x.amount, baks: x.baks })),
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
goNext: { goCount: next, plus: next, factor: goFactor(next) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeView(s: GostopState, r: RoundState): OutcomeView | null {
|
||||||
|
const o = r.outcome;
|
||||||
|
if (!o) return null;
|
||||||
|
const log = s.log.find((l) => l.no === r.no);
|
||||||
|
return {
|
||||||
|
...o,
|
||||||
|
winner: o.winner === null ? null : s.players[o.winner]!,
|
||||||
|
payments: o.payments.map((x) => ({ from: s.players[x.from]!, amount: x.amount, baks: x.baks, coveredBy: x.coveredBy === undefined ? null : s.players[x.coveredBy]! })),
|
||||||
|
shown: o.shown.map((c) => cv(s, c)),
|
||||||
|
delta: Object.fromEntries(s.players.map((id, i) => [id, log?.delta[i] ?? 0])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- definition
|
||||||
|
|
||||||
|
export const gostop: GameDefinition<GostopState, GostopAction, GostopView, GostopOptions> = {
|
||||||
|
id: 'gostop',
|
||||||
|
nameKo: '고스톱',
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 5,
|
||||||
|
stateVersion: 1,
|
||||||
|
defaultOptions: gostopOptionsSchema.parse({}),
|
||||||
|
optionsSchema: gostopOptionsSchema as unknown as z.ZodType<GostopOptions>,
|
||||||
|
actionSchema: gostopActionSchema as unknown as z.ZodType<GostopAction>,
|
||||||
|
playersFor: playerRange,
|
||||||
|
playerCountError(o, count) {
|
||||||
|
return count > 3 && !o.gwangPalgi ? '광팔기를 끄면 3명까지만 할 수 있어요.' : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setup({ players, options, rng, now }) {
|
||||||
|
const range = playerRange(options);
|
||||||
|
if (players.length < range.min || players.length > range.max) throw new Error(`gostop: ${players.length} players not allowed`);
|
||||||
|
const n = players.length;
|
||||||
|
const zeros = () => Array.from({ length: n }, () => 0);
|
||||||
|
const s: GostopState = {
|
||||||
|
options,
|
||||||
|
rules: resolveRules(options, n),
|
||||||
|
mode: n === 2 ? 'matgo' : 'gostop',
|
||||||
|
players: [...players],
|
||||||
|
totals: zeros(),
|
||||||
|
roundNo: 0,
|
||||||
|
dealer: rng.int(n),
|
||||||
|
nagariStreak: 0,
|
||||||
|
round: null,
|
||||||
|
phase: 'playing',
|
||||||
|
ready: [],
|
||||||
|
left: players.map(() => false),
|
||||||
|
autoPilot: players.map(() => false),
|
||||||
|
timeoutStreak: zeros(),
|
||||||
|
deadline: null,
|
||||||
|
waitKey: '',
|
||||||
|
actionSeq: 0,
|
||||||
|
log: [],
|
||||||
|
stats: { wins: zeros(), goCalls: zeros(), piBakGiven: zeros(), gwangSold: zeros(), nagari: 0 },
|
||||||
|
final: null,
|
||||||
|
};
|
||||||
|
startRound(s, { rng, events: [] });
|
||||||
|
refreshDeadline(s, now);
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
|
||||||
|
validate(s, actor, a) {
|
||||||
|
const p = idx(s, actor);
|
||||||
|
if (p < 0) return fail('이 게임의 플레이어가 아니에요.');
|
||||||
|
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
|
||||||
|
if (a.type === 'leave') return s.left[p] ? fail('이미 나간 플레이어예요.') : ok;
|
||||||
|
if (a.type === 'timeout') return waiting(s).includes(p) ? ok : fail('시간 초과 대상이 아니에요.');
|
||||||
|
if (s.left[p]) return fail('방을 나간 플레이어예요.');
|
||||||
|
if (a.type === 'ready') {
|
||||||
|
if (s.phase !== 'roundResult') return fail('아직 판이 끝나지 않았어요.');
|
||||||
|
if (s.ready.includes(p)) return fail('이미 준비했어요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
const r = s.round;
|
||||||
|
if (s.phase !== 'playing' || !r) return fail('지금은 할 수 없어요.');
|
||||||
|
const mine = waiting(s).includes(p);
|
||||||
|
switch (a.type) {
|
||||||
|
case 'sell':
|
||||||
|
if (r.step !== 'sell') return fail('지금은 광을 팔 때가 아니에요.');
|
||||||
|
if (p === s.dealer) return fail('선은 광을 팔 수 없어요.');
|
||||||
|
if (!mine) return fail('지금은 내가 정할 차례가 아니에요.');
|
||||||
|
return ok;
|
||||||
|
case 'chongtong':
|
||||||
|
if (r.step !== 'chongtong' || !mine) return fail('총통을 선언할 수 없어요.');
|
||||||
|
return ok;
|
||||||
|
case 'play':
|
||||||
|
if (r.step !== 'play') return fail(r.step === 'chooseFlip' ? '먼저 뒤집은 패로 가져올 패를 골라 주세요.' : '지금은 패를 낼 때가 아니에요.');
|
||||||
|
if (!mine) return fail('지금은 내 차례가 아니에요.');
|
||||||
|
return validatePlay(s, p, a);
|
||||||
|
case 'chooseFlip':
|
||||||
|
if (r.step !== 'chooseFlip' || !mine) return fail('지금은 고를 패가 없어요.');
|
||||||
|
if (!r.pending!.options.includes(a.target)) return fail('뒤집은 패와 같은 월인 바닥 패 중에서 골라 주세요.');
|
||||||
|
return ok;
|
||||||
|
case 'goStop':
|
||||||
|
if (r.step !== 'goStop' || !mine) return fail('지금은 고·스톱을 고를 때가 아니에요.');
|
||||||
|
if (a.decision === 'go' && r.hands[p]!.length === 0) return fail('마지막 턴에는 고를 할 수 없어요.');
|
||||||
|
return ok;
|
||||||
|
case 'gukjin':
|
||||||
|
if (r.step !== 'gukjin' || !mine) return fail('지금은 국진을 정할 때가 아니에요.');
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
return fail('알 수 없는 행동이에요.');
|
||||||
|
},
|
||||||
|
|
||||||
|
apply(prev, actor, a, { rng, now }) {
|
||||||
|
const s = structuredClone(prev);
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
const ctx: Ctx = { rng, events };
|
||||||
|
const p = idx(s, actor);
|
||||||
|
const r = s.round;
|
||||||
|
if (a.type !== 'leave') s.actionSeq += 1;
|
||||||
|
if (a.type !== 'timeout' && a.type !== 'leave') {
|
||||||
|
s.timeoutStreak[p] = 0;
|
||||||
|
if (s.autoPilot[p] && !s.left[p]) {
|
||||||
|
s.autoPilot[p] = false;
|
||||||
|
events.push({ type: 'autoPilot', player: actor, on: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let act: GostopAction = a;
|
||||||
|
if (a.type === 'timeout') {
|
||||||
|
events.push({ type: 'timeout', player: actor });
|
||||||
|
if (s.phase === 'roundResult') act = { type: 'ready' };
|
||||||
|
else {
|
||||||
|
s.timeoutStreak[p]! += 1;
|
||||||
|
if (s.timeoutStreak[p]! >= 2 && !s.autoPilot[p]) {
|
||||||
|
s.autoPilot[p] = true;
|
||||||
|
events.push({ type: 'autoPilot', player: actor, on: true });
|
||||||
|
}
|
||||||
|
switch (r!.step) {
|
||||||
|
case 'sell':
|
||||||
|
act = { type: 'sell', sell: true };
|
||||||
|
break;
|
||||||
|
case 'chongtong':
|
||||||
|
act = { type: 'chongtong', declare: true };
|
||||||
|
break;
|
||||||
|
case 'play':
|
||||||
|
act = autoPlay(s, p);
|
||||||
|
break;
|
||||||
|
case 'chooseFlip':
|
||||||
|
act = { type: 'chooseFlip', target: bestCard(r!.pending!.options, s.rules.nb) };
|
||||||
|
break;
|
||||||
|
case 'goStop':
|
||||||
|
act = { type: 'goStop', decision: 'stop' };
|
||||||
|
break;
|
||||||
|
case 'gukjin':
|
||||||
|
act = { type: 'gukjin', as: autoGukjin(s) };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`gostop: timeout in step ${r!.step}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (act.type) {
|
||||||
|
case 'leave':
|
||||||
|
s.left[p] = true;
|
||||||
|
s.autoPilot[p] = true;
|
||||||
|
events.push({ type: 'left', player: actor });
|
||||||
|
break;
|
||||||
|
case 'ready':
|
||||||
|
s.ready.push(p);
|
||||||
|
break;
|
||||||
|
case 'sell':
|
||||||
|
decideSell(s, p, act.sell, ctx);
|
||||||
|
break;
|
||||||
|
case 'chongtong':
|
||||||
|
decideChongtong(s, p, act.declare, ctx);
|
||||||
|
break;
|
||||||
|
case 'play':
|
||||||
|
playCard(s, p, act, ctx);
|
||||||
|
break;
|
||||||
|
case 'chooseFlip':
|
||||||
|
chooseFlip(s, act.target, ctx);
|
||||||
|
break;
|
||||||
|
case 'goStop':
|
||||||
|
decideGoStop(s, p, act.decision, ctx);
|
||||||
|
break;
|
||||||
|
case 'gukjin':
|
||||||
|
decideGukjin(s, p, act.as, ctx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
progress(s, ctx);
|
||||||
|
refreshDeadline(s, now);
|
||||||
|
return { state: s, events };
|
||||||
|
},
|
||||||
|
|
||||||
|
view(s, viewer) {
|
||||||
|
const r = s.round!;
|
||||||
|
const meIdx = viewer === null ? -1 : idx(s, viewer);
|
||||||
|
const me = meIdx >= 0 ? viewer : null;
|
||||||
|
const step = s.phase === 'playing' ? r.step : null;
|
||||||
|
const turnIdx = step && step !== 'sell' && step !== 'done' ? r.turn : null;
|
||||||
|
const gukjinPi = r.outcome?.gukjinAs === 'pi';
|
||||||
|
const holder = r.participants.find((q) => r.captured[q]!.includes(GUKJIN));
|
||||||
|
const seats: SeatView[] = s.players.map((id, i) => {
|
||||||
|
const seller = r.sellers.find((x) => x.idx === i);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
total: s.totals[i]!,
|
||||||
|
participant: r.participants.includes(i),
|
||||||
|
seller: seller ? { shown: seller.shown.map((c) => cardView(c, s.rules.nb, `${keyOf(s)}s`)), paid: seller.paid } : null,
|
||||||
|
handCount: r.hands[i]!.length,
|
||||||
|
dummies: r.bombDummies[i]!,
|
||||||
|
revealed: r.revealed[i]!.map((c) => cv(s, c)),
|
||||||
|
captured: grouped(s, r.captured[i]!, gukjinPi && holder === i),
|
||||||
|
score: scoreView(s, r.captured[i]!, r.outcome?.gukjinAs && holder === i ? r.outcome.gukjinAs : undefined),
|
||||||
|
goCount: r.goCount[i]!,
|
||||||
|
shakes: r.shakes[i]!,
|
||||||
|
bombs: r.bombs[i]!,
|
||||||
|
ppeok: r.ppeokCount[i]!,
|
||||||
|
left: s.left[i]!,
|
||||||
|
autoPilot: s.autoPilot[i]!,
|
||||||
|
ready: s.phase === 'roundResult' && s.ready.includes(i),
|
||||||
|
isDealer: r.participants.length ? r.participants[0] === i : s.dealer === i,
|
||||||
|
isTurn: turnIdx === i || (step === 'sell' && sellDecider(s) === i),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
let myHand: HandCardView[] | null = null;
|
||||||
|
if (me && s.phase === 'playing' && r.participants.includes(meIdx)) {
|
||||||
|
myHand = [...r.hands[meIdx]!]
|
||||||
|
.sort((a, b) => monthOf(a) - monthOf(b) || a - b)
|
||||||
|
.map((c) => {
|
||||||
|
if (isBonus(c)) return { card: cv(s, c), matches: [], needTarget: false, canShake: false, canBomb: false, hint: s.rules.hints ? '보너스패: 내고 한 장 더 받아요' : null };
|
||||||
|
const m = monthOf(c);
|
||||||
|
const g = floorGroup(r, m);
|
||||||
|
const n = handMonthCount(r, meIdx, m);
|
||||||
|
return {
|
||||||
|
card: cv(s, c),
|
||||||
|
matches: g ? [...g.cards] : [],
|
||||||
|
needTarget: !!g && g.cards.length === 2 && needsTarget(s, m),
|
||||||
|
canShake: s.rules.shake && n >= 3 && !g && !r.shakenMonths[meIdx]!.includes(m),
|
||||||
|
canBomb: s.rules.bomb && n === 3 && g?.cards.length === 1,
|
||||||
|
hint: s.rules.hints ? handHint(s, meIdx, c) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else if (me && r.step === 'sell' && s.phase === 'playing') {
|
||||||
|
myHand = [...r.hands[meIdx]!].sort((a, b) => monthOf(a) - monthOf(b) || a - b).map((c) => ({ card: cv(s, c), matches: [], needTarget: false, canShake: false, canBomb: false, hint: null }));
|
||||||
|
}
|
||||||
|
const decider = step === 'sell' ? sellDecider(s) : null;
|
||||||
|
const lt = r.lastTurn;
|
||||||
|
const stopper = step === 'gukjin' ? r.stopBy : null;
|
||||||
|
return {
|
||||||
|
rules: s.rules,
|
||||||
|
mode: s.mode,
|
||||||
|
me,
|
||||||
|
phase: s.phase,
|
||||||
|
step,
|
||||||
|
roundNo: s.roundNo,
|
||||||
|
dealer: s.players[r.participants[0] ?? s.dealer]!,
|
||||||
|
nagariStreak: s.nagariStreak,
|
||||||
|
nagariMult: r.nagariMult,
|
||||||
|
turn: turnIdx === null ? null : s.players[turnIdx]!,
|
||||||
|
deckCount: r.deck.length,
|
||||||
|
floor: r.floor.map((g) => ({ month: g.month, cards: g.cards.map((c) => cv(s, c)), ppeokBy: g.ppeokBy === null ? null : s.players[g.ppeokBy]!, natural: g.natural })),
|
||||||
|
seats,
|
||||||
|
myHand,
|
||||||
|
myDummies: meIdx >= 0 ? r.bombDummies[meIdx]! : 0,
|
||||||
|
pendingFlip:
|
||||||
|
step === 'chooseFlip' && r.pending
|
||||||
|
? { player: s.players[r.turn]!, flipped: cv(s, r.pending.flipped), options: r.pending.options.map((c) => cv(s, c)) }
|
||||||
|
: null,
|
||||||
|
goStop: step === 'goStop' ? goStopPreview(s, r.turn) : null,
|
||||||
|
gukjinAsk:
|
||||||
|
step === 'gukjin' && stopper !== null
|
||||||
|
? {
|
||||||
|
player: s.players[r.turn]!,
|
||||||
|
yeolScore: scoreOf(r.captured[r.turn]!, s.rules, 'yeol').total,
|
||||||
|
piScore: scoreOf(r.captured[r.turn]!, s.rules, 'pi').total,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
chongtongAsk: step === 'chongtong' ? { player: s.players[r.turn]!, months: meIdx === r.turn ? hasChongtong(r, r.turn) : null } : null,
|
||||||
|
sell:
|
||||||
|
r.sellOrder.length > 0
|
||||||
|
? {
|
||||||
|
current: decider === null ? null : s.players[decider]!,
|
||||||
|
decisions: r.sellDecisions.map((d) => ({ player: s.players[d.idx]!, sell: d.sell, auto: d.auto })),
|
||||||
|
mySellable: decider !== null && decider === meIdx ? sellableCards(s, meIdx).map((c) => cv(s, c)) : null,
|
||||||
|
canSell: decider !== null && decider !== s.dealer,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lastTurn: lt
|
||||||
|
? {
|
||||||
|
seq: lt.seq,
|
||||||
|
tags: lt.tags,
|
||||||
|
bonusPoints: lt.bonusPoints,
|
||||||
|
player: s.players[lt.player]!,
|
||||||
|
played: lt.played.map((c) => cv(s, c)),
|
||||||
|
flipped: lt.flipped.map((c) => cv(s, c)),
|
||||||
|
captured: lt.captured.map((c) => cv(s, c)),
|
||||||
|
stolen: lt.stolen.map(([q, cs]) => ({ from: s.players[q]!, cards: cs.map((c) => cv(s, c)) })),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
outcome: outcomeView(s, r),
|
||||||
|
log: s.log.map((l) => ({ ...l, winner: l.winner === null ? null : s.players[l.winner]!, delta: Object.fromEntries(s.players.map((id, i) => [id, l.delta[i] ?? 0])) })),
|
||||||
|
ready: s.ready.map((i) => s.players[i]!),
|
||||||
|
deadline: s.deadline,
|
||||||
|
result: s.final,
|
||||||
|
stats: statsView(s),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
activePlayers(s) {
|
||||||
|
return waiting(s).map((i) => s.players[i]!);
|
||||||
|
},
|
||||||
|
|
||||||
|
deadline(s) {
|
||||||
|
return s.phase === 'finished' ? null : s.deadline;
|
||||||
|
},
|
||||||
|
|
||||||
|
onTimeout() {
|
||||||
|
return { type: 'timeout' };
|
||||||
|
},
|
||||||
|
|
||||||
|
onLeave(s, player) {
|
||||||
|
const p = idx(s, player);
|
||||||
|
if (p < 0 || s.left[p] || s.phase === 'finished') return null;
|
||||||
|
return { type: 'leave' };
|
||||||
|
},
|
||||||
|
|
||||||
|
result(s): GameResult | null {
|
||||||
|
if (!s.final) return null;
|
||||||
|
return { ranking: s.final.ranking, scores: s.final.scores, summary: s.final.summary, reason: 'normal' };
|
||||||
|
},
|
||||||
|
|
||||||
|
legalActions(s, player) {
|
||||||
|
const p = idx(s, player);
|
||||||
|
return p < 0 ? [] : legalFor(s, p);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Unrevealed hand cards of `player` plus the deck order (keys as used in views). */
|
||||||
|
secretsOf(s, player) {
|
||||||
|
const r = s.round;
|
||||||
|
const p = idx(s, player);
|
||||||
|
if (!r || p < 0) return [];
|
||||||
|
const key = (c: CardId) => `${keyOf(s)}c${c}`;
|
||||||
|
const own = r.hands[p]!.filter((c) => !r.revealed[p]!.includes(c)).map(key);
|
||||||
|
return [...own, ...r.deck.map(key)];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function statsView(s: GostopState): GostopView['stats'] {
|
||||||
|
let best: GostopView['stats']['bestRound'] = null;
|
||||||
|
for (const l of s.log) {
|
||||||
|
if (l.winner !== null && (!best || l.amount > best.amount)) best = { player: s.players[l.winner]!, amount: l.amount, roundNo: l.no, breakdown: l.breakdown };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rounds: s.log.length,
|
||||||
|
nagari: s.stats.nagari,
|
||||||
|
bestRound: best,
|
||||||
|
perPlayer: s.players.map((id, i) => ({
|
||||||
|
player: id,
|
||||||
|
wins: s.stats.wins[i]!,
|
||||||
|
goCalls: s.stats.goCalls[i]!,
|
||||||
|
piBakGiven: s.stats.piBakGiven[i]!,
|
||||||
|
gwangSold: s.stats.gwangSold[i]!,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
744
packages/games/src/gostop/round.ts
Normal file
744
packages/games/src/gostop/round.ts
Normal file
@@ -0,0 +1,744 @@
|
|||||||
|
/** 고스톱 판 진행 (docs/games/gostop.md §4, §5): 패 돌리기, 광팔기, 총통, 먹기 판정, 피 뺏기, 고/스톱, 정산. */
|
||||||
|
import type { GameEvent, Rng } from '@bg/engine';
|
||||||
|
import { BONUS_COUNT, bestCard, cardInfo, cardValue, fullDeck, isBonus, monthOf, sameKind, type CardId } from './cards';
|
||||||
|
import { bestScore, settle } from './scoring';
|
||||||
|
import type { FloorGroup, GostopOptions, GostopRules, GostopState, PlayAction, RoundOutcome, RoundState, TurnCtx, TurnRecord } from './types';
|
||||||
|
|
||||||
|
export interface Ctx {
|
||||||
|
rng: Rng;
|
||||||
|
events: GameEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDEAL_GUARD = 50;
|
||||||
|
|
||||||
|
export function resolveRules(o: GostopOptions, players: number): GostopRules {
|
||||||
|
const matgo = players === 2;
|
||||||
|
const bonusCards = o.bonusCards === 'auto' ? (matgo ? '2+1' : 'none') : o.bonusCards;
|
||||||
|
return {
|
||||||
|
...o,
|
||||||
|
bonusCards,
|
||||||
|
nb: BONUS_COUNT[bonusCards],
|
||||||
|
winScore: o.winScore === 'auto' ? (matgo ? 7 : 3) : o.winScore,
|
||||||
|
piBakLimit: o.piBakLimit === 'auto' ? (matgo ? 7 : 5) : o.piBakLimit,
|
||||||
|
chongtongPoints: o.chongtongPoints === 'auto' ? (matgo ? 10 : 5) : o.chongtongPoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- small helpers
|
||||||
|
|
||||||
|
export const floorGroup = (r: RoundState, month: number): FloorGroup | undefined => r.floor.find((g) => g.month === month);
|
||||||
|
export const floorCount = (r: RoundState, month: number): number => floorGroup(r, month)?.cards.length ?? 0;
|
||||||
|
export const handMonthCount = (r: RoundState, p: number, month: number): number => r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === month).length;
|
||||||
|
|
||||||
|
function removeFrom(arr: CardId[], id: CardId): void {
|
||||||
|
const i = arr.indexOf(id);
|
||||||
|
if (i < 0) throw new Error(`gostop: card ${id} not found`);
|
||||||
|
arr.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function putOnFloor(r: RoundState, id: CardId): void {
|
||||||
|
const m = monthOf(id);
|
||||||
|
const g = floorGroup(r, m);
|
||||||
|
if (g) g.cards.push(id);
|
||||||
|
else {
|
||||||
|
r.floor.push({ month: m, cards: [id], ppeokBy: null, natural: false });
|
||||||
|
r.floor.sort((a, b) => a.month - b.month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeFromFloor(r: RoundState, ids: CardId[]): void {
|
||||||
|
for (const id of ids) {
|
||||||
|
const g = floorGroup(r, monthOf(id));
|
||||||
|
if (!g) throw new Error(`gostop: floor card ${id} missing`);
|
||||||
|
removeFrom(g.cards, id);
|
||||||
|
if (g.cards.length === 0) r.floor = r.floor.filter((x) => x !== g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(r: RoundState, ctx: TurnCtx, ids: CardId[]): void {
|
||||||
|
r.captured[ctx.player]!.push(...ids);
|
||||||
|
ctx.captured.push(...ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Players from `start` in turn order (seat order). */
|
||||||
|
export function orderFrom(n: number, start: number): number[] {
|
||||||
|
return Array.from({ length: n }, (_, i) => (start + i) % n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentScore(s: GostopState, p: number): number {
|
||||||
|
return bestScore(s.round!.captured[p]!, s.rules).calc.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasChongtong(r: RoundState, p: number): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let m = 1; m <= 12; m++) if (handMonthCount(r, p, m) === 4) out.push(m);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- dealing
|
||||||
|
|
||||||
|
function emptyRound(s: GostopState): RoundState {
|
||||||
|
const n = s.players.length;
|
||||||
|
const arr = <T>(f: () => T): T[] => Array.from({ length: n }, f);
|
||||||
|
const rules = s.rules;
|
||||||
|
return {
|
||||||
|
no: s.roundNo,
|
||||||
|
participants: [],
|
||||||
|
sellOrder: [],
|
||||||
|
sellCursor: 0,
|
||||||
|
sellDecisions: [],
|
||||||
|
sellers: [],
|
||||||
|
hands: arr(() => []),
|
||||||
|
bombDummies: arr(() => 0),
|
||||||
|
revealed: arr(() => []),
|
||||||
|
shakenMonths: arr(() => []),
|
||||||
|
deck: [],
|
||||||
|
floor: [],
|
||||||
|
captured: arr(() => []),
|
||||||
|
gukjinChoice: arr(() => null),
|
||||||
|
goCount: arr(() => 0),
|
||||||
|
lastGoScore: arr(() => 0),
|
||||||
|
goOrder: [],
|
||||||
|
shakes: arr(() => 0),
|
||||||
|
bombs: arr(() => 0),
|
||||||
|
ppeokCount: arr(() => 0),
|
||||||
|
ppeokTurns: arr(() => []),
|
||||||
|
turnsTaken: arr(() => 0),
|
||||||
|
turn: s.dealer,
|
||||||
|
step: 'play',
|
||||||
|
pending: null,
|
||||||
|
stopBy: null,
|
||||||
|
chongtongBy: null,
|
||||||
|
nagariMult: rules.nagariMultiplier === 'x2' ? Math.min(2 ** s.nagariStreak, rules.nagariMaxMultiplier) : 1,
|
||||||
|
instant: arr(() => 0),
|
||||||
|
turnSeq: 0,
|
||||||
|
lastTurn: null,
|
||||||
|
outcome: null,
|
||||||
|
redeals: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuffles and deals to `who` (§4.3), then cleans the floor (§4.4): floor bonus cards go to the dealer
|
||||||
|
* with a refill from the deck; four of a month on the floor → shuffle again.
|
||||||
|
*/
|
||||||
|
function deal(s: GostopState, r: RoundState, who: number[], ctx: Ctx): void {
|
||||||
|
const rules = s.rules;
|
||||||
|
const handSize = s.mode === 'matgo' ? 10 : 7;
|
||||||
|
const floorSize = s.mode === 'matgo' ? 8 : 6;
|
||||||
|
for (let guard = 0; ; guard++) {
|
||||||
|
const deck = ctx.rng.shuffle(fullDeck(rules.nb));
|
||||||
|
for (const p of who) r.hands[p] = deck.splice(0, handSize);
|
||||||
|
const floor = deck.splice(0, floorSize);
|
||||||
|
const taken: CardId[] = [];
|
||||||
|
for (let i = 0; i < floor.length; i++) {
|
||||||
|
while (isBonus(floor[i]!)) {
|
||||||
|
taken.push(floor[i]!);
|
||||||
|
floor[i] = deck.shift()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const c of floor) counts.set(monthOf(c), (counts.get(monthOf(c)) ?? 0) + 1);
|
||||||
|
if ([...counts.values()].some((v) => v === 4) && guard < REDEAL_GUARD) {
|
||||||
|
r.redeals++;
|
||||||
|
ctx.events.push({ type: 'redeal', reason: 'floorFour' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
r.deck = deck;
|
||||||
|
r.captured = r.captured.map(() => []);
|
||||||
|
r.captured[s.dealer] = taken;
|
||||||
|
r.floor = [];
|
||||||
|
for (const c of floor) putOnFloor(r, c);
|
||||||
|
for (const g of r.floor) if (g.cards.length === 3) g.natural = true;
|
||||||
|
ctx.events.push({ type: 'dealt', players: who.map((p) => s.players[p]), handSize, floor: [...floor] });
|
||||||
|
if (taken.length) ctx.events.push({ type: 'bonusTaken', player: s.players[s.dealer], cards: taken });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRound(s: GostopState, ctx: Ctx): void {
|
||||||
|
s.roundNo += 1;
|
||||||
|
s.phase = 'playing';
|
||||||
|
s.ready = [];
|
||||||
|
const r = emptyRound(s);
|
||||||
|
s.round = r;
|
||||||
|
const n = s.players.length;
|
||||||
|
const order = orderFrom(n, s.dealer);
|
||||||
|
ctx.events.push({ type: 'roundStart', roundNo: s.roundNo, dealer: s.players[s.dealer], nagariMult: r.nagariMult });
|
||||||
|
deal(s, r, order, ctx);
|
||||||
|
if (n >= 4) {
|
||||||
|
r.step = 'sell';
|
||||||
|
r.sellOrder = order.slice(1);
|
||||||
|
r.sellCursor = 0;
|
||||||
|
progressSell(s, ctx);
|
||||||
|
} else {
|
||||||
|
r.participants = order;
|
||||||
|
afterDeal(s, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 광팔기 (§5.8)
|
||||||
|
|
||||||
|
export function sellableCards(s: GostopState, p: number): CardId[] {
|
||||||
|
const r = s.round!;
|
||||||
|
return r.hands[p]!.filter((c) => {
|
||||||
|
const i = cardInfo(c, s.rules.nb);
|
||||||
|
if (i.kind === 'gwang') return true;
|
||||||
|
return s.rules.gwangSellCards === 'gwangPlus' && (i.gukjin || i.bonus || (i.kind === 'pi' && i.piValue === 2));
|
||||||
|
}).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who must decide next, or null when the remaining decisions are automatic. */
|
||||||
|
export function sellDecider(s: GostopState): number | null {
|
||||||
|
const r = s.round!;
|
||||||
|
if (r.step !== 'sell' || r.sellCursor >= r.sellOrder.length) return null;
|
||||||
|
const n = s.players.length;
|
||||||
|
const plays = 1 + r.sellDecisions.filter((d) => !d.sell).length;
|
||||||
|
const dies = r.sellDecisions.filter((d) => d.sell).length;
|
||||||
|
if (plays >= 3 || dies >= n - 3) return null;
|
||||||
|
return r.sellOrder[r.sellCursor]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideSell(s: GostopState, p: number, sell: boolean, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
r.sellDecisions.push({ idx: p, sell, auto: false });
|
||||||
|
r.sellCursor++;
|
||||||
|
ctx.events.push({ type: sell ? 'sellDie' : 'sellPlay', player: s.players[p] });
|
||||||
|
progressSell(s, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressSell(s: GostopState, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const n = s.players.length;
|
||||||
|
while (r.sellCursor < r.sellOrder.length) {
|
||||||
|
const plays = 1 + r.sellDecisions.filter((d) => !d.sell).length;
|
||||||
|
const dies = r.sellDecisions.filter((d) => d.sell).length;
|
||||||
|
if (plays < 3 && dies < n - 3) return; // a real decision is needed
|
||||||
|
const p = r.sellOrder[r.sellCursor]!;
|
||||||
|
r.sellDecisions.push({ idx: p, sell: plays >= 3, auto: true });
|
||||||
|
r.sellCursor++;
|
||||||
|
}
|
||||||
|
finishSelling(s, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSelling(s: GostopState, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const rules = s.rules;
|
||||||
|
const order = orderFrom(s.players.length, s.dealer);
|
||||||
|
const dying = new Set(r.sellDecisions.filter((d) => d.sell).map((d) => d.idx));
|
||||||
|
r.participants = order.filter((p) => !dying.has(p));
|
||||||
|
const payers = rules.gwangPayer === 'nonDealerPlayers' ? r.participants.slice(1) : r.participants;
|
||||||
|
const back: CardId[] = [];
|
||||||
|
for (const p of order) {
|
||||||
|
if (!dying.has(p)) continue;
|
||||||
|
const shown = sellableCards(s, p);
|
||||||
|
const each = shown.length * rules.gwangPrice;
|
||||||
|
for (const q of payers) {
|
||||||
|
s.totals[q]! -= each;
|
||||||
|
r.instant[q]! -= each;
|
||||||
|
}
|
||||||
|
s.totals[p]! += each * payers.length;
|
||||||
|
r.instant[p]! += each * payers.length;
|
||||||
|
s.stats.gwangSold[p]! += shown.length;
|
||||||
|
r.sellers.push({ idx: p, shown, paid: each * payers.length });
|
||||||
|
ctx.events.push({ type: 'gwangSold', player: s.players[p], cards: shown, points: each * payers.length });
|
||||||
|
back.push(...r.hands[p]!);
|
||||||
|
r.hands[p] = [];
|
||||||
|
}
|
||||||
|
r.deck = ctx.rng.shuffle([...r.deck, ...back]);
|
||||||
|
afterDeal(s, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 총통 (§5.7)
|
||||||
|
|
||||||
|
function afterDeal(s: GostopState, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
for (let guard = 0; guard <= REDEAL_GUARD; guard++) {
|
||||||
|
const holder = s.rules.chongtong === 'off' ? undefined : r.participants.find((p) => hasChongtong(r, p).length > 0);
|
||||||
|
if (holder === undefined) break;
|
||||||
|
if (s.rules.chongtong === 'win') {
|
||||||
|
r.step = 'chongtong';
|
||||||
|
r.chongtongBy = holder;
|
||||||
|
r.turn = holder;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (guard === REDEAL_GUARD) break;
|
||||||
|
r.redeals++;
|
||||||
|
ctx.events.push({ type: 'redeal', reason: 'chongtong' });
|
||||||
|
deal(s, r, r.participants, ctx);
|
||||||
|
}
|
||||||
|
startPlay(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPlay(s: GostopState): void {
|
||||||
|
const r = s.round!;
|
||||||
|
r.step = 'play';
|
||||||
|
r.chongtongBy = null;
|
||||||
|
r.turn = r.participants[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideChongtong(s: GostopState, p: number, declare: boolean, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
if (!declare) {
|
||||||
|
ctx.events.push({ type: 'chongtongSkip', player: s.players[p] });
|
||||||
|
startPlay(s);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const months = hasChongtong(r, p);
|
||||||
|
const shown = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === months[0]).sort((a, b) => a - b);
|
||||||
|
r.revealed[p]!.push(...shown);
|
||||||
|
ctx.events.push({ type: 'chongtong', player: s.players[p], cards: shown });
|
||||||
|
instantWin(s, p, 'chongtong', shown, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 총통·삼뻑: points × 나가리 배수 from every opponent, no 박 (§5.7, §5.11). */
|
||||||
|
function instantWin(s: GostopState, p: number, type: 'chongtong' | 'samppeok', shown: CardId[], ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const amount = s.rules.chongtongPoints * r.nagariMult;
|
||||||
|
const outcome: RoundOutcome = {
|
||||||
|
type,
|
||||||
|
winner: p,
|
||||||
|
baseScore: s.rules.chongtongPoints,
|
||||||
|
breakdown: [{ name: type === 'chongtong' ? '총통' : '삼뻑', points: s.rules.chongtongPoints }],
|
||||||
|
goCount: 0,
|
||||||
|
multipliers: r.nagariMult > 1 ? [{ name: '나가리 배수', factor: r.nagariMult }] : [],
|
||||||
|
perLoser: amount,
|
||||||
|
payments: r.participants.filter((q) => q !== p).map((q) => ({ from: q, amount, baks: [] })),
|
||||||
|
gukjinAs: null,
|
||||||
|
shown,
|
||||||
|
};
|
||||||
|
endRound(s, outcome, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- a turn (§5.1–5.6)
|
||||||
|
|
||||||
|
/** Two floor cards that differ → the player must pick (§5.2). */
|
||||||
|
export function needsTarget(s: GostopState, month: number): boolean {
|
||||||
|
const g = floorGroup(s.round!, month);
|
||||||
|
return !!g && g.cards.length === 2 && !sameKind(g.cards[0]!, g.cards[1]!, s.rules.nb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flip(s: GostopState, ctx: TurnCtx): CardId {
|
||||||
|
const r = s.round!;
|
||||||
|
for (;;) {
|
||||||
|
const c = r.deck.shift();
|
||||||
|
if (c === undefined) throw new Error('gostop: deck exhausted');
|
||||||
|
ctx.flipped.push(c);
|
||||||
|
if (!isBonus(c)) return c;
|
||||||
|
capture(r, ctx, [c]);
|
||||||
|
ctx.tags.push('bonusFlip');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pileSteal(s: GostopState, g: FloorGroup, ctx: TurnCtx): void {
|
||||||
|
if (g.ppeokBy === ctx.player) {
|
||||||
|
ctx.steal += s.rules.jaPpeokSteal;
|
||||||
|
ctx.tags.push('jaPpeok');
|
||||||
|
} else if (g.ppeokBy !== null) {
|
||||||
|
ctx.steal += 1;
|
||||||
|
ctx.tags.push('ppeokEat');
|
||||||
|
} else if (g.natural) {
|
||||||
|
if (s.rules.naturalThreeSteal) ctx.steal += 1;
|
||||||
|
ctx.tags.push('ppeokEat');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweepCounts = (s: GostopState, ctx: TurnCtx): boolean => !ctx.lastOfRound || s.rules.lastTurnSweepBonus;
|
||||||
|
|
||||||
|
export function playCard(s: GostopState, p: number, a: PlayAction, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const nb = s.rules.nb;
|
||||||
|
const t: TurnCtx = { player: p, played: [], flipped: [], captured: [], tags: [], steal: 0, lastOfRound: false };
|
||||||
|
// 보너스패: goes to my pile, I take the deck top and play again in the same turn (§5.6).
|
||||||
|
if (a.card !== 'dummy' && isBonus(a.card)) {
|
||||||
|
removeFrom(r.hands[p]!, a.card);
|
||||||
|
capture(r, t, [a.card]);
|
||||||
|
t.played.push(a.card);
|
||||||
|
t.tags.push('bonus');
|
||||||
|
const drawn = r.deck.shift();
|
||||||
|
if (drawn === undefined) throw new Error('gostop: deck exhausted (bonus draw)');
|
||||||
|
r.hands[p]!.push(drawn);
|
||||||
|
ctx.events.push({ type: 'bonusPlayed', player: s.players[p], card: a.card });
|
||||||
|
record(s, t, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let H: CardId | null = null;
|
||||||
|
let m1 = 0;
|
||||||
|
let k = 0;
|
||||||
|
if (a.card === 'dummy') {
|
||||||
|
r.bombDummies[p]! -= 1;
|
||||||
|
t.tags.push('dummy');
|
||||||
|
ctx.events.push({ type: 'dummyPlayed', player: s.players[p] });
|
||||||
|
} else if (a.bomb) {
|
||||||
|
const m = monthOf(a.card);
|
||||||
|
const mine = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === m).sort((x, y) => x - y);
|
||||||
|
for (const c of mine) removeFrom(r.hands[p]!, c);
|
||||||
|
r.revealed[p] = r.revealed[p]!.filter((c) => !mine.includes(c));
|
||||||
|
const g = floorGroup(r, m)!;
|
||||||
|
const floorCards = [...g.cards];
|
||||||
|
takeFromFloor(r, floorCards);
|
||||||
|
t.played.push(...mine);
|
||||||
|
capture(r, t, [...mine, ...floorCards]);
|
||||||
|
t.steal += 1;
|
||||||
|
t.tags.push('bomb');
|
||||||
|
r.bombs[p]! += 1;
|
||||||
|
r.bombDummies[p]! += 2;
|
||||||
|
ctx.events.push({ type: 'bomb', player: s.players[p], cards: mine });
|
||||||
|
} else {
|
||||||
|
H = a.card;
|
||||||
|
m1 = monthOf(H);
|
||||||
|
k = floorCount(r, m1);
|
||||||
|
removeFrom(r.hands[p]!, H);
|
||||||
|
r.revealed[p] = r.revealed[p]!.filter((c) => c !== H);
|
||||||
|
t.played.push(H);
|
||||||
|
if (a.shake) {
|
||||||
|
const rest = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === m1);
|
||||||
|
r.shakes[p]! += 1;
|
||||||
|
r.shakenMonths[p]!.push(m1);
|
||||||
|
for (const c of rest) if (!r.revealed[p]!.includes(c)) r.revealed[p]!.push(c);
|
||||||
|
t.tags.push('shake');
|
||||||
|
ctx.events.push({ type: 'shake', player: s.players[p], cards: [H, ...rest].sort((x, y) => x - y) });
|
||||||
|
}
|
||||||
|
ctx.events.push({ type: 'played', player: s.players[p], card: H });
|
||||||
|
}
|
||||||
|
const F = flip(s, t);
|
||||||
|
t.lastOfRound = r.deck.length === 0;
|
||||||
|
ctx.events.push({ type: 'flipped', player: s.players[p], cards: [...t.flipped] });
|
||||||
|
if (H !== null) {
|
||||||
|
const g = floorGroup(r, m1);
|
||||||
|
if (monthOf(F) === m1) {
|
||||||
|
if (k === 0) {
|
||||||
|
capture(r, t, [H, F]);
|
||||||
|
t.tags.push('jjok');
|
||||||
|
if (sweepCounts(s, t)) t.steal += 1;
|
||||||
|
} else if (k === 1) {
|
||||||
|
g!.cards.push(H, F);
|
||||||
|
g!.ppeokBy = p;
|
||||||
|
g!.natural = false;
|
||||||
|
r.ppeokCount[p]! += 1;
|
||||||
|
r.ppeokTurns[p]!.push(r.turnsTaken[p]!);
|
||||||
|
t.tags.push('ppeok');
|
||||||
|
} else {
|
||||||
|
// k = 2 → 따닥 (k = 3 is impossible: the 4th card of the month was H).
|
||||||
|
const cards = [...g!.cards];
|
||||||
|
takeFromFloor(r, cards);
|
||||||
|
capture(r, t, [...cards, H, F]);
|
||||||
|
t.tags.push('ttadak');
|
||||||
|
t.steal += 1;
|
||||||
|
}
|
||||||
|
finishTurn(s, t, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (k === 0) putOnFloor(r, H);
|
||||||
|
else if (k === 1) {
|
||||||
|
const c = g!.cards[0]!;
|
||||||
|
takeFromFloor(r, [c]);
|
||||||
|
capture(r, t, [H, c]);
|
||||||
|
} else if (k === 2) {
|
||||||
|
const target = a.target ?? bestCard(g!.cards, nb);
|
||||||
|
takeFromFloor(r, [target]);
|
||||||
|
capture(r, t, [H, target]);
|
||||||
|
} else {
|
||||||
|
const cards = [...g!.cards];
|
||||||
|
pileSteal(s, g!, t);
|
||||||
|
takeFromFloor(r, cards);
|
||||||
|
capture(r, t, [...cards, H]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveFlip(s, F, t, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** F 처리 (§5.2): j = floor cards of F's month. */
|
||||||
|
function resolveFlip(s: GostopState, F: CardId, t: TurnCtx, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const nb = s.rules.nb;
|
||||||
|
const g = floorGroup(r, monthOf(F));
|
||||||
|
const j = g?.cards.length ?? 0;
|
||||||
|
if (j === 0) putOnFloor(r, F);
|
||||||
|
else if (j === 1) {
|
||||||
|
const c = g!.cards[0]!;
|
||||||
|
takeFromFloor(r, [c]);
|
||||||
|
capture(r, t, [F, c]);
|
||||||
|
} else if (j === 2) {
|
||||||
|
if (sameKind(g!.cards[0]!, g!.cards[1]!, nb)) {
|
||||||
|
const c = bestCard(g!.cards, nb);
|
||||||
|
takeFromFloor(r, [c]);
|
||||||
|
capture(r, t, [F, c]);
|
||||||
|
} else {
|
||||||
|
r.pending = { flipped: F, options: [...g!.cards].sort((a, b) => a - b), ctx: t };
|
||||||
|
r.step = 'chooseFlip';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const cards = [...g!.cards];
|
||||||
|
pileSteal(s, g!, t);
|
||||||
|
takeFromFloor(r, cards);
|
||||||
|
capture(r, t, [...cards, F]);
|
||||||
|
}
|
||||||
|
finishTurn(s, t, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseFlip(s: GostopState, target: CardId, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const pend = r.pending!;
|
||||||
|
r.pending = null;
|
||||||
|
r.step = 'play';
|
||||||
|
takeFromFloor(r, [target]);
|
||||||
|
capture(r, pend.ctx, [pend.flipped, target]);
|
||||||
|
finishTurn(s, pend.ctx, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives `n` 피 per opponent, lowest value first (일반 피 → 쌍피·2피 → 3피), then lowest id (§5.3). */
|
||||||
|
function steal(s: GostopState, t: TurnCtx): [number, CardId[]][] {
|
||||||
|
const r = s.round!;
|
||||||
|
const nb = s.rules.nb;
|
||||||
|
const out: [number, CardId[]][] = [];
|
||||||
|
for (const q of r.participants) {
|
||||||
|
if (q === t.player) continue;
|
||||||
|
const pis = r.captured[q]!.filter((c) => cardInfo(c, nb).kind === 'pi').sort((a, b) => cardInfo(a, nb).piValue - cardInfo(b, nb).piValue || a - b);
|
||||||
|
const given = pis.slice(0, t.steal);
|
||||||
|
for (const c of given) removeFrom(r.captured[q]!, c);
|
||||||
|
r.captured[t.player]!.push(...given);
|
||||||
|
if (given.length) out.push([q, given]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(s: GostopState, t: TurnCtx, ctx: Ctx, stolen: [number, CardId[]][] = [], bonusPoints = 0): TurnRecord {
|
||||||
|
const r = s.round!;
|
||||||
|
r.turnSeq += 1;
|
||||||
|
const rec: TurnRecord = { seq: r.turnSeq, player: t.player, played: t.played, flipped: t.flipped, captured: t.captured, tags: [...t.tags], stolen, bonusPoints };
|
||||||
|
r.lastTurn = rec;
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishTurn(s: GostopState, t: TurnCtx, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const p = t.player;
|
||||||
|
const others = r.participants.filter((q) => q !== p);
|
||||||
|
if (r.floor.length === 0) {
|
||||||
|
t.tags.push('sseul');
|
||||||
|
if (sweepCounts(s, t)) t.steal += 1;
|
||||||
|
}
|
||||||
|
const stolen = t.steal > 0 ? steal(s, t) : [];
|
||||||
|
const turnIdx = r.turnsTaken[p]!;
|
||||||
|
r.turnsTaken[p]! += 1;
|
||||||
|
// 첫뻑·연뻑·삼뻑 (§5.11)
|
||||||
|
let bonusPoints = 0;
|
||||||
|
let samppeok = false;
|
||||||
|
if (t.tags.includes('ppeok') && s.rules.ppeokBonus) {
|
||||||
|
if (r.ppeokCount[p]! >= 3) {
|
||||||
|
samppeok = true;
|
||||||
|
t.tags.push('samPpeok');
|
||||||
|
} else if (turnIdx === 0) {
|
||||||
|
bonusPoints = 3;
|
||||||
|
t.tags.push('firstPpeok');
|
||||||
|
} else if (turnIdx === 1 && r.ppeokTurns[p]!.includes(0)) {
|
||||||
|
bonusPoints = 6;
|
||||||
|
t.tags.push('yeonPpeok');
|
||||||
|
}
|
||||||
|
for (const q of others) {
|
||||||
|
s.totals[q]! -= bonusPoints;
|
||||||
|
r.instant[q]! -= bonusPoints;
|
||||||
|
s.totals[p]! += bonusPoints;
|
||||||
|
r.instant[p]! += bonusPoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tag of t.tags) if (!['bonus', 'bonusFlip', 'dummy', 'bomb', 'shake'].includes(tag)) ctx.events.push({ type: tag, player: s.players[p] });
|
||||||
|
if (stolen.length) ctx.events.push({ type: 'piStolen', player: s.players[p], from: stolen.map(([q, cs]) => ({ player: s.players[q], cards: cs })) });
|
||||||
|
record(s, t, ctx, stolen, bonusPoints);
|
||||||
|
if (samppeok) {
|
||||||
|
instantWin(s, p, 'samppeok', [], ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const score = currentScore(s, p);
|
||||||
|
ctx.events.push({ type: 'scoreUpdate', player: s.players[p], score });
|
||||||
|
if (score >= s.rules.winScore && (r.goCount[p] === 0 || score > r.lastGoScore[p]!)) {
|
||||||
|
if (r.hands[p]!.length === 0) {
|
||||||
|
ctx.events.push({ type: 'stop', player: s.players[p], auto: true });
|
||||||
|
stop(s, p, ctx);
|
||||||
|
} else {
|
||||||
|
r.step = 'goStop';
|
||||||
|
r.turn = p;
|
||||||
|
ctx.events.push({ type: 'goStopPrompt', player: s.players[p], score });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
advance(s, p, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next player with something to play; nobody left → 나가리 (§5.10). */
|
||||||
|
function advance(s: GostopState, from: number, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const parts = r.participants;
|
||||||
|
const i = parts.indexOf(from);
|
||||||
|
for (let step = 1; step <= parts.length; step++) {
|
||||||
|
const q = parts[(i + step) % parts.length]!;
|
||||||
|
if (r.hands[q]!.length > 0 || r.bombDummies[q]! > 0) {
|
||||||
|
r.step = 'play';
|
||||||
|
r.turn = q;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r.deck.length !== 0) throw new Error('gostop: hands empty but deck not empty');
|
||||||
|
ctx.events.push({ type: 'nagari' });
|
||||||
|
endRound(
|
||||||
|
s,
|
||||||
|
{ type: 'nagari', winner: null, baseScore: 0, breakdown: [], goCount: 0, multipliers: [], perLoser: 0, payments: [], gukjinAs: null, shown: [] },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideGoStop(s: GostopState, p: number, decision: 'go' | 'stop', ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
if (decision === 'stop') {
|
||||||
|
ctx.events.push({ type: 'stop', player: s.players[p], auto: false });
|
||||||
|
stop(s, p, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.goCount[p]! += 1;
|
||||||
|
r.lastGoScore[p] = currentScore(s, p);
|
||||||
|
r.goOrder.push(p);
|
||||||
|
s.stats.goCalls[p]! += 1;
|
||||||
|
ctx.events.push({ type: 'go', player: s.players[p], count: r.goCount[p] });
|
||||||
|
advance(s, p, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 국진 owner who still has to choose (ask mode) before settling. */
|
||||||
|
export function gukjinPending(s: GostopState): number | null {
|
||||||
|
const r = s.round!;
|
||||||
|
if (s.rules.gukjinMode !== 'ask') return null;
|
||||||
|
const holder = r.participants.find((q) => r.captured[q]!.includes(32));
|
||||||
|
return holder !== undefined && r.gukjinChoice[holder] === null ? holder : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(s: GostopState, p: number, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
r.stopBy = p;
|
||||||
|
if (gukjinPending(s) !== null) {
|
||||||
|
r.step = 'gukjin';
|
||||||
|
r.turn = gukjinPending(s)!;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settleStop(s, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settleInput(s: GostopState, winner: number) {
|
||||||
|
const r = s.round!;
|
||||||
|
return {
|
||||||
|
rules: s.rules,
|
||||||
|
mode: s.mode,
|
||||||
|
winner,
|
||||||
|
participants: r.participants,
|
||||||
|
captured: r.captured,
|
||||||
|
goCount: r.goCount,
|
||||||
|
goOrder: r.goOrder,
|
||||||
|
shakes: r.shakes,
|
||||||
|
bombs: r.bombs,
|
||||||
|
nagariMult: r.nagariMult,
|
||||||
|
gukjinChoice: r.gukjinChoice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideGukjin(s: GostopState, p: number, as: 'yeol' | 'pi', ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
r.gukjinChoice[p] = as;
|
||||||
|
ctx.events.push({ type: 'gukjin', player: s.players[p], as });
|
||||||
|
settleStop(s, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The automatic 국진 rule for the pending owner (timeout in ask mode). */
|
||||||
|
export function autoGukjin(s: GostopState): 'yeol' | 'pi' {
|
||||||
|
return settle(settleInput(s, s.round!.stopBy!)).gukjinAs ?? 'yeol';
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleStop(s: GostopState, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
const st = settle(settleInput(s, r.stopBy!));
|
||||||
|
const { calcs: _c, ...rest } = st;
|
||||||
|
endRound(s, { type: 'stop', ...rest, shown: [] }, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- round end
|
||||||
|
|
||||||
|
function endRound(s: GostopState, outcome: RoundOutcome, ctx: Ctx): void {
|
||||||
|
const r = s.round!;
|
||||||
|
r.outcome = outcome;
|
||||||
|
r.step = 'done';
|
||||||
|
r.pending = null;
|
||||||
|
const delta = [...r.instant];
|
||||||
|
if (outcome.winner !== null) {
|
||||||
|
for (const pay of outcome.payments) {
|
||||||
|
s.totals[pay.from]! -= pay.amount;
|
||||||
|
s.totals[outcome.winner]! += pay.amount;
|
||||||
|
delta[pay.from]! -= pay.amount;
|
||||||
|
delta[outcome.winner]! += pay.amount;
|
||||||
|
if (pay.baks.includes('pi')) s.stats.piBakGiven[outcome.winner]! += 1;
|
||||||
|
}
|
||||||
|
s.stats.wins[outcome.winner]! += 1;
|
||||||
|
s.nagariStreak = 0;
|
||||||
|
s.dealer = outcome.winner;
|
||||||
|
} else {
|
||||||
|
s.stats.nagari += 1;
|
||||||
|
s.nagariStreak += 1;
|
||||||
|
}
|
||||||
|
const amount = outcome.payments.reduce((a, x) => a + x.amount, 0);
|
||||||
|
s.log.push({
|
||||||
|
no: s.roundNo,
|
||||||
|
type: outcome.type,
|
||||||
|
winner: outcome.winner,
|
||||||
|
amount,
|
||||||
|
delta,
|
||||||
|
breakdown: [...outcome.breakdown.map((b) => `${b.name} ${b.points}점`), ...outcome.multipliers.map((m) => `${m.name} x${m.factor}`)],
|
||||||
|
});
|
||||||
|
ctx.events.push({ type: 'roundEnded', roundNo: s.roundNo, outcome: outcome.type, winner: outcome.winner === null ? null : s.players[outcome.winner], amount });
|
||||||
|
if (s.roundNo >= s.rules.rounds) finishGame(s, ctx);
|
||||||
|
else {
|
||||||
|
s.phase = 'roundResult';
|
||||||
|
s.ready = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishGame(s: GostopState, ctx: Ctx): void {
|
||||||
|
s.phase = 'finished';
|
||||||
|
const ids = s.players.map((_, i) => i).sort((a, b) => s.totals[b]! - s.totals[a]! || a - b);
|
||||||
|
const ranking: string[][] = [];
|
||||||
|
let last: number | null = null;
|
||||||
|
for (const i of ids) {
|
||||||
|
if (last !== null && s.totals[i] === last) ranking[ranking.length - 1]!.push(s.players[i]!);
|
||||||
|
else ranking.push([s.players[i]!]);
|
||||||
|
last = s.totals[i]!;
|
||||||
|
}
|
||||||
|
const scores = Object.fromEntries(s.players.map((p, i) => [p, s.totals[i]!]));
|
||||||
|
const top = s.totals[ids[0]!]!;
|
||||||
|
const tie = ranking[0]!.length > 1 ? ` (공동 ${ranking[0]!.length}명)` : '';
|
||||||
|
const nag = s.stats.nagari ? `, 나가리 ${s.stats.nagari}번` : '';
|
||||||
|
const summary = `${s.roundNo}판 끝${nag}. 1위 ${top >= 0 ? '+' : ''}${top.toLocaleString('ko-KR')}점${tie}`;
|
||||||
|
s.final = { ranking, scores, summary };
|
||||||
|
ctx.events.push({ type: 'setEnded', ranking });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- automatic play (§8.5)
|
||||||
|
|
||||||
|
/** Deterministic play for timeouts: 빈패 → 보너스패 → 가장 값진 바닥 패를 먹는 패 → 가장 싼 패. */
|
||||||
|
export function autoPlay(s: GostopState, p: number): PlayAction {
|
||||||
|
const r = s.round!;
|
||||||
|
const nb = s.rules.nb;
|
||||||
|
if (r.bombDummies[p]! > 0) return { type: 'play', card: 'dummy' };
|
||||||
|
const hand = [...r.hands[p]!].sort((a, b) => a - b);
|
||||||
|
const bonus = hand.find(isBonus);
|
||||||
|
if (bonus !== undefined) return { type: 'play', card: bonus };
|
||||||
|
let best: { card: CardId; value: number } | null = null;
|
||||||
|
for (const c of hand) {
|
||||||
|
const g = floorGroup(r, monthOf(c));
|
||||||
|
if (!g) continue;
|
||||||
|
const value = Math.max(...g.cards.map((x) => cardValue(x, nb)));
|
||||||
|
if (!best || value > best.value) best = { card: c, value };
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
const g = floorGroup(r, monthOf(best.card))!;
|
||||||
|
return g.cards.length === 2 && needsTarget(s, g.month) ? { type: 'play', card: best.card, target: bestCard(g.cards, nb) } : { type: 'play', card: best.card };
|
||||||
|
}
|
||||||
|
const cheapest = [...hand].sort((a, b) => cardValue(a, nb) - cardValue(b, nb) || a - b)[0]!;
|
||||||
|
return { type: 'play', card: cheapest };
|
||||||
|
}
|
||||||
151
packages/games/src/gostop/scoring.ts
Normal file
151
packages/games/src/gostop/scoring.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/** 고스톱 점수 계산 (docs/games/gostop.md §6): 족보 합, 국진, 최종 공식, 박. */
|
||||||
|
import { GUKJIN, cardInfo, type CardId } from './cards';
|
||||||
|
import type { BakKind, GostopRules, Payment, RoundOutcome, ScoreView } from './types';
|
||||||
|
|
||||||
|
export interface ScoreCalc extends ScoreView {
|
||||||
|
/** Points from 광 족보 (삼광·비삼광·사광·오광) — 광박 needs this > 0. */
|
||||||
|
gwangPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 족보 합 (§6.1). `gukjinAs` = how the 9월 열끗 counts if it is among `cards`. */
|
||||||
|
export function scoreOf(cards: readonly CardId[], rules: Pick<GostopRules, 'nb' | 'ogwangPoints' | 'biGwangThree'>, gukjinAs: 'yeol' | 'pi' = 'yeol'): ScoreCalc {
|
||||||
|
let gwang = 0;
|
||||||
|
let bi = false;
|
||||||
|
let yeol = 0;
|
||||||
|
let tti = 0;
|
||||||
|
let pi = 0;
|
||||||
|
let hong = 0;
|
||||||
|
let cheong = 0;
|
||||||
|
let cho = 0;
|
||||||
|
let godori = 0;
|
||||||
|
for (const id of cards) {
|
||||||
|
const c = cardInfo(id, rules.nb);
|
||||||
|
if (c.gukjin && gukjinAs === 'pi') {
|
||||||
|
pi += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c.kind === 'gwang') {
|
||||||
|
gwang++;
|
||||||
|
if (c.bi) bi = true;
|
||||||
|
} else if (c.kind === 'yeol') {
|
||||||
|
yeol++;
|
||||||
|
if (c.godori) godori++;
|
||||||
|
} else if (c.kind === 'tti') {
|
||||||
|
tti++;
|
||||||
|
if (c.dan === 'hong') hong++;
|
||||||
|
else if (c.dan === 'cheong') cheong++;
|
||||||
|
else if (c.dan === 'cho') cho++;
|
||||||
|
} else pi += c.piValue;
|
||||||
|
}
|
||||||
|
const items: { name: string; points: number }[] = [];
|
||||||
|
let gwangPoints = 0;
|
||||||
|
if (gwang >= 5) {
|
||||||
|
gwangPoints = rules.ogwangPoints;
|
||||||
|
items.push({ name: '오광', points: gwangPoints });
|
||||||
|
} else if (gwang === 4) {
|
||||||
|
gwangPoints = 4;
|
||||||
|
items.push({ name: '사광', points: 4 });
|
||||||
|
} else if (gwang === 3) {
|
||||||
|
gwangPoints = bi ? rules.biGwangThree : 3;
|
||||||
|
if (gwangPoints > 0) items.push({ name: bi ? '비삼광' : '삼광', points: gwangPoints });
|
||||||
|
}
|
||||||
|
if (yeol >= 5) items.push({ name: `열끗 ${yeol}장`, points: yeol - 4 });
|
||||||
|
if (godori === 3) items.push({ name: '고도리', points: 5 });
|
||||||
|
if (tti >= 5) items.push({ name: `띠 ${tti}장`, points: tti - 4 });
|
||||||
|
if (hong === 3) items.push({ name: '홍단', points: 3 });
|
||||||
|
if (cheong === 3) items.push({ name: '청단', points: 3 });
|
||||||
|
if (cho === 3) items.push({ name: '초단', points: 3 });
|
||||||
|
if (pi >= 10) items.push({ name: `피 ${pi}`, points: pi - 9 });
|
||||||
|
const total = items.reduce((a, x) => a + x.points, 0);
|
||||||
|
return { total, items, gwang, yeol, tti, pi, hong, cheong, cho, godori, gwangPoints };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score with the 국진 counted the way that gives more points (§6.1 auto; also used for reaching winScore). */
|
||||||
|
export function bestScore(cards: readonly CardId[], rules: GostopRules): { calc: ScoreCalc; as: 'yeol' | 'pi' } {
|
||||||
|
const asYeol = scoreOf(cards, rules, 'yeol');
|
||||||
|
if (rules.gukjinMode === 'yeolOnly' || !cards.includes(GUKJIN)) return { calc: asYeol, as: 'yeol' };
|
||||||
|
const asPi = scoreOf(cards, rules, 'pi');
|
||||||
|
return asPi.total > asYeol.total ? { calc: asPi, as: 'pi' } : { calc: asYeol, as: 'yeol' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const goFactor = (go: number): number => (go >= 3 ? 2 ** (go - 2) : 1);
|
||||||
|
|
||||||
|
export interface SettleInput {
|
||||||
|
rules: GostopRules;
|
||||||
|
mode: 'matgo' | 'gostop';
|
||||||
|
winner: number;
|
||||||
|
participants: number[];
|
||||||
|
captured: CardId[][];
|
||||||
|
goCount: number[];
|
||||||
|
goOrder: number[];
|
||||||
|
shakes: number[];
|
||||||
|
bombs: number[];
|
||||||
|
nagariMult: number;
|
||||||
|
/** Explicit 국진 choice (ask mode) or null for the automatic rule. */
|
||||||
|
gukjinChoice: (null | 'yeol' | 'pi')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Settlement = Omit<RoundOutcome, 'type' | 'shown'> & { calcs: Record<number, ScoreCalc> };
|
||||||
|
|
||||||
|
function compute(inp: SettleInput, holder: number, as: 'yeol' | 'pi'): Settlement {
|
||||||
|
const { rules, winner } = inp;
|
||||||
|
const calcs: Record<number, ScoreCalc> = {};
|
||||||
|
for (const p of inp.participants) calcs[p] = scoreOf(inp.captured[p]!, rules, p === holder ? as : 'yeol');
|
||||||
|
const w = calcs[winner]!;
|
||||||
|
const go = inp.goCount[winner]!;
|
||||||
|
const meong = rules.meongTta && w.yeol >= 7;
|
||||||
|
const multipliers: { name: string; factor: number }[] = [];
|
||||||
|
if (go >= 3) multipliers.push({ name: `${go}고`, factor: goFactor(go) });
|
||||||
|
if (inp.shakes[winner]! > 0) multipliers.push({ name: `흔들기 ${inp.shakes[winner]}번`, factor: 2 ** inp.shakes[winner]! });
|
||||||
|
if (inp.bombs[winner]! > 0) multipliers.push({ name: `폭탄 ${inp.bombs[winner]}번`, factor: 2 ** inp.bombs[winner]! });
|
||||||
|
if (meong) multipliers.push({ name: '멍따', factor: 2 });
|
||||||
|
if (inp.nagariMult > 1) multipliers.push({ name: '나가리 배수', factor: inp.nagariMult });
|
||||||
|
const T = multipliers.reduce((a, m) => a * m.factor, w.total + go);
|
||||||
|
const breakdown = [...w.items];
|
||||||
|
if (go > 0) breakdown.push({ name: `${go}고`, points: go });
|
||||||
|
const losers = inp.participants.filter((p) => p !== winner);
|
||||||
|
const payments: Payment[] = losers.map((l) => {
|
||||||
|
const c = calcs[l]!;
|
||||||
|
const baks: BakKind[] = [];
|
||||||
|
if (w.pi >= 10 && ((c.pi >= 1 && c.pi <= rules.piBakLimit) || (c.pi === 0 && !rules.piBakZeroExempt))) baks.push('pi');
|
||||||
|
if (rules.gwangBak && w.gwangPoints > 0 && c.gwang === 0) baks.push('gwang');
|
||||||
|
if (rules.meongBak && meong && c.yeol === 0) baks.push('meong');
|
||||||
|
let amount = T * 2 ** baks.length;
|
||||||
|
if (rules.goBak && inp.goCount[l]! > 0 && inp.mode === 'matgo') {
|
||||||
|
baks.push('go');
|
||||||
|
amount *= 2;
|
||||||
|
}
|
||||||
|
return { from: l, amount, baks };
|
||||||
|
});
|
||||||
|
if (rules.goBak && inp.mode === 'gostop') {
|
||||||
|
// 독박: the loser who called 고 last pays every loser's share (§6.2).
|
||||||
|
const dok = [...inp.goOrder].reverse().find((p) => p !== winner && losers.includes(p));
|
||||||
|
if (dok !== undefined && losers.length > 1) {
|
||||||
|
const sum = payments.reduce((a, x) => a + x.amount, 0);
|
||||||
|
for (const pay of payments) {
|
||||||
|
if (pay.from === dok) {
|
||||||
|
pay.amount = sum;
|
||||||
|
pay.baks.push('go');
|
||||||
|
} else {
|
||||||
|
pay.amount = 0;
|
||||||
|
pay.coveredBy = dok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { winner, baseScore: w.total, breakdown, goCount: go, multipliers, perLoser: T, payments, gukjinAs: holder >= 0 ? as : null, calcs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final settlement of a stopped round (§6.2), resolving the 국진 for its owner (§6.1). */
|
||||||
|
export function settle(inp: SettleInput): Settlement {
|
||||||
|
const holder = inp.participants.find((p) => inp.captured[p]!.includes(GUKJIN)) ?? -1;
|
||||||
|
if (holder < 0) return compute(inp, -1, 'yeol');
|
||||||
|
const fixed = inp.rules.gukjinMode === 'yeolOnly' ? 'yeol' : inp.gukjinChoice[holder];
|
||||||
|
if (fixed) return compute(inp, holder, fixed);
|
||||||
|
const y = compute(inp, holder, 'yeol');
|
||||||
|
const p = compute(inp, holder, 'pi');
|
||||||
|
const sum = (x: Settlement) => x.payments.reduce((a, b) => a + b.amount, 0);
|
||||||
|
if (holder === inp.winner) return sum(p) > sum(y) ? p : y;
|
||||||
|
const paid = (x: Settlement) => x.payments.find((b) => b.from === holder)?.amount ?? 0;
|
||||||
|
return paid(p) < paid(y) ? p : y;
|
||||||
|
}
|
||||||
372
packages/games/src/gostop/types.ts
Normal file
372
packages/games/src/gostop/types.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
/** 고스톱 options, actions, state and view types (docs/games/gostop.md §2, §8). */
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { PlayerId } from '@bg/engine';
|
||||||
|
import type { BonusSetting, CardId, CardView } from './cards';
|
||||||
|
|
||||||
|
const lit = <T extends string | number>(...vals: T[]) =>
|
||||||
|
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
|
||||||
|
|
||||||
|
export const gostopOptionsSchema = z.object({
|
||||||
|
rounds: lit(1, 3, 5, 10, 20)
|
||||||
|
.default(10)
|
||||||
|
.meta({ title: '판 수(나가리 판도 1판)', labels: { 1: '1판', 3: '3판', 5: '5판', 10: '10판', 20: '20판' } }),
|
||||||
|
bonusCards: lit<'auto' | BonusSetting>('auto', 'none', '2+1', '3+1')
|
||||||
|
.default('auto')
|
||||||
|
.meta({
|
||||||
|
title: '보너스패',
|
||||||
|
labels: { auto: '자동(맞고 2피 2장+3피 1장, 고스톱 없음)', none: '없음', '2+1': '2피 2장 + 3피 1장', '3+1': '2피 3장 + 3피 1장' },
|
||||||
|
}),
|
||||||
|
winScore: lit<'auto' | 3 | 5 | 7>('auto', 3, 5, 7)
|
||||||
|
.default('auto')
|
||||||
|
.meta({ title: '고·스톱 기준 점수', labels: { auto: '자동(맞고 7점, 고스톱 3점)', 3: '3점', 5: '5점', 7: '7점' } }),
|
||||||
|
piBakLimit: lit<'auto' | 5 | 6 | 7>('auto', 5, 6, 7)
|
||||||
|
.default('auto')
|
||||||
|
.meta({ title: '피박 기준(패자 피 값이 이 이하)', labels: { auto: '자동(맞고 7, 고스톱 5)', 5: '5 이하', 6: '6 이하', 7: '7 이하' } }),
|
||||||
|
piBakZeroExempt: z.boolean().default(true).meta({ title: '피가 하나도 없으면 피박 면제' }),
|
||||||
|
gwangBak: z.boolean().default(true).meta({ title: '광박' }),
|
||||||
|
meongTta: z.boolean().default(true).meta({ title: '멍따(열끗 7장 이상 x2)' }),
|
||||||
|
meongBak: z.boolean().default(false).meta({ title: '멍박(멍따일 때 패자 열끗 0장 x2)' }),
|
||||||
|
goBak: z.boolean().default(true).meta({ title: '고박(맞고 x2, 3인 독박)' }),
|
||||||
|
biGwangThree: lit(2, 0).default(2).meta({ title: '비광 낀 3광', labels: { 2: '2점', 0: '점수 없음' } }),
|
||||||
|
ogwangPoints: lit(15, 30).default(15).meta({ title: '5광 점수', labels: { 15: '15점', 30: '30점' } }),
|
||||||
|
gukjinMode: z
|
||||||
|
.enum(['auto', 'ask', 'yeolOnly'])
|
||||||
|
.default('auto')
|
||||||
|
.meta({ title: '9월 열끗(국진)', labels: { auto: '유리한 쪽으로 자동', ask: '판 끝에 직접 고르기', yeolOnly: '열끗으로만' } }),
|
||||||
|
shake: z.boolean().default(true).meta({ title: '흔들기(x2)' }),
|
||||||
|
bomb: z.boolean().default(true).meta({ title: '폭탄(x2, 피 1장씩)' }),
|
||||||
|
chongtong: z
|
||||||
|
.enum(['win', 'redeal', 'off'])
|
||||||
|
.default('win')
|
||||||
|
.meta({ title: '총통(같은 월 4장)', labels: { win: '즉시 승리', redeal: '다시 돌리기', off: '그냥 진행' } }),
|
||||||
|
chongtongPoints: lit<'auto' | 5 | 7 | 10 | 20>('auto', 5, 7, 10, 20)
|
||||||
|
.default('auto')
|
||||||
|
.meta({ title: '총통·삼뻑 승리 점수', labels: { auto: '자동(맞고 10점, 고스톱 5점)', 5: '5점', 7: '7점', 10: '10점', 20: '20점' } }),
|
||||||
|
ppeokBonus: z.boolean().default(true).meta({ title: '첫뻑·연뻑 보너스, 삼뻑 즉시 승리' }),
|
||||||
|
jaPpeokSteal: lit(1, 2).default(2).meta({ title: '자뻑 때 뺏는 피', labels: { 1: '1장씩', 2: '2장씩' } }),
|
||||||
|
lastTurnSweepBonus: z.boolean().default(false).meta({ title: '마지막 턴의 쓸·쪽 피 뺏기' }),
|
||||||
|
naturalThreeSteal: z.boolean().default(true).meta({ title: '처음 깔린 같은 월 3장을 먹으면 피 뺏기' }),
|
||||||
|
nagariMultiplier: z.enum(['x2', 'none']).default('x2').meta({ title: '나가리 다음 판', labels: { x2: '점수 2배(연속이면 누적)', none: '배수 없음' } }),
|
||||||
|
nagariMaxMultiplier: lit(2, 4, 8).default(8).meta({ title: '나가리 배수 상한', labels: { 2: 'x2', 4: 'x4', 8: 'x8' } }),
|
||||||
|
gwangPalgi: z.boolean().default(true).meta({ title: '4~5명이면 광팔기' }),
|
||||||
|
gwangPrice: lit(1, 2, 3).default(1).meta({ title: '광 1장 값', labels: { 1: '1점', 2: '2점', 3: '3점' } }),
|
||||||
|
gwangPayer: z
|
||||||
|
.enum(['nonDealerPlayers', 'allPlayers'])
|
||||||
|
.default('nonDealerPlayers')
|
||||||
|
.meta({ title: '광값 내는 사람', labels: { nonDealerPlayers: '선을 뺀 참가자', allPlayers: '참가자 3명 모두' } }),
|
||||||
|
gwangSellCards: z
|
||||||
|
.enum(['gwang', 'gwangPlus'])
|
||||||
|
.default('gwang')
|
||||||
|
.meta({ title: '팔 수 있는 패', labels: { gwang: '광만', gwangPlus: '광 + 쌍피·국진·보너스' } }),
|
||||||
|
turnSeconds: lit(5, 10, 15, 20, 30, 60)
|
||||||
|
.default(15)
|
||||||
|
.meta({ title: '한 턴 제한 시간', labels: { 5: '5초', 10: '10초', 15: '15초', 20: '20초', 30: '30초', 60: '60초' } }),
|
||||||
|
decisionSeconds: lit(5, 10, 15, 20, 30)
|
||||||
|
.default(10)
|
||||||
|
.meta({ title: '고·스톱·선택 제한 시간', labels: { 5: '5초', 10: '10초', 15: '15초', 20: '20초', 30: '30초' } }),
|
||||||
|
hints: z.boolean().default(true).meta({ title: '먹을 수 있는 패·족보 힌트' }),
|
||||||
|
});
|
||||||
|
export type GostopOptions = z.infer<typeof gostopOptionsSchema>;
|
||||||
|
|
||||||
|
/** Options with the mode-dependent `auto` values resolved (§2: 2명 맞고, 3~5명 고스톱 기본값). */
|
||||||
|
export interface GostopRules extends Omit<GostopOptions, 'bonusCards' | 'winScore' | 'piBakLimit' | 'chongtongPoints'> {
|
||||||
|
bonusCards: BonusSetting;
|
||||||
|
/** Number of bonus cards (0, 3 or 4). */
|
||||||
|
nb: number;
|
||||||
|
winScore: number;
|
||||||
|
piBakLimit: number;
|
||||||
|
chongtongPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gostopActionSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({ type: z.literal('sell'), sell: z.boolean() }),
|
||||||
|
z.object({ type: z.literal('chongtong'), declare: z.boolean() }),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('play'),
|
||||||
|
card: z.union([z.number().int().min(0).max(51), z.literal('dummy')]),
|
||||||
|
target: z.number().int().min(0).max(51).optional(),
|
||||||
|
shake: z.boolean().optional(),
|
||||||
|
bomb: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
z.object({ type: z.literal('chooseFlip'), target: z.number().int().min(0).max(51) }),
|
||||||
|
z.object({ type: z.literal('goStop'), decision: z.enum(['go', 'stop']) }),
|
||||||
|
z.object({ type: z.literal('gukjin'), as: z.enum(['yeol', 'pi']) }),
|
||||||
|
z.object({ type: z.literal('ready') }),
|
||||||
|
]);
|
||||||
|
export type GostopClientAction = z.infer<typeof gostopActionSchema>;
|
||||||
|
export type PlayAction = Extract<GostopClientAction, { type: 'play' }>;
|
||||||
|
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
|
||||||
|
export type GostopAction = GostopClientAction | { type: 'timeout' } | { type: 'leave' };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- state
|
||||||
|
|
||||||
|
export interface FloorGroup {
|
||||||
|
month: number;
|
||||||
|
/** 1..3 cards; 3 = 뻑 무더기 or 자연 뻑. */
|
||||||
|
cards: CardId[];
|
||||||
|
ppeokBy: number | null;
|
||||||
|
natural: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TurnTag =
|
||||||
|
| 'bonus' // 손에서 보너스패를 냄
|
||||||
|
| 'bonusFlip' // 더미에서 보너스패가 뒤집힘
|
||||||
|
| 'jjok'
|
||||||
|
| 'ppeok'
|
||||||
|
| 'ttadak'
|
||||||
|
| 'sseul'
|
||||||
|
| 'bomb'
|
||||||
|
| 'shake'
|
||||||
|
| 'ppeokEat' // 남의 뻑 / 자연 뻑 먹기
|
||||||
|
| 'jaPpeok'
|
||||||
|
| 'firstPpeok'
|
||||||
|
| 'yeonPpeok'
|
||||||
|
| 'samPpeok'
|
||||||
|
| 'dummy';
|
||||||
|
|
||||||
|
/** Public summary of the latest turn (drives the screen's event toasts). */
|
||||||
|
export interface TurnRecord {
|
||||||
|
seq: number;
|
||||||
|
player: number;
|
||||||
|
played: CardId[];
|
||||||
|
flipped: CardId[];
|
||||||
|
captured: CardId[];
|
||||||
|
tags: TurnTag[];
|
||||||
|
/** 피 cards taken from each opponent: [opponent, cards]. */
|
||||||
|
stolen: [number, CardId[]][];
|
||||||
|
/** 첫뻑·연뻑 points received from each opponent. */
|
||||||
|
bonusPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnCtx {
|
||||||
|
player: number;
|
||||||
|
played: CardId[];
|
||||||
|
flipped: CardId[];
|
||||||
|
captured: CardId[];
|
||||||
|
tags: TurnTag[];
|
||||||
|
steal: number;
|
||||||
|
/** This turn flipped the last deck card (§5.2 lastTurnSweepBonus). */
|
||||||
|
lastOfRound: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BakKind = 'pi' | 'gwang' | 'meong' | 'go';
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
from: number;
|
||||||
|
amount: number;
|
||||||
|
baks: BakKind[];
|
||||||
|
/** 독박: this loser pays nothing because the go-caller pays for them. */
|
||||||
|
coveredBy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundOutcome {
|
||||||
|
type: 'stop' | 'chongtong' | 'samppeok' | 'nagari';
|
||||||
|
winner: number | null;
|
||||||
|
baseScore: number;
|
||||||
|
breakdown: { name: string; points: number }[];
|
||||||
|
goCount: number;
|
||||||
|
multipliers: { name: string; factor: number }[];
|
||||||
|
/** Amount before the per-loser 박 multipliers. */
|
||||||
|
perLoser: number;
|
||||||
|
payments: Payment[];
|
||||||
|
gukjinAs: 'yeol' | 'pi' | null;
|
||||||
|
/** 총통 cards (public once declared). */
|
||||||
|
shown: CardId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Seller {
|
||||||
|
idx: number;
|
||||||
|
shown: CardId[];
|
||||||
|
/** Total points received for the shown cards. */
|
||||||
|
paid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundState {
|
||||||
|
no: number;
|
||||||
|
/** Player indexes playing this round, in turn order from the dealer. */
|
||||||
|
participants: number[];
|
||||||
|
sellOrder: number[];
|
||||||
|
sellCursor: number;
|
||||||
|
sellDecisions: { idx: number; sell: boolean; auto: boolean }[];
|
||||||
|
sellers: Seller[];
|
||||||
|
hands: CardId[][];
|
||||||
|
bombDummies: number[];
|
||||||
|
/** 흔들기·총통 선언으로 공개된 손패. */
|
||||||
|
revealed: CardId[][];
|
||||||
|
shakenMonths: number[][];
|
||||||
|
deck: CardId[];
|
||||||
|
floor: FloorGroup[];
|
||||||
|
captured: CardId[][];
|
||||||
|
gukjinChoice: (null | 'yeol' | 'pi')[];
|
||||||
|
goCount: number[];
|
||||||
|
lastGoScore: number[];
|
||||||
|
goOrder: number[];
|
||||||
|
shakes: number[];
|
||||||
|
bombs: number[];
|
||||||
|
ppeokCount: number[];
|
||||||
|
ppeokTurns: number[][];
|
||||||
|
turnsTaken: number[];
|
||||||
|
turn: number;
|
||||||
|
step: 'sell' | 'chongtong' | 'play' | 'chooseFlip' | 'goStop' | 'gukjin' | 'done';
|
||||||
|
pending: { flipped: CardId; options: CardId[]; ctx: TurnCtx } | null;
|
||||||
|
/** Player who stopped, waiting for the 국진 choice (step 'gukjin'). */
|
||||||
|
stopBy: number | null;
|
||||||
|
chongtongBy: number | null;
|
||||||
|
nagariMult: number;
|
||||||
|
/** Net 첫뻑·연뻑·광값 points moved during this round, per player. */
|
||||||
|
instant: number[];
|
||||||
|
turnSeq: number;
|
||||||
|
lastTurn: TurnRecord | null;
|
||||||
|
outcome: RoundOutcome | null;
|
||||||
|
redeals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundLog {
|
||||||
|
no: number;
|
||||||
|
type: RoundOutcome['type'];
|
||||||
|
winner: number | null;
|
||||||
|
amount: number;
|
||||||
|
/** Net change per player this round (판 점수 + 광값 + 첫뻑·연뻑). */
|
||||||
|
delta: number[];
|
||||||
|
breakdown: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GostopFinal {
|
||||||
|
ranking: PlayerId[][];
|
||||||
|
scores: Record<PlayerId, number>;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GostopState {
|
||||||
|
options: GostopOptions;
|
||||||
|
rules: GostopRules;
|
||||||
|
mode: 'matgo' | 'gostop';
|
||||||
|
players: PlayerId[];
|
||||||
|
totals: number[];
|
||||||
|
roundNo: number;
|
||||||
|
dealer: number;
|
||||||
|
nagariStreak: number;
|
||||||
|
round: RoundState | null;
|
||||||
|
phase: 'playing' | 'roundResult' | 'finished';
|
||||||
|
ready: number[];
|
||||||
|
left: boolean[];
|
||||||
|
autoPilot: boolean[];
|
||||||
|
timeoutStreak: number[];
|
||||||
|
deadline: number | null;
|
||||||
|
waitKey: string;
|
||||||
|
actionSeq: number;
|
||||||
|
log: RoundLog[];
|
||||||
|
stats: { wins: number[]; goCalls: number[]; piBakGiven: number[]; gwangSold: number[]; nagari: number };
|
||||||
|
final: GostopFinal | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- view
|
||||||
|
|
||||||
|
export interface ScoreView {
|
||||||
|
total: number;
|
||||||
|
items: { name: string; points: number }[];
|
||||||
|
gwang: number;
|
||||||
|
yeol: number;
|
||||||
|
tti: number;
|
||||||
|
pi: number;
|
||||||
|
hong: number;
|
||||||
|
cheong: number;
|
||||||
|
cho: number;
|
||||||
|
godori: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeatView {
|
||||||
|
id: PlayerId;
|
||||||
|
total: number;
|
||||||
|
participant: boolean;
|
||||||
|
seller: { shown: CardView[]; paid: number } | null;
|
||||||
|
handCount: number;
|
||||||
|
dummies: number;
|
||||||
|
revealed: CardView[];
|
||||||
|
captured: { gwang: CardView[]; yeol: CardView[]; tti: CardView[]; pi: CardView[] };
|
||||||
|
score: ScoreView;
|
||||||
|
goCount: number;
|
||||||
|
shakes: number;
|
||||||
|
bombs: number;
|
||||||
|
ppeok: number;
|
||||||
|
left: boolean;
|
||||||
|
autoPilot: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
isDealer: boolean;
|
||||||
|
isTurn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandCardView {
|
||||||
|
card: CardView;
|
||||||
|
/** Floor cards of the same month (먹을 수 있는 패). */
|
||||||
|
matches: CardId[];
|
||||||
|
/** Two different floor cards: the player must pick one. */
|
||||||
|
needTarget: boolean;
|
||||||
|
canShake: boolean;
|
||||||
|
canBomb: boolean;
|
||||||
|
/** One-line hint, e.g. "홍단 완성" (hints option). */
|
||||||
|
hint: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoStopPreview {
|
||||||
|
score: number;
|
||||||
|
goCount: number;
|
||||||
|
/** Points if stopping now: amount per loser and the multiplier notes. */
|
||||||
|
stopNow: { total: number; perLoser: { player: PlayerId; amount: number; baks: BakKind[] }[]; notes: string[] };
|
||||||
|
/** After one more 고: count, added points and the 고 multiplier. */
|
||||||
|
goNext: { goCount: number; plus: number; factor: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutcomeView extends Omit<RoundOutcome, 'winner' | 'payments' | 'shown'> {
|
||||||
|
winner: PlayerId | null;
|
||||||
|
payments: { from: PlayerId; amount: number; baks: BakKind[]; coveredBy: PlayerId | null }[];
|
||||||
|
shown: CardView[];
|
||||||
|
delta: Record<PlayerId, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GostopView {
|
||||||
|
rules: GostopRules;
|
||||||
|
mode: GostopState['mode'];
|
||||||
|
me: PlayerId | null;
|
||||||
|
phase: GostopState['phase'];
|
||||||
|
step: RoundState['step'] | null;
|
||||||
|
roundNo: number;
|
||||||
|
dealer: PlayerId;
|
||||||
|
nagariStreak: number;
|
||||||
|
nagariMult: number;
|
||||||
|
turn: PlayerId | null;
|
||||||
|
deckCount: number;
|
||||||
|
floor: { month: number; cards: CardView[]; ppeokBy: PlayerId | null; natural: boolean }[];
|
||||||
|
seats: SeatView[];
|
||||||
|
myHand: HandCardView[] | null;
|
||||||
|
myDummies: number;
|
||||||
|
pendingFlip: { player: PlayerId; flipped: CardView; options: CardView[] } | null;
|
||||||
|
goStop: (GoStopPreview & { player: PlayerId }) | null;
|
||||||
|
gukjinAsk: { player: PlayerId; yeolScore: number; piScore: number } | null;
|
||||||
|
chongtongAsk: { player: PlayerId; months: number[] | null } | null;
|
||||||
|
sell: {
|
||||||
|
current: PlayerId | null;
|
||||||
|
decisions: { player: PlayerId; sell: boolean; auto: boolean }[];
|
||||||
|
/** Sellable cards of the deciding player (only in their own view). */
|
||||||
|
mySellable: CardView[] | null;
|
||||||
|
canSell: boolean;
|
||||||
|
} | null;
|
||||||
|
lastTurn: (Omit<TurnRecord, 'player' | 'played' | 'flipped' | 'captured' | 'stolen'> & {
|
||||||
|
player: PlayerId;
|
||||||
|
played: CardView[];
|
||||||
|
flipped: CardView[];
|
||||||
|
captured: CardView[];
|
||||||
|
stolen: { from: PlayerId; cards: CardView[] }[];
|
||||||
|
}) | null;
|
||||||
|
outcome: OutcomeView | null;
|
||||||
|
log: (Omit<RoundLog, 'winner' | 'delta'> & { winner: PlayerId | null; delta: Record<PlayerId, number> })[];
|
||||||
|
ready: PlayerId[];
|
||||||
|
deadline: number | null;
|
||||||
|
result: GostopFinal | null;
|
||||||
|
stats: {
|
||||||
|
rounds: number;
|
||||||
|
nagari: number;
|
||||||
|
bestRound: { player: PlayerId; amount: number; roundNo: number; breakdown: string[] } | null;
|
||||||
|
perPlayer: { player: PlayerId; wins: number; goCalls: number; piBakGiven: number; gwangSold: number }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { oneCard } from './one-card';
|
|||||||
import { rummikub } from './rummikub';
|
import { rummikub } from './rummikub';
|
||||||
import { davinci } from './davinci-code';
|
import { davinci } from './davinci-code';
|
||||||
import { mahjong } from './mahjong';
|
import { mahjong } from './mahjong';
|
||||||
|
import { gostop } from './gostop';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
@@ -27,4 +28,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
|||||||
[rummikub.id]: rummikub,
|
[rummikub.id]: rummikub,
|
||||||
[davinci.id]: davinci,
|
[davinci.id]: davinci,
|
||||||
[mahjong.id]: mahjong,
|
[mahjong.id]: mahjong,
|
||||||
|
[gostop.id]: gostop,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const CATALOG: CatalogEntry[] = [
|
|||||||
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
|
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
|
||||||
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
|
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
|
||||||
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
|
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
|
||||||
|
{ id: 'gostop', nameKo: '고스톱', minPlayers: 2, maxPlayers: 5, minutes: '20~40', level: '보통', blurb: '같은 월을 맞춰 먹고 고·스톱! 2인 맞고·3인·광팔기 · 놀이용 칩', available: true },
|
||||||
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
|
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
|
||||||
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user