Merge branch 'm5/marble' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
1393
apps/web/src/games/marble/index.tsx
Normal file
1393
apps/web/src/games/marble/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
gostop: () => import('./gostop'),
|
gostop: () => import('./gostop'),
|
||||||
bang: () => import('./bang'),
|
bang: () => import('./bang'),
|
||||||
'halli-galli': () => import('./halli-galli'),
|
'halli-galli': () => import('./halli-galli'),
|
||||||
|
marble: () => import('./marble'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const GAME_ICON: Record<string, string> = {
|
|||||||
rummikub: '🔢',
|
rummikub: '🔢',
|
||||||
'davinci-code': '🔐',
|
'davinci-code': '🔐',
|
||||||
gostop: '🌸',
|
gostop: '🌸',
|
||||||
|
marble: '🏙️',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { expect, test } from 'bun:test';
|
|||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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][] = [
|
const FORBIDDEN: [RegExp, string][] = [
|
||||||
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
|
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
|
||||||
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],
|
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],
|
||||||
|
|||||||
@@ -203,6 +203,48 @@ async function gostopStep(p: Page, state: { go: number; stop: number; picks: num
|
|||||||
if (await lifted.count()) await lifted.click({ timeout: 1500 });
|
if (await lifted.count()) await lifted.click({ timeout: 1500 });
|
||||||
state.plays++;
|
state.plays++;
|
||||||
return 'play';
|
return 'play';
|
||||||
|
/**
|
||||||
|
* 주사위 땅따먹기: 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> = {
|
const PLANS: Record<string, Plan> = {
|
||||||
@@ -394,6 +436,7 @@ const PLANS: Record<string, Plan> = {
|
|||||||
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') });
|
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') });
|
||||||
await guest.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 (!acted) await host.waitForTimeout(120);
|
||||||
}
|
}
|
||||||
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`);
|
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`);
|
||||||
@@ -401,6 +444,59 @@ const PLANS: Record<string, Plan> = {
|
|||||||
console.log(` yut: finished after ${actions} UI actions`);
|
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': {
|
'one-card': {
|
||||||
// 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play,
|
// 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.
|
// pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./rummikub/rules": "./src/rummikub/rules.ts",
|
"./rummikub/rules": "./src/rummikub/rules.ts",
|
||||||
|
"./marble/board": "./src/marble/board.ts",
|
||||||
"./*": "./src/*/index.ts"
|
"./*": "./src/*/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { mahjong } from './mahjong';
|
|||||||
import { gostop } from './gostop';
|
import { gostop } from './gostop';
|
||||||
import { bang } from './bang';
|
import { bang } from './bang';
|
||||||
import { halliGalli } from './halli-galli';
|
import { halliGalli } from './halli-galli';
|
||||||
|
import { marble } from './marble';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
@@ -33,4 +34,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
|||||||
[gostop.id]: gostop,
|
[gostop.id]: gostop,
|
||||||
[bang.id]: bang,
|
[bang.id]: bang,
|
||||||
[halliGalli.id]: halliGalli,
|
[halliGalli.id]: halliGalli,
|
||||||
|
[marble.id]: marble,
|
||||||
};
|
};
|
||||||
|
|||||||
182
packages/games/src/marble/board.ts
Normal file
182
packages/games/src/marble/board.ts
Normal 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;
|
||||||
559
packages/games/src/marble/index.ts
Normal file
559
packages/games/src/marble/index.ts
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
1131
packages/games/src/marble/marble.test.ts
Normal file
1131
packages/games/src/marble/marble.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
945
packages/games/src/marble/rules.ts
Normal file
945
packages/games/src/marble/rules.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export const CATALOG: CatalogEntry[] = [
|
|||||||
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
|
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
|
||||||
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
|
||||||
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
|
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', 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);
|
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||||
|
|||||||
Reference in New Issue
Block a user