/** * 주사위 땅따먹기 상태 기계 (docs/games/marble.md §5, §8). Functions here mutate a state that * `apply` has already cloned. The RNG is only used for dice (ROLL) — the deck is shuffled once in * setup and never again (cards go back to the bottom). */ import { z } from 'zod'; import { nextSeat, type GameEvent, type PlayerId, type Rng } from '@bg/engine'; import { BIRTHDAY_GIFT, CARD_CAP, FUND_PAYMENT, GROUPS, ISLAND, KEEP_CARDS, LINES, PROPERTY_POSITIONS, PROPERTY_TAX, REPAIR_PER_STAGE, SEOUL, SPACES, SPACE_CENTER, SPACE_FARE, SPACE_TRAVEL, START, FUND_IN, TOUR_POSITIONS, TRAVEL_GIFT, baseToll, forwardSteps, fullSale, investment, isCity, stageCost, stageSale, takeoverPrice, type CardId, type Level, } from './board'; // ---------------------------------------------------------------- options & actions export const marbleOptionsSchema = z.object({ startMoney: z .union([z.literal(200), z.literal(300), z.literal(500)]) .default(300) .meta({ title: '시작 포인트', labels: { 200: '200P', 300: '300P', 500: '500P' } }), salary: z .union([z.literal(10), z.literal(20), z.literal(30)]) .default(20) .meta({ title: '월급(출발 칸을 지나거나 도착할 때)', labels: { 10: '10P', 20: '20P', 30: '30P' } }), maxRounds: z .union([z.literal(0), z.literal(15), z.literal(20), z.literal(30)]) .default(20) .meta({ title: '라운드 제한', labels: { 0: '무제한(파산으로만 끝)', 15: '15라운드', 20: '20라운드', 30: '30라운드' } }), buildOnPurchase: z.boolean().default(false).meta({ title: '땅을 산 그 자리에서 별장까지 바로 짓기' }), islandTurns: z .union([z.literal(1), z.literal(2), z.literal(3)]) .default(3) .meta({ title: '무인도에서 최대 쉬는 차례', labels: { 1: '1번', 2: '2번', 3: '3번' } }), islandFee: z .union([z.literal(0), z.literal(10), z.literal(20)]) .default(0) .meta({ title: '무인도 탈출비(내고 바로 나가기)', labels: { 0: '없음(낼 수 없음)', 10: '10P', 20: '20P' } }), takeover: z.boolean().default(false).meta({ title: '인수(통행료를 낸 뒤 2배 값에 사들이기)' }), landmark: z.boolean().default(false).meta({ title: '랜드마크(호텔 다음 단계)' }), monopolyWin: z.boolean().default(false).meta({ title: '독점 즉시 승리(트리플·라인·관광지)' }), trade: z.boolean().default(false).meta({ title: '플레이어끼리 땅·포인트 교환' }), turnSeconds: z .union([z.literal(15), z.literal(30), z.literal(60), z.literal(null)]) .default(30) .meta({ title: '결정 1번 제한 시간', labels: { 15: '15초', 30: '30초', 60: '60초', null: '무제한' } }), }); export type MarbleOptions = z.infer; const posSchema = z.number().int().min(0).max(39); const offerSchema = z.object({ lands: z.array(posSchema).max(28), cash: z.number().int().min(0).max(1_000_000) }); export const marbleActionSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('ROLL') }), z.object({ type: z.literal('PAY_ISLAND_FEE') }), z.object({ type: z.literal('USE_ESCAPE_CARD') }), z.object({ type: z.literal('SPACE_TRAVEL'), dest: posSchema }), z.object({ type: z.literal('BUY') }), z.object({ type: z.literal('BUILD'), level: z.number().int().min(1).max(4) }), z.object({ type: z.literal('TAKEOVER') }), z.object({ type: z.literal('PASS') }), z.object({ type: z.literal('SELL'), pos: posSchema, what: z.enum(['top', 'all']) }), z.object({ type: z.literal('PROPOSE_TRADE'), to: z.string().min(1).max(64), give: offerSchema, take: offerSchema }), z.object({ type: z.literal('TRADE_RESPOND'), accept: z.boolean() }), z.object({ type: z.literal('CONCEDE') }), ]); export type MarbleClientAction = z.infer; /** Client actions plus the system-only timer action (not in the schema, docs/09 §2). */ export type MarbleAction = MarbleClientAction | { type: 'TIMEOUT' }; export type TradeOffer = { lands: number[]; cash: number }; // ---------------------------------------------------------------- state export type Creditor = { kind: 'player'; id: PlayerId } | { kind: 'bank' } | { kind: 'fund' }; export type DebtReason = 'toll' | 'card' | 'fund' | 'fare'; export interface Debt { debtor: PlayerId; amount: number; to: Creditor; reason: DebtReason; } /** What happens once every queued payment is settled. */ export type Cont = { k: 'land' } | { k: 'toll'; pos: number } | { k: 'space'; dest: number }; export interface MarblePlayer { pos: number; cash: number; alive: boolean; bankruptAtRound: number | null; /** 1 = first to go bankrupt / concede. */ outSeq: number | null; /** 0 = not on the island. */ islandLeft: number; spacePending: boolean; heldCards: CardId[]; /** Left by CONCEDE (not by bankruptcy). */ conceded: boolean; } export interface Owned { owner: PlayerId; level: Level; } export interface Decision { pos: number; canBuy: boolean; canBuild: Level | null; /** 인수가, when a takeover is offered. */ canTakeover: number | null; boughtThisVisit: boolean; } export interface DebtState extends Debt { queue: Debt[]; then: Cont; } export interface Trade { from: PlayerId; to: PlayerId; give: TradeOffer; take: TradeOffer; } export type MoveKind = 'roll' | 'card' | 'back' | 'space' | 'warp'; export interface MarbleRankEntry { rank: number; player: PlayerId; totalAssets: number; cash: number; lands: number; bankruptAtRound: number | null; } export interface MarbleResult { reason: 'lastSurvivor' | 'roundLimit' | 'monopoly'; monopolyType?: 'triple' | 'line' | 'tour'; ranking: MarbleRankEntry[]; winners: PlayerId[]; summary: string; } export type Phase = 'turnStart' | 'decide' | 'debt' | 'tradeResponse' | 'finished'; export interface MarbleState { options: MarbleOptions; order: PlayerId[]; current: number; round: number; phase: Phase; players: Record; /** Cities and tourist spots that have an owner, keyed by position. */ owners: Record; fund: number; /** Hidden: index 0 = top. */ deck: CardId[]; /** Cards drawn at least once (their IDs are public from then on). */ revealed: CardId[]; doublesThisTurn: number; cardsThisTurn: number; decision: Decision | null; debt: DebtState | null; pendingTrade: Trade | null; tradesThisTurn: number; /** Doubles: the same player rolls again from turnStart. */ extraRoll: boolean; lastRoll: { seq: number; player: PlayerId; dice: [number, number]; double: boolean; island: boolean } | null; lastMove: { seq: number; player: PlayerId; from: number; to: number; kind: MoveKind; passedStart: boolean } | null; lastCard: { seq: number; player: PlayerId; id: CardId } | null; /** Increases with every roll / move / card (animation keys). */ seq: number; turnSeq: number; deadlineAt: number | null; timeouts: Record; log: GameEvent[]; outCount: number; /** Bank flows (points created / destroyed), for the bookkeeping invariant. */ ledger: { minted: number; burned: number }; final: MarbleResult | null; } // ---------------------------------------------------------------- small helpers const LOG_MAX = 60; export const AWAY_AFTER = 3; export const AWAY_MS = 5000; export const TRADE_MS = 20_000; export const MAX_TRADES_PER_TURN = 2; export const curId = (s: MarbleState): PlayerId => s.order[s.current]!; export const pl = (s: MarbleState, id: PlayerId): MarblePlayer => s.players[id]!; export const aliveIds = (s: MarbleState): PlayerId[] => s.order.filter((p) => s.players[p]!.alive); export const maxLevel = (o: MarbleOptions): Level => (o.landmark ? 4 : 3); export function pushLog(s: MarbleState, ev: GameEvent[], e: GameEvent): void { ev.push(e); s.log.push(e); if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX); } export const ownedBy = (s: MarbleState, id: PlayerId): number[] => PROPERTY_POSITIONS.filter((p) => s.owners[p]?.owner === id); /** Toll to pay on `pos` right now (tourist spots double when the owner has 2+). */ export function tollAt(s: MarbleState, pos: number): number { const o = s.owners[pos]; if (!o) return 0; const base = baseToll(pos, o.level); if (SPACES[pos]!.kind === 'tour') { const n = TOUR_POSITIONS.filter((p) => s.owners[p]?.owner === o.owner).length; return n >= 2 ? base * 2 : base; } return base; } /** 현금 + 모든 건물·땅 매각가 합 (§5.3). */ export const raisable = (s: MarbleState, id: PlayerId): number => pl(s, id).cash + ownedBy(s, id).reduce((a, p) => a + fullSale(p, s.owners[p]!.level), 0); /** 총자산 = 현금 + Σ(땅값 + 건설비, 정가) (§6). */ export const totalAssets = (s: MarbleState, id: PlayerId): number => pl(s, id).cash + ownedBy(s, id).reduce((a, p) => a + investment(p, s.owners[p]!.level), 0); export function canTakeoverAt(s: MarbleState, pos: number, by: PlayerId): number | null { const o = s.owners[pos]; if (!s.options.takeover || !o || !isCity(pos) || o.level >= 4 || o.owner === by || !pl(s, o.owner).alive) return null; return takeoverPrice(pos, o.level); } /** Bank → player. */ function mint(s: MarbleState, id: PlayerId, amount: number): void { pl(s, id).cash += amount; s.ledger.minted += amount; } /** Player → creditor (a gone player creditor counts as the bank). */ function transfer(s: MarbleState, from: PlayerId, to: Creditor, amount: number): void { const p = pl(s, from); if (amount > p.cash) throw new Error(`marble: ${from} cannot pay ${amount} with ${p.cash}`); p.cash -= amount; if (to.kind === 'player' && s.players[to.id]?.alive) s.players[to.id]!.cash += amount; else if (to.kind === 'fund') s.fund += amount; else s.ledger.burned += amount; } const bankC: Creditor = { kind: 'bank' }; const playerC = (id: PlayerId): Creditor => ({ kind: 'player', id }); function deckBottom(s: MarbleState, id: CardId): void { s.deck.push(id); } // ---------------------------------------------------------------- movement function salary(s: MarbleState, id: PlayerId, ev: GameEvent[]): void { mint(s, id, s.options.salary); pushLog(s, ev, { type: 'salary', player: id, amount: s.options.salary }); } /** Clockwise move; passing or reaching 출발 pays the salary once. */ function moveForward(s: MarbleState, id: PlayerId, steps: number, kind: MoveKind, ev: GameEvent[]): void { const p = pl(s, id); const from = p.pos; const passedStart = steps > 0 && from + steps >= 40; p.pos = (from + steps) % 40; s.lastMove = { seq: ++s.seq, player: id, from, to: p.pos, kind, passedStart }; pushLog(s, ev, { type: 'moved', player: id, from, to: p.pos, kind, passedStart }); if (passedStart) salary(s, id, ev); } function moveBack(s: MarbleState, id: PlayerId, steps: number, ev: GameEvent[]): void { const p = pl(s, id); const from = p.pos; p.pos = (from - steps + 40) % 40; s.lastMove = { seq: ++s.seq, player: id, from, to: p.pos, kind: 'back', passedStart: false }; pushLog(s, ev, { type: 'moved', player: id, from, to: p.pos, kind: 'back', passedStart: false }); } function sendToIsland(s: MarbleState, id: PlayerId, why: 'landed' | 'doubles' | 'card', ev: GameEvent[]): void { const p = pl(s, id); const from = p.pos; p.pos = ISLAND; p.islandLeft = s.options.islandTurns; s.extraRoll = false; s.doublesThisTurn = 0; if (from !== ISLAND) s.lastMove = { seq: ++s.seq, player: id, from, to: ISLAND, kind: 'warp', passedStart: false }; pushLog(s, ev, { type: 'toIsland', player: id, why, turns: p.islandLeft }); } // ---------------------------------------------------------------- turn flow /** Landing resolved (and every decision taken): roll again on doubles, else the turn ends. */ function afterLanding(s: MarbleState, ev: GameEvent[]): void { s.decision = null; if (s.phase === 'finished') return; if (!pl(s, curId(s)).alive) return endTurn(s, ev); if (s.extraRoll) { s.phase = 'turnStart'; pushLog(s, ev, { type: 'extraRoll', player: curId(s) }); return; } endTurn(s, ev); } export function endTurn(s: MarbleState, ev: GameEvent[]): void { s.decision = null; s.debt = null; s.pendingTrade = null; s.extraRoll = false; s.doublesThisTurn = 0; s.cardsThisTurn = 0; s.tradesThisTurn = 0; if (s.phase === 'finished') return; if (aliveIds(s).length <= 1) return finish(s, 'lastSurvivor', ev); const from = s.current; const next = nextSeat(from, s.order.length, (i) => !s.players[s.order[i]!]!.alive); if (next <= from) { pushLog(s, ev, { type: 'roundEnd', round: s.round }); if (s.options.maxRounds > 0 && s.round >= s.options.maxRounds) return finish(s, 'roundLimit', ev); s.round += 1; } s.current = next; s.phase = 'turnStart'; pushLog(s, ev, { type: 'turn', player: curId(s), round: s.round }); } /** Arrival on the current player's square (§5.2). */ function land(s: MarbleState, ev: GameEvent[]): void { const id = curId(s); const p = pl(s, id); const sp = SPACES[p.pos]!; switch (sp.kind) { case 'start': return afterLanding(s, ev); case 'city': case 'tour': { const o = s.owners[p.pos]; if (!o) return offerBuy(s, ev); if (o.owner === id) return offerBuild(s, ev, false); return payToll(s, ev); } case 'card': if (s.cardsThisTurn >= CARD_CAP) { pushLog(s, ev, { type: 'cardIgnored', player: id }); return afterLanding(s, ev); } return drawCard(s, ev); case 'fundOut': return pay(s, [{ debtor: id, amount: FUND_PAYMENT, to: { kind: 'fund' }, reason: 'fund' }], { k: 'land' }, ev); case 'fundIn': { const amount = s.fund; if (amount > 0) { s.fund = 0; p.cash += amount; } pushLog(s, ev, { type: 'fundReceived', player: id, amount }); return afterLanding(s, ev); } case 'island': sendToIsland(s, id, 'landed', ev); return endTurn(s, ev); case 'space': p.spacePending = true; s.extraRoll = false; pushLog(s, ev, { type: 'spaceWait', player: id }); return endTurn(s, ev); } } function offerBuy(s: MarbleState, ev: GameEvent[]): void { const id = curId(s); const pos = pl(s, id).pos; const price = SPACES[pos]!.price!; if (pl(s, id).cash < price) { pushLog(s, ev, { type: 'cannotAfford', player: id, pos, what: 'buy', cost: price }); return afterLanding(s, ev); } s.decision = { pos, canBuy: true, canBuild: null, canTakeover: null, boughtThisVisit: false }; s.phase = 'decide'; } /** Own square: offer the next building stage (one per visit). */ function offerBuild(s: MarbleState, ev: GameEvent[], justBought: boolean): void { const id = curId(s); const pos = pl(s, id).pos; const o = s.owners[pos]!; if (!isCity(pos) || o.level >= maxLevel(s.options) || (justBought && (!s.options.buildOnPurchase || o.level !== 0))) return afterLanding(s, ev); const next = (o.level + 1) as Level; const cost = stageCost(pos, next); if (pl(s, id).cash < cost) { if (!justBought) pushLog(s, ev, { type: 'cannotAfford', player: id, pos, what: 'build', cost }); return afterLanding(s, ev); } s.decision = { pos, canBuy: false, canBuild: next, canTakeover: null, boughtThisVisit: justBought }; s.phase = 'decide'; } function payToll(s: MarbleState, ev: GameEvent[]): void { const id = curId(s); const p = pl(s, id); const pos = p.pos; const owner = s.owners[pos]!.owner; const amount = tollAt(s, pos); const waiver = p.heldCards.indexOf('L12'); if (waiver >= 0) { p.heldCards.splice(waiver, 1); deckBottom(s, 'L12'); pushLog(s, ev, { type: 'tollPaid', from: id, to: owner, pos, amount, waived: true }); return contToll(s, pos, ev); } pay(s, [{ debtor: id, amount, to: playerC(owner), reason: 'toll' }], { k: 'toll', pos }, ev); } /** After the toll: offer a takeover when the option allows it (§5.7). */ function contToll(s: MarbleState, pos: number, ev: GameEvent[]): void { const id = curId(s); const price = canTakeoverAt(s, pos, id); if (price !== null && pl(s, id).cash >= price) { s.decision = { pos, canBuy: false, canBuild: null, canTakeover: price, boughtThisVisit: false }; s.phase = 'decide'; return; } afterLanding(s, ev); } function runCont(s: MarbleState, then: Cont, ev: GameEvent[]): void { if (s.phase === 'finished') return; if (!pl(s, curId(s)).alive) return endTurn(s, ev); if (then.k === 'land') return afterLanding(s, ev); if (then.k === 'toll') return contToll(s, then.pos, ev); return fly(s, then.dest, ev); } /** Settles payments in order; stops in the `debt` phase when someone has to sell first (§5.3). */ function pay(s: MarbleState, debts: Debt[], then: Cont, ev: GameEvent[]): void { s.debt = null; runQueue(s, [...debts], then, ev); } function runQueue(s: MarbleState, queue: Debt[], then: Cont, ev: GameEvent[]): void { while (queue.length > 0) { const d = queue[0]!; const p = pl(s, d.debtor); if (!p.alive || d.amount <= 0) { queue.shift(); continue; } if (p.cash >= d.amount) { transfer(s, d.debtor, d.to, d.amount); logPaid(s, d, ev); queue.shift(); continue; } if (raisable(s, d.debtor) < d.amount) { queue.shift(); bankrupt(s, d.debtor, d.to, ev); if (s.phase === 'finished') return; if (d.debtor === curId(s)) return endTurn(s, ev); continue; } s.phase = 'debt'; s.debt = { ...d, queue: queue.slice(1), then }; pushLog(s, ev, { type: 'debt', player: d.debtor, amount: d.amount, short: d.amount - p.cash, to: d.to, reason: d.reason }); return; } s.debt = null; runCont(s, then, ev); } function logPaid(s: MarbleState, d: Debt, ev: GameEvent[]): void { if (d.reason === 'toll' && d.to.kind === 'player') { pushLog(s, ev, { type: 'tollPaid', from: d.debtor, to: d.to.id, pos: pl(s, d.debtor).pos, amount: d.amount, waived: false }); } else if (d.reason === 'fund') { pushLog(s, ev, { type: 'fundPaid', player: d.debtor, amount: d.amount }); } else { pushLog(s, ev, { type: 'paid', from: d.debtor, to: d.to, amount: d.amount, reason: d.reason }); } } /** Sells everything to the bank (sale value), returns the lands to the bank. */ function liquidate(s: MarbleState, id: PlayerId): number { let got = 0; for (const pos of ownedBy(s, id)) { got += fullSale(pos, s.owners[pos]!.level); delete s.owners[pos]; } if (got > 0) mint(s, id, got); return got; } /** 파산 (§5.3): sell everything, all points go to the creditor, the player is out. */ function bankrupt(s: MarbleState, id: PlayerId, to: Creditor, ev: GameEvent[]): void { liquidate(s, id); const p = pl(s, id); const amount = p.cash; if (amount > 0) transfer(s, id, to, amount); retire(s, id); pushLog(s, ev, { type: 'bankrupt', player: id, creditor: to, amount }); if (aliveIds(s).length <= 1) finish(s, 'lastSurvivor', ev); } function retire(s: MarbleState, id: PlayerId): void { const p = pl(s, id); for (const c of p.heldCards) deckBottom(s, c); p.heldCards = []; p.alive = false; p.bankruptAtRound = s.round; p.outSeq = ++s.outCount; p.islandLeft = 0; p.spacePending = false; s.timeouts[id] = 0; } // ---------------------------------------------------------------- cards function drawCard(s: MarbleState, ev: GameEvent[]): void { const id = curId(s); const p = pl(s, id); const card = s.deck.shift(); if (!card) throw new Error('marble: empty deck'); s.cardsThisTurn += 1; s.lastCard = { seq: ++s.seq, player: id, id: card }; if (!s.revealed.includes(card)) s.revealed.push(card); pushLog(s, ev, { type: 'cardDrawn', player: id, cardId: card }); if (KEEP_CARDS.includes(card)) p.heldCards.push(card); else deckBottom(s, card); const others = () => { // Seat order starting after the current player (§5.3). const out: PlayerId[] = []; for (let k = 1; k < s.order.length; k++) { const q = s.order[(s.current + k) % s.order.length]!; if (s.players[q]!.alive) out.push(q); } return out; }; const gain = (n: number) => { mint(s, id, n); pushLog(s, ev, { type: 'cardGain', player: id, amount: n }); afterLanding(s, ev); }; const owe = (amount: number, to: Creditor) => (amount > 0 ? pay(s, [{ debtor: id, amount, to, reason: 'card' }], { k: 'land' }, ev) : afterLanding(s, ev)); switch (card) { case 'L01': return gain(20); case 'L02': return gain(10); case 'L03': return gain(15); case 'L05': return gain(10); case 'L04': return pay(s, others().map((q) => ({ debtor: q, amount: BIRTHDAY_GIFT, to: playerC(id), reason: 'card' as const })), { k: 'land' }, ev); case 'L06': return owe(5, { kind: 'fund' }); case 'L07': return owe(10, bankC); case 'L08': return owe(10, { kind: 'fund' }); case 'L09': return owe(repairCost(s, id), bankC); case 'L20': return owe(ownedBy(s, id).length * PROPERTY_TAX, bankC); case 'L19': return pay(s, others().map((q) => ({ debtor: id, amount: TRAVEL_GIFT, to: playerC(q), reason: 'card' as const })), { k: 'land' }, ev); case 'L10': sendToIsland(s, id, 'card', ev); return endTurn(s, ev); case 'L11': case 'L12': return afterLanding(s, ev); case 'L13': moveForward(s, id, forwardSteps(p.pos, START) || 40, 'card', ev); return land(s, ev); case 'L14': moveForward(s, id, forwardSteps(p.pos, SPACE_TRAVEL), 'card', ev); return land(s, ev); case 'L15': moveForward(s, id, forwardSteps(p.pos, SEOUL), 'card', ev); return land(s, ev); case 'L16': moveBack(s, id, 3, ev); return land(s, ev); case 'L17': moveForward(s, id, forwardSteps(p.pos, FUND_IN), 'card', ev); return land(s, ev); case 'L18': { const dest = TOUR_POSITIONS.map((t) => ({ t, d: forwardSteps(p.pos, t) || 40 })).sort((a, b) => a.d - b.d)[0]!; moveForward(s, id, dest.d, 'card', ev); return land(s, ev); } } } /** L09: 별장 3, 빌딩 6, 호텔 10, 랜드마크 15 per built stage. */ export function repairCost(s: MarbleState, id: PlayerId): number { let t = 0; for (const pos of ownedBy(s, id)) for (let l = 1; l <= s.owners[pos]!.level; l++) t += REPAIR_PER_STAGE[l - 1]!; return t; } // ---------------------------------------------------------------- monopoly & finish export function monopolyOf(s: MarbleState, id: PlayerId): 'triple' | 'line' | 'tour' | null { const mine = (p: number) => s.owners[p]?.owner === id; const groups = Object.values(GROUPS).filter((g) => g.every(mine)).length; if (groups >= 3) return 'triple'; if (LINES.some((l) => l.every(mine))) return 'line'; if (TOUR_POSITIONS.every(mine)) return 'tour'; return null; } function checkMonopoly(s: MarbleState, id: PlayerId, ev: GameEvent[]): boolean { if (!s.options.monopolyWin || s.phase === 'finished' || !pl(s, id).alive) return false; const m = monopolyOf(s, id); if (!m) return false; pushLog(s, ev, { type: 'monopoly', player: id, kind: m }); finish(s, 'monopoly', ev, { player: id, kind: m }); return true; } const MONO_KO = { triple: '트리플 독점', line: '라인 독점', tour: '관광지 독점' } as const; export function finish(s: MarbleState, reason: MarbleResult['reason'], ev: GameEvent[], mono?: { player: PlayerId; kind: 'triple' | 'line' | 'tour' }): void { const entry = (p: PlayerId): Omit => ({ player: p, totalAssets: totalAssets(s, p), cash: pl(s, p).cash, lands: ownedBy(s, p).length, bankruptAtRound: pl(s, p).bankruptAtRound, }); const ranking: MarbleRankEntry[] = []; let alive = aliveIds(s).map(entry); if (mono) { ranking.push({ rank: 1, ...entry(mono.player) }); alive = alive.filter((e) => e.player !== mono.player); } alive.sort((a, b) => b.totalAssets - a.totalAssets || b.cash - a.cash); for (let i = 0; i < alive.length; i++) { const e = alive[i]!; const prev = alive[i - 1]; const rank = prev && prev.totalAssets === e.totalAssets && prev.cash === e.cash ? ranking[ranking.length - 1]!.rank : ranking.length + 1; ranking.push({ rank, ...e }); } const out = s.order.filter((p) => !pl(s, p).alive).sort((a, b) => pl(s, b).outSeq! - pl(s, a).outSeq!); for (const p of out) ranking.push({ rank: ranking.length + 1, ...entry(p) }); const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player); const top = ranking[0]!; let summary: string; if (reason === 'monopoly') summary = `${MONO_KO[mono!.kind]} 달성으로 즉시 승리! (총자산 ${top.totalAssets}P)`; else if (reason === 'lastSurvivor') summary = out.length > 0 ? `다른 사람이 모두 파산해서 끝까지 남은 사람이 이겼어요 (총자산 ${top.totalAssets}P)` : '게임이 끝났어요'; else if (winners.length > 1) summary = `${s.round}라운드 끝! 총자산 ${top.totalAssets}P로 ${winners.length}명 공동 1등`; else summary = `${s.round}라운드 끝! 총자산 ${top.totalAssets}P로 1등`; s.phase = 'finished'; s.decision = null; s.debt = null; s.pendingTrade = null; s.extraRoll = false; s.deadlineAt = null; s.final = { reason, ...(mono ? { monopolyType: mono.kind } : {}), ranking, winners, summary }; pushLog(s, ev, { type: 'gameOver', reason, winners, summary }); } // ---------------------------------------------------------------- actions function rollDice(rng: Rng): [number, number] { return [1 + rng.int(6), 1 + rng.int(6)]; } export function doRoll(s: MarbleState, rng: Rng, ev: GameEvent[]): void { const id = curId(s); const p = pl(s, id); const dice = rollDice(rng); const double = dice[0] === dice[1]; const sum = dice[0] + dice[1]; const onIsland = p.islandLeft > 0; s.lastRoll = { seq: ++s.seq, player: id, dice, double, island: onIsland }; pushLog(s, ev, { type: 'rolled', player: id, dice, double, island: onIsland }); if (onIsland) { s.extraRoll = false; if (double) { p.islandLeft = 0; pushLog(s, ev, { type: 'escaped', player: id, how: 'double' }); moveForward(s, id, sum, 'roll', ev); return land(s, ev); } p.islandLeft -= 1; pushLog(s, ev, { type: 'islandStay', player: id, left: p.islandLeft }); return endTurn(s, ev); } s.extraRoll = false; if (double) { s.doublesThisTurn += 1; if (s.doublesThisTurn >= 3) { sendToIsland(s, id, 'doubles', ev); return endTurn(s, ev); } s.extraRoll = true; } moveForward(s, id, sum, 'roll', ev); land(s, ev); } export function doEscape(s: MarbleState, how: 'fee' | 'card', ev: GameEvent[]): void { const id = curId(s); const p = pl(s, id); if (how === 'fee') transfer(s, id, bankC, s.options.islandFee); else { p.heldCards.splice(p.heldCards.indexOf('L11'), 1); deckBottom(s, 'L11'); } p.islandLeft = 0; pushLog(s, ev, { type: 'escaped', player: id, how, amount: how === 'fee' ? s.options.islandFee : 0 }); } export function doSpaceTravel(s: MarbleState, dest: number, ev: GameEvent[]): void { const id = curId(s); pl(s, id).spacePending = false; s.extraRoll = false; pushLog(s, ev, { type: 'spaceTravel', player: id, dest }); const center = s.owners[SPACE_CENTER]; if (center && center.owner !== id && pl(s, center.owner).alive) { return pay(s, [{ debtor: id, amount: SPACE_FARE, to: playerC(center.owner), reason: 'fare' }], { k: 'space', dest }, ev); } fly(s, dest, ev); } function fly(s: MarbleState, dest: number, ev: GameEvent[]): void { const id = curId(s); moveForward(s, id, forwardSteps(pl(s, id).pos, dest), 'space', ev); land(s, ev); } export function doBuy(s: MarbleState, ev: GameEvent[]): void { const id = curId(s); const pos = s.decision!.pos; const price = SPACES[pos]!.price!; transfer(s, id, bankC, price); s.owners[pos] = { owner: id, level: 0 }; pushLog(s, ev, { type: 'bought', player: id, pos, price }); if (checkMonopoly(s, id, ev)) return; s.decision = null; offerBuild(s, ev, true); } export function doBuild(s: MarbleState, level: Level, ev: GameEvent[]): void { const id = curId(s); const pos = s.decision!.pos; const cost = stageCost(pos, level); transfer(s, id, bankC, cost); s.owners[pos]!.level = level; pushLog(s, ev, { type: 'built', player: id, pos, level, cost }); afterLanding(s, ev); } export function doTakeover(s: MarbleState, ev: GameEvent[]): void { const id = curId(s); const pos = s.decision!.pos; const o = s.owners[pos]!; const price = takeoverPrice(pos, o.level); const prev = o.owner; transfer(s, id, playerC(prev), price); o.owner = id; pushLog(s, ev, { type: 'takeover', player: id, from: prev, pos, price }); if (checkMonopoly(s, id, ev)) return; afterLanding(s, ev); } export function doPass(s: MarbleState, ev: GameEvent[]): void { const d = s.decision; if (d) pushLog(s, ev, { type: 'passed', player: curId(s), pos: d.pos }); afterLanding(s, ev); } export function doSell(s: MarbleState, id: PlayerId, pos: number, what: 'top' | 'all', ev: GameEvent[]): void { const o = s.owners[pos]!; let got: number; if (what === 'top') { got = stageSale(pos, o.level); o.level = (o.level - 1) as Level; } else { got = fullSale(pos, o.level); delete s.owners[pos]; } mint(s, id, got); pushLog(s, ev, { type: 'sold', player: id, pos, what, amount: got }); const d = s.debt; if (d && d.debtor === id && pl(s, id).cash >= d.amount) { transfer(s, id, d.to, d.amount); logPaid(s, d, ev); s.debt = null; runQueue(s, [...d.queue], d.then, ev); } } /** Cheapest sale first (§8.5): a top stage with the smallest sale value, else the cheapest land; ties → lower square. */ export function autoSell(s: MarbleState, id: PlayerId): { pos: number; what: 'top' | 'all' } | null { const mine = ownedBy(s, id); const built = mine.filter((p) => s.owners[p]!.level >= 1); if (built.length) { const best = [...built].sort((a, b) => stageSale(a, s.owners[a]!.level) - stageSale(b, s.owners[b]!.level) || a - b)[0]!; return { pos: best, what: 'top' }; } if (!mine.length) return null; const best = [...mine].sort((a, b) => SPACES[a]!.price! - SPACES[b]!.price! || a - b)[0]!; return { pos: best, what: 'all' }; } export function doPropose(s: MarbleState, t: Trade, ev: GameEvent[]): void { s.pendingTrade = { from: t.from, to: t.to, give: { lands: [...t.give.lands].sort((a, b) => a - b), cash: t.give.cash }, take: { lands: [...t.take.lands].sort((a, b) => a - b), cash: t.take.cash } }; s.tradesThisTurn += 1; s.phase = 'tradeResponse'; pushLog(s, ev, { type: 'tradeProposed', ...structuredClone(s.pendingTrade) }); } /** Ownership / points still match the offer? */ export function tradeStillValid(s: MarbleState, t: Trade): boolean { return ( pl(s, t.from).alive && pl(s, t.to).alive && t.give.lands.every((p) => s.owners[p]?.owner === t.from) && t.take.lands.every((p) => s.owners[p]?.owner === t.to) && pl(s, t.from).cash >= t.give.cash && pl(s, t.to).cash >= t.take.cash ); } export function doRespond(s: MarbleState, accept: boolean, ev: GameEvent[]): void { const t = s.pendingTrade!; s.pendingTrade = null; s.phase = 'turnStart'; if (!accept) { pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: false }); return; } if (!tradeStillValid(s, t)) { pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: false, failed: true }); return; } for (const p of t.give.lands) s.owners[p]!.owner = t.to; for (const p of t.take.lands) s.owners[p]!.owner = t.from; if (t.give.cash) transfer(s, t.from, playerC(t.to), t.give.cash); if (t.take.cash) transfer(s, t.to, playerC(t.from), t.take.cash); pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: true }); if (checkMonopoly(s, t.from, ev)) return; checkMonopoly(s, t.to, ev); } /** 기권 (§8.2 CONCEDE): like bankruptcy to the bank. A debtor in the middle of paying still settles that debt first. */ export function doConcede(s: MarbleState, id: PlayerId, ev: GameEvent[]): void { const wasCurrent = id === curId(s); if (s.phase === 'tradeResponse' && s.pendingTrade && (s.pendingTrade.from === id || s.pendingTrade.to === id)) { pushLog(s, ev, { type: 'tradeResolved', from: s.pendingTrade.from, to: s.pendingTrade.to, accepted: false, cancelled: true }); s.pendingTrade = null; s.phase = 'turnStart'; } const d = s.debt && s.phase === 'debt' && s.debt.debtor === id ? s.debt : null; liquidate(s, id); let toCreditor = 0; if (d) { toCreditor = Math.min(pl(s, id).cash, d.amount); if (toCreditor > 0) transfer(s, id, d.to, toCreditor); } const rest = pl(s, id).cash; if (rest > 0) transfer(s, id, bankC, rest); retire(s, id); pl(s, id).conceded = true; pushLog(s, ev, { type: 'conceded', player: id, ...(d ? { creditor: d.to, paid: toCreditor } : {}) }); if (aliveIds(s).length <= 1) return finish(s, 'lastSurvivor', ev); if (wasCurrent) return endTurn(s, ev); if (d) { s.debt = null; return runQueue(s, [...d.queue], d.then, ev); } // A takeover offer on the leaver's land is gone with the land. if (s.phase === 'decide' && s.decision && s.decision.canTakeover !== null && !s.owners[s.decision.pos]) afterLanding(s, ev); } // ---------------------------------------------------------------- setup export function createState(players: PlayerId[], options: MarbleOptions, rng: Rng, deck: CardId[]): MarbleState { const order = rng.shuffle(players); const ps: Record = {}; const timeouts: Record = {}; for (const p of order) { ps[p] = { pos: START, cash: options.startMoney, alive: true, bankruptAtRound: null, outSeq: null, islandLeft: 0, spacePending: false, heldCards: [], conceded: false }; timeouts[p] = 0; } return { options, order, current: 0, round: 1, phase: 'turnStart', players: ps, owners: {}, fund: 0, deck: rng.shuffle(deck), revealed: [], doublesThisTurn: 0, cardsThisTurn: 0, decision: null, debt: null, pendingTrade: null, tradesThisTurn: 0, extraRoll: false, lastRoll: null, lastMove: null, lastCard: null, seq: 0, turnSeq: 0, deadlineAt: null, timeouts, log: [], outCount: 0, ledger: { minted: 0, burned: 0 }, final: null, }; }