- 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>
946 lines
34 KiB
TypeScript
946 lines
34 KiB
TypeScript
/**
|
|
* 주사위 땅따먹기 상태 기계 (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,
|
|
};
|
|
}
|
|
|