- 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>
613 lines
23 KiB
TypeScript
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]!,
|
|
})),
|
|
};
|
|
}
|