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>
This commit is contained in:
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]!,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user