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

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
'one-card': () => import('./one-card'),
rummikub: () => import('./rummikub'),
'davinci-code': () => import('./davinci-code'),
marble: () => import('./marble'),
};
const cache = new Map<string, GameUI>();

View File

@@ -12,6 +12,7 @@ export const GAME_ICON: Record<string, string> = {
'one-card': '♣',
rummikub: '🔢',
'davinci-code': '🔐',
marble: '🏙️',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */

View File

@@ -6,7 +6,7 @@ import { expect, test } from 'bun:test';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'components/cards'].map((p) => join(import.meta.dir, p));
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'components/cards'].map((p) => join(import.meta.dir, p));
const FORBIDDEN: [RegExp, string][] = [
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],

View File

@@ -150,6 +150,50 @@ async function yutStep(p: Page): Promise<boolean> {
}
}
/**
* 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip),
* pick a space-travel square on the board, escape the island or roll. Returns the label pressed.
*/
const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc');
async function marbleStep(p: Page, stats: Record<string, number>): Promise<string | null> {
const prompt = p.locator('.mb-prompt.mine');
if (!(await prompt.count())) return null;
const btn = (name: string | RegExp) => prompt.getByRole('button', { name, exact: typeof name === 'string' });
const press = async (name: string | RegExp) => {
const b = btn(name);
if (!(await b.count()) || !(await b.first().isEnabled().catch(() => false))) return false;
return b.first().click({ timeout: 1500 }).then(() => true, () => false);
};
// Space travel: tap a glowing square on the board, check the preview, fly there.
if (await btn(/^출발로 가기/).count()) {
const cell = p.locator('g.mb-cell.pick').nth(5 + (stats.space ?? 0) * 7);
await cell.click({ timeout: 1500 }).catch(() => {});
const go = p.getByRole('button', { name: '🚀 여기로 가기' });
if (await go.waitFor({ timeout: 1500 }).then(() => true, () => false)) {
await p.getByText('여기로 가면').waitFor({ timeout: 1000 });
if (!stats.space) await p.screenshot({ path: join(SHOTS, `marble-space-${tag(p)}.png`) });
await go.click();
stats.space = (stats.space ?? 0) + 1;
return 'space';
}
return (await press(/^출발로 가기/)) ? 'space-start' : null;
}
// First buy prompt on each screen size, for the visual check.
const shotKey = `shot-${tag(p)}`;
if (!stats[shotKey] && (await btn('사기').count())) {
stats[shotKey] = 1;
await p.screenshot({ path: join(SHOTS, `marble-buy-${tag(p)}.png`) });
}
for (const name of ['거절', /^가장 싼 것부터 팔기/, '사기', '짓기', '인수하기', '🎲 탈출 굴리기', '🎲 주사위 굴리기', '🎲 한 번 더 굴리기', '안 사기', '안 짓기', '안 하기'] as const) {
if (await press(name)) {
const key = typeof name === 'string' ? name : 'sell';
stats[key] = (stats[key] ?? 0) + 1;
return key;
}
}
return null;
}
const PLANS: Record<string, Plan> = {
rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -194,6 +238,7 @@ const PLANS: Record<string, Plan> = {
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
if (!acted) await host.waitForTimeout(120);
}
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`);
@@ -201,6 +246,59 @@ const PLANS: Record<string, Plan> = {
console.log(` yut: finished after ${actions} UI actions`);
},
},
marble: {
// PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or
// build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots.
lobby: async (host) => {
await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click();
await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^15라운드 · 시작 200P/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const stats: Record<string, number> = {};
let actions = 0;
let shot = false;
const until = Date.now() + 420_000;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
while (Date.now() < until && !(await over())) {
let acted = false;
for (const p of pages) {
if (await marbleStep(p, stats)) {
acted = true;
actions++;
await p.waitForTimeout(80);
break;
}
}
if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) {
shot = true;
await host.waitForTimeout(1300);
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400); // let the button colour transitions settle
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
if (!acted) await host.waitForTimeout(120);
}
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600); // result dialog fade-in
await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') });
const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0);
if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
},
},
'one-card': {
// 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play,
// pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends.

View File

@@ -6,6 +6,7 @@
"exports": {
".": "./src/index.ts",
"./rummikub/rules": "./src/rummikub/rules.ts",
"./marble/board": "./src/marble/board.ts",
"./*": "./src/*/index.ts"
},
"dependencies": {

View File

@@ -11,6 +11,7 @@ import { yut } from './yut';
import { oneCard } from './one-card';
import { rummikub } from './rummikub';
import { davinci } from './davinci-code';
import { marble } from './marble';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -25,4 +26,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[oneCard.id]: oneCard,
[rummikub.id]: rummikub,
[davinci.id]: davinci,
[marble.id]: marble,
};

View File

@@ -0,0 +1,182 @@
/**
* 주사위 땅따먹기 판 데이터 (docs/games/marble.md §3). Pure data + price helpers, no zod,
* so it stays cheap to import. Every number here is copied from the doc tables (tests compare
* them with the §3.2 formulas).
*/
export type SpaceKind = 'start' | 'city' | 'tour' | 'card' | 'island' | 'fundIn' | 'fundOut' | 'space';
export type Group = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H';
/** 0 땅만, 1 별장, 2 빌딩, 3 호텔, 4 랜드마크 */
export type Level = 0 | 1 | 2 | 3 | 4;
export interface Space {
pos: number;
name: string;
kind: SpaceKind;
group: Group | null;
/** 땅값 (cities and tourist spots). */
price: number | null;
/** 건설비 per stage: [별장, 빌딩, 호텔, 랜드마크] (cities only). */
build: [number, number, number, number] | null;
/** 통행료 by level: [땅, 별장, 빌딩, 호텔, 랜드마크] (cities); tourist spots have one value. */
tolls: number[] | null;
}
type CityRow = [pos: number, name: string, group: Group, price: number, build: [number, number, number, number], tolls: [number, number, number, number, number]];
/** §3.2 */
const CITIES: CityRow[] = [
[1, '타이베이', 'A', 6, [3, 6, 9, 12], [1, 4, 9, 15, 24]],
[3, '마닐라', 'A', 6, [3, 6, 9, 12], [1, 4, 9, 15, 24]],
[4, '하노이', 'A', 8, [4, 8, 12, 16], [2, 5, 12, 20, 32]],
[6, '방콕', 'B', 10, [5, 10, 15, 20], [2, 6, 15, 25, 40]],
[8, '싱가포르', 'B', 10, [5, 10, 15, 20], [2, 6, 15, 25, 40]],
[9, '카이로', 'B', 12, [6, 12, 18, 24], [2, 7, 18, 30, 48]],
[11, '이스탄불', 'C', 14, [7, 14, 21, 28], [3, 8, 21, 35, 56]],
[13, '아테네', 'C', 14, [7, 14, 21, 28], [3, 8, 21, 35, 56]],
[14, '로마', 'C', 16, [8, 16, 24, 32], [3, 10, 24, 40, 64]],
[16, '코펜하겐', 'D', 18, [9, 18, 27, 36], [4, 11, 27, 45, 72]],
[18, '스톡홀름', 'D', 18, [9, 18, 27, 36], [4, 11, 27, 45, 72]],
[19, '베를린', 'D', 20, [10, 20, 30, 40], [4, 12, 30, 50, 80]],
[21, '부에노스아이레스', 'E', 22, [11, 22, 33, 44], [4, 13, 33, 55, 88]],
[23, '상파울루', 'E', 22, [11, 22, 33, 44], [4, 13, 33, 55, 88]],
[24, '시드니', 'E', 24, [12, 24, 36, 48], [5, 14, 36, 60, 96]],
[26, '리스본', 'F', 26, [13, 26, 39, 52], [5, 16, 39, 65, 104]],
[27, '마드리드', 'F', 26, [13, 26, 39, 52], [5, 16, 39, 65, 104]],
[29, '파리', 'F', 28, [14, 28, 42, 56], [6, 17, 42, 70, 112]],
[31, '런던', 'G', 30, [15, 30, 45, 60], [6, 18, 45, 75, 120]],
[32, '밴쿠버', 'G', 30, [15, 30, 45, 60], [6, 18, 45, 75, 120]],
[34, '샌프란시스코', 'G', 32, [16, 32, 48, 64], [6, 19, 48, 80, 128]],
[36, '뉴욕', 'H', 35, [18, 35, 53, 70], [7, 21, 53, 88, 140]],
[37, '도쿄', 'H', 35, [18, 35, 53, 70], [7, 21, 53, 88, 140]],
[39, '서울', 'H', 40, [20, 40, 60, 80], [8, 24, 60, 100, 160]],
];
/** §3.3 */
const TOURS: [pos: number, name: string, price: number, toll: number][] = [
[5, '제주도', 20, 10],
[15, '경주', 20, 10],
[25, '우주센터', 30, 15],
[35, '부산', 25, 12],
];
const OTHERS: [pos: number, name: string, kind: SpaceKind][] = [
[0, '출발', 'start'],
[10, '무인도', 'island'],
[20, '사회복지기금 받는 곳', 'fundIn'],
[30, '우주여행', 'space'],
[38, '사회복지기금 내는 곳', 'fundOut'],
...[2, 7, 12, 17, 22, 28, 33].map((p): [number, string, SpaceKind] => [p, '행운카드', 'card']),
];
export const SPACES: readonly Space[] = (() => {
const out: Space[] = [];
for (const [pos, name, group, price, build, tolls] of CITIES) out[pos] = { pos, name, kind: 'city', group, price, build, tolls };
for (const [pos, name, price, toll] of TOURS) out[pos] = { pos, name, kind: 'tour', group: null, price, build: null, tolls: [toll] };
for (const [pos, name, kind] of OTHERS) out[pos] = { pos, name, kind, group: null, price: null, build: null, tolls: null };
if (out.length !== 40 || out.some((s) => !s)) throw new Error('marble: board must have 40 spaces');
return out;
})();
export const BOARD_SIZE = 40;
export const START = 0;
export const ISLAND = 10;
export const FUND_IN = 20;
export const SPACE_TRAVEL = 30;
export const SPACE_CENTER = 25;
export const SEOUL = 39;
export const TOUR_POSITIONS = [5, 15, 25, 35];
export const CITY_POSITIONS = CITIES.map((c) => c[0]);
export const PROPERTY_POSITIONS = [...CITY_POSITIONS, ...TOUR_POSITIONS].sort((a, b) => a - b);
/** §3.1 라인(독점 판정용): 각 줄의 도시 6곳. */
export const LINES: number[][] = [0, 1, 2, 3].map((k) => CITY_POSITIONS.filter((p) => p > k * 10 && p < k * 10 + 10));
export const GROUPS: Record<Group, number[]> = (() => {
const g = {} as Record<Group, number[]>;
for (const [pos, , group] of CITIES) (g[group] ??= []).push(pos);
return g;
})();
/** §3.4 고정 금액 */
export const FUND_PAYMENT = 15;
export const SPACE_FARE = 20;
// ---------------------------------------------------------------- 행운카드 (§3.5)
export type CardId =
| 'L01' | 'L02' | 'L03' | 'L04' | 'L05' | 'L06' | 'L07' | 'L08' | 'L09' | 'L10'
| 'L11' | 'L12' | 'L13' | 'L14' | 'L15' | 'L16' | 'L17' | 'L18' | 'L19' | 'L20';
export const CARD_IDS: CardId[] = Array.from({ length: 20 }, (_, i) => `L${String(i + 1).padStart(2, '0')}` as CardId);
export const KEEP_CARDS: CardId[] = ['L11', 'L12'];
export const CARDS: Record<CardId, { name: string; text: string }> = {
L01: { name: '복권 당첨', text: '은행에서 20 받기' },
L02: { name: '장학금', text: '은행에서 10 받기' },
L03: { name: '주식 배당', text: '은행에서 15 받기' },
L04: { name: '생일 축하', text: '다른 사람 모두에게서 5씩 받기' },
L05: { name: '예금 이자', text: '은행에서 10 받기' },
L06: { name: '과속 벌금', text: '사회복지기금에 5 내기' },
L07: { name: '병원비', text: '은행에 10 내기' },
L08: { name: '이웃 돕기 성금', text: '사회복지기금에 10 내기' },
L09: { name: '건물 수리비', text: '내 건물마다 별장 3, 빌딩 6, 호텔 10, 랜드마크 15를 은행에 내기' },
L10: { name: '폭풍 경보', text: '무인도로 바로 가기(월급 없음)' },
L11: { name: '무인도 탈출권', text: '가지고 있다가 무인도에서 쓰면 바로 탈출' },
L12: { name: '통행료 면제권', text: '다음에 통행료를 낼 때 한 번 면제' },
L13: { name: '출발지로', text: '출발 칸으로 가서 월급 받기' },
L14: { name: '우주 초대장', text: '우주여행 칸으로 가기' },
L15: { name: '서울 관광', text: '서울로 가기' },
L16: { name: '뒤로 3칸', text: '뒤로 3칸 가기(월급 없음)' },
L17: { name: '기부 천사', text: '사회복지기금 받는 곳으로 가서 모인 기금 받기' },
L18: { name: '관광 안내', text: '앞쪽의 가장 가까운 관광지로 가기' },
L19: { name: '여행 경비', text: '다른 사람 모두에게 5씩 주기' },
L20: { name: '재산세', text: '내 땅 한 곳마다 2를 은행에 내기' },
};
/** L09 건물 수리비 per stage (별장, 빌딩, 호텔, 랜드마크). */
export const REPAIR_PER_STAGE = [3, 6, 10, 15];
export const PROPERTY_TAX = 2;
export const BIRTHDAY_GIFT = 5;
export const TRAVEL_GIFT = 5;
/** §3.5: from the 5th card in one turn on, card spaces do nothing. */
export const CARD_CAP = 5;
// ---------------------------------------------------------------- price helpers
export const isProperty = (pos: number) => SPACES[pos]!.kind === 'city' || SPACES[pos]!.kind === 'tour';
export const isCity = (pos: number) => SPACES[pos]!.kind === 'city';
/** Amount paid for one stage: level 0 = 땅값, 1.. = 건설비. */
export function stageCost(pos: number, level: Level): number {
const s = SPACES[pos]!;
if (level === 0) return s.price!;
return s.build![level - 1]!;
}
/** 땅값 + 지어진 건물 건설비 합 (정가). */
export function investment(pos: number, level: Level): number {
let t = 0;
for (let l = 0; l <= level; l++) t += stageCost(pos, l as Level);
return t;
}
/** 매각가: 그 단계에 낸 금액의 50%, 내림. */
export const stageSale = (pos: number, level: Level) => Math.floor(stageCost(pos, level) / 2);
/** Selling the whole land (every building, top down, then the land). */
export function fullSale(pos: number, level: Level): number {
let t = 0;
for (let l = 0; l <= level; l++) t += stageSale(pos, l as Level);
return t;
}
/** 인수가 = 2 × 투자액 (§5.7). */
export const takeoverPrice = (pos: number, level: Level) => 2 * investment(pos, level);
/** Base toll from the tables (no tourist doubling). */
export function baseToll(pos: number, level: Level): number {
const s = SPACES[pos]!;
return s.kind === 'tour' ? s.tolls![0]! : s.tolls![level]!;
}
/** Forward distance from `from` to `to` (0..39). */
export const forwardSteps = (from: number, to: number) => (to - from + BOARD_SIZE) % BOARD_SIZE;

View File

@@ -0,0 +1,559 @@
/** 주사위 땅따먹기 (docs/games/marble.md). Hidden info: the 행운카드 deck order (only its size is public). */
import type { ZodType } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine';
import {
CARD_IDS,
PROPERTY_POSITIONS,
SPACES,
SPACE_CENTER,
SPACE_FARE,
SPACE_TRAVEL,
forwardSteps,
fullSale,
isProperty,
stageCost,
stageSale,
takeoverPrice,
type CardId,
type Level,
} from './board';
import {
AWAY_AFTER,
AWAY_MS,
MAX_TRADES_PER_TURN,
TRADE_MS,
aliveIds,
autoSell,
canTakeoverAt,
createState,
curId,
doBuild,
doBuy,
doConcede,
doEscape,
doPass,
doPropose,
doRespond,
doRoll,
doSell,
doSpaceTravel,
doTakeover,
marbleActionSchema,
marbleOptionsSchema,
maxLevel,
ownedBy,
pl,
pushLog,
raisable,
tollAt,
totalAssets,
type Creditor,
type DebtReason,
type MarbleAction,
type MarbleClientAction,
type MarbleOptions,
type MarbleResult,
type MarbleState,
type Phase,
type Trade,
} from './rules';
export * from './board';
export * from './rules';
// ---------------------------------------------------------------- view types
export interface MarblePlayerView {
id: PlayerId;
/** Seat colour index (turn order). */
seat: number;
pos: number;
cash: number;
alive: boolean;
conceded: boolean;
bankruptAtRound: number | null;
islandLeft: number;
spacePending: boolean;
heldCards: CardId[];
lands: number[];
totalAssets: number;
away: boolean;
}
export interface MarbleOwnedView {
pos: number;
owner: PlayerId;
level: Level;
/** Toll someone else pays here right now. */
toll: number;
/** 인수가 when the takeover option applies to this square. */
takeoverPrice: number | null;
/** Sale value of the top building stage (null = no building). */
sellTop: number | null;
/** Sale value of the whole land with its buildings. */
sellAll: number;
}
export interface MarbleSellOption {
pos: number;
what: 'top' | 'all';
amount: number;
level: Level;
}
/** What the viewer can do right now, with costs precomputed (§8.3). */
export interface MarbleMyView {
canRoll: boolean;
island: { left: number; fee: number | null; canPayFee: boolean; hasCard: boolean } | null;
space: boolean;
decide: { pos: number; buy: number | null; build: { level: Level; cost: number } | null; takeover: number | null; boughtThisVisit: boolean } | null;
sell: MarbleSellOption[] | null;
autoSell: { pos: number; what: 'top' | 'all' } | null;
canPropose: boolean;
respond: boolean;
}
export interface MarbleSpacePreview {
dest: number;
salary: boolean;
owner: PlayerId | null;
toll: number;
fare: number;
}
export interface MarbleView {
options: MarbleOptions;
me: PlayerId | null;
order: PlayerId[];
current: PlayerId | null;
phase: Phase;
round: number;
maxRounds: number;
fund: number;
deckCount: number;
players: MarblePlayerView[];
owned: MarbleOwnedView[];
lastRoll: MarbleState['lastRoll'];
lastMove: MarbleState['lastMove'];
lastCard: MarbleState['lastCard'];
doublesThisTurn: number;
extraRoll: boolean;
decision: MarbleState['decision'];
debt: { debtor: PlayerId; amount: number; to: Creditor; reason: DebtReason; short: number; raisable: number } | null;
trade: Trade | null;
tradesLeft: number;
my: MarbleMyView | null;
spacePreview: MarbleSpacePreview[] | null;
turnSeq: number;
deadlineAt: number | null;
log: GameEvent[];
result: MarbleResult | null;
}
// ---------------------------------------------------------------- helpers
type ClientAction = MarbleClientAction;
function activeOf(s: MarbleState): PlayerId[] {
if (s.phase === 'finished') return [];
if (s.phase === 'debt') return [s.debt!.debtor];
if (s.phase === 'tradeResponse') return [s.pendingTrade!.to];
return [curId(s)];
}
/** The deterministic action a timeout plays (§8.5): never spends points by choice. */
export function autoAction(s: MarbleState, player: PlayerId): ClientAction {
switch (s.phase) {
case 'tradeResponse':
return { type: 'TRADE_RESPOND', accept: false };
case 'debt': {
const a = autoSell(s, player);
return a ? { type: 'SELL', ...a } : { type: 'CONCEDE' };
}
case 'decide':
return { type: 'PASS' };
default:
return pl(s, curId(s)).spacePending ? { type: 'SPACE_TRAVEL', dest: 0 } : { type: 'ROLL' };
}
}
function sellOptions(s: MarbleState, id: PlayerId): MarbleSellOption[] {
const out: MarbleSellOption[] = [];
for (const pos of ownedBy(s, id)) {
const o = s.owners[pos]!;
if (o.level >= 1) out.push({ pos, what: 'top', amount: stageSale(pos, o.level), level: o.level });
out.push({ pos, what: 'all', amount: fullSale(pos, o.level), level: o.level });
}
return out;
}
const PHASE_BUSY: Record<Phase, string> = {
turnStart: '지금은 주사위를 굴릴 차례예요.',
decide: '먼저 이 칸에서 할 일을 정해 주세요.',
debt: '먼저 모자란 포인트를 채워야 해요.',
tradeResponse: '교환 제안의 답을 기다리는 중이에요.',
finished: '게임이 이미 끝났어요.',
};
function validateTrade(s: MarbleState, actor: PlayerId, a: Extract<ClientAction, { type: 'PROPOSE_TRADE' }>): Validation {
if (!s.options.trade) return fail('교환 규칙을 쓰지 않는 방이에요.');
if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]);
if (s.tradesThisTurn >= MAX_TRADES_PER_TURN) return fail('이번 차례에는 더 제안할 수 없어요(2번까지).');
const to = s.players[a.to];
if (!to || a.to === actor || !to.alive) return fail('그 사람에게는 제안할 수 없어요.');
if (a.give.lands.length + a.take.lands.length + a.give.cash + a.take.cash === 0) return fail('주거나 받을 것을 하나 이상 골라 주세요.');
const all = [...a.give.lands, ...a.take.lands];
if (new Set(all).size !== all.length || !all.every(isProperty)) return fail('땅을 다시 골라 주세요.');
if (!a.give.lands.every((p) => s.owners[p]?.owner === actor)) return fail('내 땅만 줄 수 있어요.');
if (!a.take.lands.every((p) => s.owners[p]?.owner === a.to)) return fail('그 사람의 땅만 받을 수 있어요.');
if (a.give.cash > pl(s, actor).cash) return fail('가진 포인트보다 많이 줄 수 없어요.');
if (a.take.cash > to.cash) return fail('상대가 가진 포인트보다 많이 받을 수 없어요.');
return ok;
}
function refreshDeadline(s: MarbleState, now: number): void {
if (s.phase === 'finished') {
s.deadlineAt = null;
return;
}
if (s.phase === 'tradeResponse') {
s.deadlineAt = now + TRADE_MS;
return;
}
if (s.options.turnSeconds === null) {
s.deadlineAt = null;
return;
}
const who = activeOf(s)[0]!;
s.deadlineAt = now + ((s.timeouts[who] ?? 0) >= AWAY_AFTER ? AWAY_MS : s.options.turnSeconds * 1000);
}
function spacePreview(s: MarbleState, id: PlayerId): MarbleSpacePreview[] {
const center = s.owners[SPACE_CENTER];
const fare = center && center.owner !== id && pl(s, center.owner).alive ? SPACE_FARE : 0;
const from = pl(s, id).pos;
const out: MarbleSpacePreview[] = [];
for (let dest = 0; dest < 40; dest++) {
if (dest === SPACE_TRAVEL) continue;
const o = s.owners[dest];
const other = o && o.owner !== id ? o.owner : null;
out.push({ dest, salary: from + forwardSteps(from, dest) >= 40, owner: o?.owner ?? null, toll: other ? tollAt(s, dest) : 0, fare });
}
return out;
}
// ---------------------------------------------------------------- definition
export const marble: GameDefinition<MarbleState, MarbleAction, MarbleView, MarbleOptions> = {
id: 'marble',
nameKo: '주사위 땅따먹기',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: marbleOptionsSchema.parse({}),
optionsSchema: marbleOptionsSchema as unknown as ZodType<MarbleOptions>,
actionSchema: marbleActionSchema as unknown as ZodType<MarbleAction>,
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 4) throw new Error('marble: 2~4명이 할 수 있어요.');
const s = createState(players, options, rng, [...CARD_IDS]);
pushLog(s, [], { type: 'turn', player: curId(s), round: 1 });
refreshDeadline(s, now);
return s;
},
validate(s, actor, a) {
const p = s.players[actor];
if (!p) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (!p.alive) return fail('이미 게임에서 빠졌어요.');
switch (a.type) {
case 'CONCEDE':
return ok;
case 'TIMEOUT':
return activeOf(s).includes(actor) ? ok : fail('지금은 시간 초과를 처리할 수 없어요.');
case 'TRADE_RESPOND':
return s.phase === 'tradeResponse' && s.pendingTrade?.to === actor ? ok : fail('답할 교환 제안이 없어요.');
case 'SELL': {
if (s.phase !== 'debt' || s.debt?.debtor !== actor) return fail('지금은 팔 수 없어요. 모자란 포인트를 채울 때만 팔 수 있어요.');
const o = s.owners[a.pos];
if (!o || o.owner !== actor) return fail('내 땅만 팔 수 있어요.');
if (a.what === 'top' && o.level < 1) return fail('그 땅에는 팔 건물이 없어요.');
return ok;
}
}
if (actor !== curId(s)) return fail('지금은 다른 사람 차례예요.');
const o = s.options;
const d = s.decision;
switch (a.type) {
case 'ROLL':
if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]);
if (p.spacePending) return fail('우주여행 차례예요. 갈 칸을 골라 주세요.');
return ok;
case 'PAY_ISLAND_FEE':
if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]);
if (p.islandLeft <= 0) return fail('무인도에 있지 않아요.');
if (o.islandFee <= 0) return fail('이 방에서는 탈출비를 내고 나갈 수 없어요.');
if (p.cash < o.islandFee) return fail('포인트가 부족해요.');
return ok;
case 'USE_ESCAPE_CARD':
if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]);
if (p.islandLeft <= 0) return fail('무인도에 있지 않아요.');
if (!p.heldCards.includes('L11')) return fail('무인도 탈출권이 없어요.');
return ok;
case 'SPACE_TRAVEL':
if (s.phase !== 'turnStart' || !p.spacePending) return fail('지금은 우주여행을 할 수 없어요.');
if (a.dest === SPACE_TRAVEL) return fail('우주여행 칸으로는 갈 수 없어요. 다른 칸을 골라 주세요.');
return ok;
case 'BUY':
if (s.phase !== 'decide' || !d?.canBuy) return fail('지금은 이 땅을 살 수 없어요.');
if (p.cash < SPACES[d.pos]!.price!) return fail('포인트가 부족해요.');
return ok;
case 'BUILD': {
if (a.level === 4 && !o.landmark) return fail('이 방에서는 랜드마크를 짓지 않아요.');
if (s.phase !== 'decide' || !d || d.canBuild === null) return fail('지금은 지을 수 없어요.');
if (a.level !== d.canBuild) return fail('건물은 한 단계씩만 올릴 수 있어요.');
if (a.level > maxLevel(o)) return fail('더 지을 수 없어요.');
if (p.cash < stageCost(d.pos, a.level as Level)) return fail('포인트가 부족해요.');
return ok;
}
case 'TAKEOVER':
if (!o.takeover) return fail('인수 규칙을 쓰지 않는 방이에요.');
if (s.phase !== 'decide' || !d || d.canTakeover === null) return fail('지금은 인수할 수 없어요.');
if (canTakeoverAt(s, d.pos, actor) === null) return fail('이 땅은 인수할 수 없어요.');
if (p.cash < d.canTakeover) return fail('포인트가 부족해요.');
return ok;
case 'PASS':
return s.phase === 'decide' ? ok : fail('지금은 넘어갈 결정이 없어요.');
case 'PROPOSE_TRADE':
return validateTrade(s, actor, a);
}
return fail('알 수 없는 행동이에요.');
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const ev: GameEvent[] = [];
s.turnSeq += 1;
let act: ClientAction;
if (a.type === 'TIMEOUT') {
act = autoAction(s, actor);
s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1;
pushLog(s, ev, { type: 'timeout', player: actor });
if (s.timeouts[actor] === AWAY_AFTER) pushLog(s, ev, { type: 'away', player: actor });
} else {
act = a;
if (a.type !== 'CONCEDE') s.timeouts[actor] = 0;
}
switch (act.type) {
case 'ROLL':
doRoll(s, rng, ev);
break;
case 'PAY_ISLAND_FEE':
doEscape(s, 'fee', ev);
break;
case 'USE_ESCAPE_CARD':
doEscape(s, 'card', ev);
break;
case 'SPACE_TRAVEL':
doSpaceTravel(s, act.dest, ev);
break;
case 'BUY':
doBuy(s, ev);
break;
case 'BUILD':
doBuild(s, act.level as Level, ev);
break;
case 'TAKEOVER':
doTakeover(s, ev);
break;
case 'PASS':
doPass(s, ev);
break;
case 'SELL':
doSell(s, actor, act.pos, act.what, ev);
break;
case 'PROPOSE_TRADE':
doPropose(s, { from: actor, to: act.to, give: act.give, take: act.take }, ev);
break;
case 'TRADE_RESPOND':
doRespond(s, act.accept, ev);
break;
case 'CONCEDE':
doConcede(s, actor, ev);
break;
}
refreshDeadline(s, now);
return { state: s, events: ev };
},
view(s, viewer) {
const me = viewer !== null && s.players[viewer] ? viewer : null;
const cur = s.phase === 'finished' ? null : curId(s);
const players: MarblePlayerView[] = s.order.map((id, seat) => {
const p = s.players[id]!;
return {
id,
seat,
pos: p.pos,
cash: p.cash,
alive: p.alive,
conceded: p.conceded,
bankruptAtRound: p.bankruptAtRound,
islandLeft: p.islandLeft,
spacePending: p.spacePending,
heldCards: [...p.heldCards],
lands: ownedBy(s, id),
totalAssets: totalAssets(s, id),
away: p.alive && (s.timeouts[id] ?? 0) >= AWAY_AFTER,
};
});
const owned: MarbleOwnedView[] = PROPERTY_POSITIONS.filter((pos) => s.owners[pos]).map((pos) => {
const o = s.owners[pos]!;
return {
pos,
owner: o.owner,
level: o.level,
toll: tollAt(s, pos),
takeoverPrice: s.options.takeover && SPACES[pos]!.kind === 'city' && o.level < 4 ? takeoverPrice(pos, o.level) : null,
sellTop: o.level >= 1 ? stageSale(pos, o.level) : null,
sellAll: fullSale(pos, o.level),
};
});
let my: MarbleMyView | null = null;
if (me && s.phase !== 'finished' && s.players[me]!.alive) {
const p = s.players[me]!;
const mine = me === cur;
const start = mine && s.phase === 'turnStart';
const d = mine && s.phase === 'decide' ? s.decision : null;
const debtor = s.phase === 'debt' && s.debt?.debtor === me;
my = {
canRoll: start && !p.spacePending,
island:
start && p.islandLeft > 0
? { left: p.islandLeft, fee: s.options.islandFee > 0 ? s.options.islandFee : null, canPayFee: s.options.islandFee > 0 && p.cash >= s.options.islandFee, hasCard: p.heldCards.includes('L11') }
: null,
space: start && p.spacePending,
decide: d
? {
pos: d.pos,
buy: d.canBuy ? SPACES[d.pos]!.price! : null,
build: d.canBuild !== null ? { level: d.canBuild, cost: stageCost(d.pos, d.canBuild) } : null,
takeover: d.canTakeover,
boughtThisVisit: d.boughtThisVisit,
}
: null,
sell: debtor ? sellOptions(s, me) : null,
autoSell: debtor ? autoSell(s, me) : null,
canPropose: start && s.options.trade && s.tradesThisTurn < MAX_TRADES_PER_TURN && aliveIds(s).length > 1,
respond: s.phase === 'tradeResponse' && s.pendingTrade?.to === me,
};
}
const dv = s.debt;
return {
options: s.options,
me,
order: [...s.order],
current: cur,
phase: s.phase,
round: s.round,
maxRounds: s.options.maxRounds,
fund: s.fund,
deckCount: s.deck.length,
players,
owned,
lastRoll: s.lastRoll,
lastMove: s.lastMove,
lastCard: s.lastCard,
doublesThisTurn: s.doublesThisTurn,
extraRoll: s.extraRoll,
decision: s.decision,
debt: dv ? { debtor: dv.debtor, amount: dv.amount, to: dv.to, reason: dv.reason, short: Math.max(0, dv.amount - s.players[dv.debtor]!.cash), raisable: raisable(s, dv.debtor) } : null,
trade: s.pendingTrade,
tradesLeft: MAX_TRADES_PER_TURN - s.tradesThisTurn,
my,
spacePreview: cur && s.phase === 'turnStart' && s.players[cur]!.spacePending ? spacePreview(s, cur) : null,
turnSeq: s.turnSeq,
deadlineAt: s.deadlineAt,
log: s.log,
result: s.final,
};
},
activePlayers: activeOf,
deadline(s) {
return s.phase === 'finished' ? null : s.deadlineAt;
},
timeoutPlayers: activeOf,
onTimeout() {
return { type: 'TIMEOUT' };
},
onLeave(s, player) {
const p = s.players[player];
if (!p || !p.alive || s.phase === 'finished') return null;
return { type: 'CONCEDE' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const groups = new Map<number, PlayerId[]>();
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.player]);
const scores: Record<PlayerId, number> = {};
for (const r of f.ranking) scores[r.player] = r.totalAssets;
const out = s.order.filter((p) => !s.players[p]!.alive);
const resign = f.reason === 'lastSurvivor' && out.length > 0 && out.every((p) => s.players[p]!.conceded);
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps),
scores,
summary: f.summary,
reason: resign ? 'resign' : 'normal',
};
},
/** Test helper: every legal action (CONCEDE left out; a couple of sample trades when the option is on). */
legalActions(s, player) {
if (s.phase === 'finished' || !s.players[player]?.alive) return [];
if (s.phase === 'tradeResponse') return s.pendingTrade?.to === player ? [{ type: 'TRADE_RESPOND', accept: true }, { type: 'TRADE_RESPOND', accept: false }] : [];
if (s.phase === 'debt') return s.debt?.debtor === player ? sellOptions(s, player).map((o) => ({ type: 'SELL' as const, pos: o.pos, what: o.what })) : [];
if (player !== curId(s)) return [];
const p = s.players[player]!;
const out: MarbleAction[] = [];
if (s.phase === 'turnStart') {
if (p.spacePending) {
for (let dest = 0; dest < 40; dest++) if (dest !== SPACE_TRAVEL) out.push({ type: 'SPACE_TRAVEL', dest });
} else out.push({ type: 'ROLL' });
for (const a of [{ type: 'PAY_ISLAND_FEE' }, { type: 'USE_ESCAPE_CARD' }] as const) if (marble.validate(s, player, a).ok) out.push(a);
if (s.options.trade) {
const mine = ownedBy(s, player);
for (const q of aliveIds(s)) {
if (q === player) continue;
const theirs = ownedBy(s, q);
const offers: MarbleAction[] = [];
if (mine.length && theirs.length) offers.push({ type: 'PROPOSE_TRADE', to: q, give: { lands: [mine[0]!], cash: 0 }, take: { lands: [theirs[0]!], cash: 0 } });
if (theirs.length) offers.push({ type: 'PROPOSE_TRADE', to: q, give: { lands: [], cash: Math.min(p.cash, 30) }, take: { lands: [theirs[theirs.length - 1]!], cash: 0 } });
for (const t of offers) if (marble.validate(s, player, t).ok) out.push(t);
}
}
return out;
}
if (s.phase === 'decide') {
const d = s.decision!;
out.push({ type: 'PASS' });
if (d.canBuy && p.cash >= SPACES[d.pos]!.price!) out.push({ type: 'BUY' });
if (d.canBuild !== null && p.cash >= stageCost(d.pos, d.canBuild)) out.push({ type: 'BUILD', level: d.canBuild });
if (d.canTakeover !== null && p.cash >= d.canTakeover) out.push({ type: 'TAKEOVER' });
}
return out;
},
/** Test helper: IDs of cards that were never drawn — no view may contain them. */
secretsOf(s) {
return CARD_IDS.filter((c) => !s.revealed.includes(c));
},
};

File diff suppressed because it is too large Load Diff

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,
};
}

View File

@@ -23,6 +23,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);