/** 고스톱 / 맞고 / 광팔기 (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): { 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) { 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(); 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 = { id: 'gostop', nameKo: '고스톱', minPlayers: 2, maxPlayers: 5, stateVersion: 1, defaultOptions: gostopOptionsSchema.parse({}), optionsSchema: gostopOptionsSchema as unknown as z.ZodType, actionSchema: gostopActionSchema as unknown as z.ZodType, 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]!, })), }; }