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

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

613 lines
23 KiB
TypeScript

/** 고스톱 / 맞고 / 광팔기 (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]!,
})),
};
}