M6: 주사위 땅따먹기 규칙·화면·테스트

- packages/games/src/marble: 판 데이터(board.ts, 40칸·가격표·행운카드 20장), 상태 기계(rules.ts),
  게임 정의(index.ts). 더블·3더블 무인도, 무인도/탈출권/탈출비, 우주여행·탑승료, 사회복지기금,
  통행료(관광지 2곳 이상 2배·면제권), 빚 갚기·파산(채권자에게 매각 대금), 인수·랜드마크·독점 승리·교환
  옵션, 라운드 제한과 총자산 순위(동점이면 포인트 비교). 카드 더미 순서는 비공개(남은 장 수만),
  시스템 전용 TIMEOUT(굴리기/안 사기/싼 것부터 매각/거절), onLeave = 기권.
- 테스트 80개: 문서 §10 체크리스트 전 항목, 1,000판 시드 무작위 대국(포인트 장부·소유 일관성·
  onTimeout 합법), 엔진 randomPlayout·리플레이 결정성, 시간 초과만으로도 끝나는지.
- 화면: SVG 정사각형 판(도시 이름·소유 색·건물 표시·말), 가운데 주사위·결정 프롬프트, 행운카드 공개,
  우주여행 칸 고르기와 미리 보기, 빚 갚기 막대·매각 목록, 교환 시트, 진행 기록, PC·휴대폰·다크 모드.
  화폐 대신 놀이용 포인트(P) 표기, no-money-wording 검사 대상에 추가.
- 등록: GAMES, CATALOG(주사위 땅따먹기), 웹 registry, GAME_ICON, @bg/games/marble/board export.
- E2E marble 계획: PC 방장 + 휴대폰 손님, 15라운드·200P로 결과 화면까지 실제 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 13:31:22 +09:00
parent 6321ea8c0d
commit ad7af30daa
12 changed files with 4315 additions and 1 deletions

View File

@@ -0,0 +1,945 @@
/**
* 주사위 땅따먹기 상태 기계 (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<typeof marbleOptionsSchema>;
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<typeof marbleActionSchema>;
/** 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<PlayerId, MarblePlayer>;
/** Cities and tourist spots that have an owner, keyed by position. */
owners: Record<number, Owned>;
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<PlayerId, number>;
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<MarbleRankEntry, 'rank'> => ({
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<PlayerId, MarblePlayer> = {};
const timeouts: Record<PlayerId, number> = {};
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,
};
}