import { describe, expect, test } from 'bun:test'; import { SeededRng, randomPlayout, seedN, type Rng } from '@bg/engine'; import { CARD_IDS, GROUPS, LINES, PROPERTY_POSITIONS, SPACES, autoAction, curId, marble, maxLevel, type CardId, type Level, type MarbleAction, type MarbleOptions, type MarbleState, } from './index'; // ---------------------------------------------------------------- helpers /** Scripted dice: `int(6)` returns the queued die faces (1..6) in order. */ class Dice implements Rng { private vals: number[]; constructor(vals: number[]) { this.vals = [...vals]; } next(): number { throw new Error('dice script: next() not expected'); } int(n: number): number { const v = this.vals.shift(); if (v === undefined) throw new Error('dice script exhausted'); if (n !== 6) throw new Error(`dice script: int(${n})`); return v - 1; } shuffle(a: readonly T[]): T[] { return [...a]; } get left(): number { return this.vals.length; } } const dice = (...v: number[]) => new Dice(v); const PLAYERS = ['A', 'B', 'C', 'D']; /** Fresh game, seats in A, B, C… order with A to move and the deck in L01…L20 order. */ function make(opts: Partial = {}, n = 2): MarbleState { const options = marble.optionsSchema.parse(opts); const s = marble.setup({ players: PLAYERS.slice(0, n), options, rng: SeededRng.fromSeed(seedN(1)), now: 0 }); s.order = PLAYERS.slice(0, n); s.current = 0; s.deck = [...CARD_IDS]; s.log = []; return s; } let clock = 1000; function actE(s: MarbleState, p: string, a: MarbleAction, rng: Rng = dice()) { const v = marble.validate(s, p, a); if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); return marble.apply(s, p, a, { rng, now: (clock += 1000) }); } const act = (s: MarbleState, p: string, a: MarbleAction, rng?: Rng) => actE(s, p, a, rng).state; const reject = (s: MarbleState, p: string, a: MarbleAction) => { const v = marble.validate(s, p, a); return v.ok ? null : v.reason; }; const roll = (s: MarbleState, d1: number, d2: number) => act(s, curId(s), { type: 'ROLL' }, dice(d1, d2)); const own = (s: MarbleState, pos: number, owner: string, level: Level = 0) => (s.owners[pos] = { owner, level }); const put = (s: MarbleState, id: string, pos: number) => (s.players[id]!.pos = pos); const cash = (s: MarbleState, id: string) => s.players[id]!.cash; const setCash = (s: MarbleState, id: string, n: number) => (s.players[id]!.cash = n); /** Puts `card` on top of the deck. */ const topCard = (s: MarbleState, card: CardId) => (s.deck = [card, ...s.deck.filter((c) => c !== card)]); const PASS: MarbleAction = { type: 'PASS' }; // ---------------------------------------------------------------- board data describe('판 데이터 (§3)', () => { test('40칸: 도시 24, 관광지 4, 행운카드 7, 특수 5', () => { expect(SPACES.length).toBe(40); const count = (k: string) => SPACES.filter((s) => s.kind === k).length; expect([count('city'), count('tour'), count('card')]).toEqual([24, 4, 7]); expect(SPACES.filter((s) => ['start', 'island', 'fundIn', 'space', 'fundOut'].includes(s.kind)).map((s) => s.pos)).toEqual([0, 10, 20, 30, 38]); expect(SPACES.filter((s) => s.kind === 'card').map((s) => s.pos)).toEqual([2, 7, 12, 17, 22, 28, 33]); expect(Object.values(GROUPS).every((g) => g.length === 3)).toBe(true); expect(LINES).toEqual([ [1, 3, 4, 6, 8, 9], [11, 13, 14, 16, 18, 19], [21, 23, 24, 26, 27, 29], [31, 32, 34, 36, 37, 39], ]); }); test('가격표는 §3.2 공식(건설비 ×0.5/1.0/1.5/2.0, 통행료 ×0.2(최소 1)/0.6/1.5/2.5/4.0, 반올림)과 같다', () => { for (const s of SPACES.filter((x) => x.kind === 'city')) { const p = s.price!; expect(s.build).toEqual([5, 10, 15, 20].map((k) => Math.round((p * k) / 10)) as [number, number, number, number]); expect(s.tolls).toEqual([Math.max(1, Math.round((p * 2) / 10)), ...[6, 15, 25, 40].map((k) => Math.round((p * k) / 10))]); } expect(SPACES.filter((x) => x.kind === 'tour').map((s) => [s.name, s.price, s.tolls![0]])).toEqual([ ['제주도', 20, 10], ['경주', 20, 10], ['우주센터', 30, 15], ['부산', 25, 12], ]); expect(SPACES[39]).toMatchObject({ name: '서울', price: 40, build: [20, 40, 60, 80], tolls: [8, 24, 60, 100, 160] }); expect(SPACES[36]).toMatchObject({ name: '뉴욕', build: [18, 35, 53, 70], tolls: [7, 21, 53, 88, 140] }); }); test('options: every option has a Korean title; defaults match §2', () => { expect(marble.defaultOptions).toEqual({ startMoney: 300, salary: 20, maxRounds: 20, buildOnPurchase: false, islandTurns: 3, islandFee: 0, takeover: false, landmark: false, monopolyWin: false, trade: false, turnSeconds: 30, }); const shape = (marble.optionsSchema as unknown as { shape: Record }).shape; for (const [k, f] of Object.entries(shape)) expect([k, /[가-힣]/.test(f.meta()?.title ?? '')]).toEqual([k, true]); }); }); // ---------------------------------------------------------------- setup describe('준비 (§4)', () => { test('everyone on 출발 with startMoney, deck of 20 hidden cards, round 1, order is a shuffle of the seats', () => { const s = marble.setup({ players: ['A', 'B', 'C'], options: marble.optionsSchema.parse({ startMoney: 500 }), rng: SeededRng.fromSeed(seedN(3)), now: 0 }); expect([...s.order].sort()).toEqual(['A', 'B', 'C']); expect(Object.values(s.players).every((p) => p.pos === 0 && p.cash === 500 && p.alive)).toBe(true); expect([...s.deck].sort()).toEqual(CARD_IDS); expect([s.round, s.fund, s.phase, Object.keys(s.owners).length]).toEqual([1, 0, 'turnStart', 0]); expect(() => marble.setup({ players: ['A'], options: marble.defaultOptions, rng: SeededRng.fromSeed(seedN(3)), now: 0 })).toThrow(); expect(() => marble.setup({ players: PLAYERS.concat('E'), options: marble.defaultOptions, rng: SeededRng.fromSeed(seedN(3)), now: 0 })).toThrow(); }); }); // ---------------------------------------------------------------- salary & movement describe('월급과 이동 (§5.1)', () => { test('37에서 5칸 → 2 도착, 월급 1번', () => { let s = make(); put(s, 'A', 37); topCard(s, 'L11'); // a card that keeps the points unchanged s = roll(s, 2, 3); expect(s.players.A!.pos).toBe(2); expect(cash(s, 'A')).toBe(320); expect(s.log.filter((e) => e.type === 'salary')).toHaveLength(1); }); test('출발(0)에 정확히 도착해도 1번', () => { let s = make(); put(s, 'A', 35); s = roll(s, 1, 4); expect([s.players.A!.pos, cash(s, 'A'), curId(s)]).toEqual([0, 320, 'B']); }); test('뒤로 3칸 카드로 0을 거꾸로 지나면 월급 없음 (2 → 39 서울)', () => { let s = make(); put(s, 'A', 37); topCard(s, 'L16'); s = roll(s, 2, 3); // 37 → 2 (salary), card: back 3 → 39 expect(s.players.A!.pos).toBe(39); expect(cash(s, 'A')).toBe(320); expect(s.log.filter((e) => e.type === 'salary')).toHaveLength(1); expect(s.phase).toBe('decide'); expect(s.decision).toMatchObject({ pos: 39, canBuy: true }); }); test('§5.9 예시 B: 37에서 더블 4-4 → 제주도(5), 월급 받고 살지 정한 뒤 한 번 더 굴림', () => { let s = make(); put(s, 'A', 37); s = roll(s, 4, 4); expect([s.players.A!.pos, cash(s, 'A'), s.phase, s.extraRoll]).toEqual([5, 320, 'decide', true]); s = act(s, 'A', { type: 'BUY' }); expect([s.owners[5]?.owner, cash(s, 'A'), s.phase, curId(s)]).toEqual(['A', 300, 'turnStart', 'A']); }); }); // ---------------------------------------------------------------- doubles describe('더블 (§5.1-4)', () => { test('더블 → 처리 후 재굴림; 3번째 더블 → 이동 없이 무인도, 월급 없음', () => { let s = make(); s = roll(s, 2, 2); // → 4 하노이 expect([s.phase, s.extraRoll]).toEqual(['decide', true]); s = act(s, 'A', PASS); expect([s.phase, curId(s), s.doublesThisTurn]).toEqual(['turnStart', 'A', 1]); s = roll(s, 1, 1); // → 6 방콕 s = act(s, 'A', PASS); expect([curId(s), s.doublesThisTurn]).toEqual(['A', 2]); put(s, 'A', 37); // the third double would pass 출발 if it moved s = roll(s, 3, 3); expect([s.players.A!.pos, s.players.A!.islandLeft, cash(s, 'A'), curId(s)]).toEqual([10, 3, 300, 'B']); expect(s.log.some((e) => e.type === 'toIsland' && e.why === 'doubles')).toBe(true); }); test('더블이 아니면 차례가 넘어가고 더블 횟수가 0으로', () => { let s = make(); s = roll(s, 1, 2); // → 3 마닐라 s = act(s, 'A', PASS); expect([curId(s), s.doublesThisTurn, s.extraRoll]).toEqual(['B', 0, false]); }); test('더블로 우주여행 도착 → 추가 굴림 소멸, 다음 차례는 SPACE_TRAVEL만 (ROLL 거부)', () => { let s = make(); put(s, 'A', 26); s = roll(s, 2, 2); expect([s.players.A!.pos, s.players.A!.spacePending, curId(s), s.extraRoll]).toEqual([30, true, 'B', false]); s = roll(s, 1, 2); // B → 3 s = act(s, 'B', PASS); expect(curId(s)).toBe('A'); expect(reject(s, 'A', { type: 'ROLL' })).toBe('우주여행 차례예요. 갈 칸을 골라 주세요.'); expect(reject(s, 'A', { type: 'SPACE_TRAVEL', dest: 30 })).toContain('우주여행 칸으로는 갈 수 없어요'); expect(marble.legalActions!(s, 'A').every((a) => a.type === 'SPACE_TRAVEL')).toBe(true); s = act(s, 'A', { type: 'SPACE_TRAVEL', dest: 39 }); // §5.9 C: 30 → 39, no salary expect([s.players.A!.pos, cash(s, 'A'), s.phase, s.players.A!.spacePending]).toEqual([39, 300, 'decide', false]); }); test('더블로 무인도 칸 도착 → 추가 굴림 없음', () => { let s = make(); put(s, 'A', 4); s = roll(s, 3, 3); expect([s.players.A!.pos, s.players.A!.islandLeft, curId(s)]).toEqual([10, 3, 'B']); }); }); // ---------------------------------------------------------------- island describe('무인도 (§5.4)', () => { /** A on the island, A to move again (B's turns are skipped by hand). */ const onIsland = (opts: Partial = {}) => { const s = make(opts); put(s, 'A', 10); s.players.A!.islandLeft = s.options.islandTurns; return s; }; const backToA = (s: MarbleState) => { expect(curId(s)).toBe('B'); s.current = 0; return s; }; test('islandTurns=3: 비더블 3번 → 4번째 차례는 일반 진행', () => { let s = onIsland(); for (const left of [2, 1, 0]) { s = backToA(roll(s, 1, 2)); expect([s.players.A!.pos, s.players.A!.islandLeft]).toEqual([10, left]); } s = roll(s, 1, 2); expect([s.players.A!.pos, s.players.A!.islandLeft]).toEqual([13, 0]); }); test('무인도에서 더블 → 탈출·이동, 추가 굴림 없음', () => { let s = onIsland(); s = roll(s, 3, 3); expect([s.players.A!.pos, s.players.A!.islandLeft, s.phase, s.extraRoll]).toEqual([16, 0, 'decide', false]); s = act(s, 'A', PASS); expect(curId(s)).toBe('B'); }); test('탈출권 사용 → 같은 차례 일반 굴림, 더블이면 재굴림; 카드는 더미 맨 아래로', () => { let s = onIsland(); s.players.A!.heldCards = ['L11']; s.deck = s.deck.filter((c) => c !== 'L11'); expect(reject(s, 'A', { type: 'PAY_ISLAND_FEE' })).toBe('이 방에서는 탈출비를 내고 나갈 수 없어요.'); s = act(s, 'A', { type: 'USE_ESCAPE_CARD' }); expect([s.players.A!.islandLeft, s.players.A!.heldCards, s.deck.at(-1), s.phase, curId(s)]).toEqual([0, [], 'L11', 'turnStart', 'A']); s = roll(s, 2, 2); expect(s.players.A!.pos).toBe(14); s = act(s, 'A', PASS); expect([curId(s), s.phase]).toEqual(['A', 'turnStart']); }); test('탈출비(옵션): 내고 바로 나가 같은 차례에 굴림; 포인트가 모자라면 거부', () => { let s = onIsland({ islandFee: 20 }); setCash(s, 'A', 19); expect(reject(s, 'A', { type: 'PAY_ISLAND_FEE' })).toBe('포인트가 부족해요.'); setCash(s, 'A', 50); s = act(s, 'A', { type: 'PAY_ISLAND_FEE' }); expect([cash(s, 'A'), s.players.A!.islandLeft, curId(s)]).toEqual([30, 0, 'A']); s = roll(s, 1, 2); expect(s.players.A!.pos).toBe(13); }); test('무인도에 있어도 통행료는 받는다; 출발을 지나 무인도에 와도 그냥 지나가면 아무 일 없음', () => { let s = onIsland(); own(s, 3, 'A', 1); s.current = 1; put(s, 'B', 1); s = roll(s, 1, 1); // B → 3 (double), toll 4 to A expect([cash(s, 'A'), cash(s, 'B')]).toEqual([304, 296]); put(s, 'B', 8); s = roll(s, 1, 2); // B passes 10 → 11 expect([s.players.B!.pos, s.players.B!.islandLeft]).toEqual([11, 0]); }); }); // ---------------------------------------------------------------- buying & building describe('구매·건설 (§5.2)', () => { test('구매한 방문에 BUILD 거부, 다음 방문 별장만, 빌딩 건너뛰기 거부, 한 방문 2단계 거부', () => { let s = make(); s = roll(s, 1, 2); // → 3 마닐라 s = act(s, 'A', { type: 'BUY' }); expect([s.owners[3], cash(s, 'A'), curId(s)]).toEqual([{ owner: 'A', level: 0 }, 294, 'B']); expect(reject(s, 'A', { type: 'BUILD', level: 1 })).toBe('지금은 다른 사람 차례예요.'); // Next visit. s.current = 0; put(s, 'A', 0); s = roll(s, 1, 2); expect(s.decision).toMatchObject({ pos: 3, canBuy: false, canBuild: 1 }); expect(reject(s, 'A', { type: 'BUY' })).toBe('지금은 이 땅을 살 수 없어요.'); expect(reject(s, 'A', { type: 'BUILD', level: 2 })).toBe('건물은 한 단계씩만 올릴 수 있어요.'); expect(reject(s, 'A', { type: 'BUILD', level: 4 })).toBe('이 방에서는 랜드마크를 짓지 않아요.'); s = act(s, 'A', { type: 'BUILD', level: 1 }); expect([s.owners[3]!.level, cash(s, 'A'), curId(s)]).toEqual([1, 291, 'B']); expect(reject(s, 'A', { type: 'BUILD', level: 2 })).toBe('지금은 다른 사람 차례예요.'); }); test('한 방문에 1단계: 더블로 같은 차례에 다시 굴려도 결정은 닫혀 있음', () => { let s = make(); own(s, 4, 'A', 0); s = roll(s, 2, 2); // 4 own → build offer s = act(s, 'A', { type: 'BUILD', level: 1 }); expect([s.phase, curId(s)]).toEqual(['turnStart', 'A']); expect(reject(s, 'A', { type: 'BUILD', level: 2 })).toBe('지금은 지을 수 없어요.'); }); test('buildOnPurchase=true: 산 직후 별장까지만', () => { let s = make({ buildOnPurchase: true }); s = roll(s, 1, 2); s = act(s, 'A', { type: 'BUY' }); expect(s.decision).toMatchObject({ pos: 3, canBuild: 1, boughtThisVisit: true }); s = act(s, 'A', { type: 'BUILD', level: 1 }); expect([s.owners[3]!.level, cash(s, 'A'), curId(s)]).toEqual([1, 291, 'B']); }); test('현금이 부족하면 살 수 없다(결정 없이 지나감); 관광지에는 건물이 없음', () => { let s = make(); setCash(s, 'A', 5); s = roll(s, 1, 2); expect([s.phase, curId(s), s.owners[3]]).toEqual(['turnStart', 'B', undefined]); expect(s.log.some((e) => e.type === 'cannotAfford')).toBe(true); s = make(); own(s, 5, 'A'); s = roll(s, 1, 4); expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); }); test('BUY 검증: 포인트가 부족해요', () => { const s = make(); s.phase = 'decide'; s.decision = { pos: 39, canBuy: true, canBuild: null, canTakeover: null, boughtThisVisit: false }; setCash(s, 'A', 39); expect(reject(s, 'A', { type: 'BUY' })).toBe('포인트가 부족해요.'); expect(reject(s, 'B', { type: 'BUY' })).toBe('지금은 다른 사람 차례예요.'); expect(reject(s, 'A', { type: 'ROLL' })).toBe('먼저 이 칸에서 할 일을 정해 주세요.'); }); test('호텔 다음은 랜드마크 옵션일 때만', () => { let s = make(); own(s, 3, 'A', 3); s = roll(s, 1, 2); expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); s = make({ landmark: true }); own(s, 3, 'A', 3); s = roll(s, 1, 2); expect(s.decision).toMatchObject({ canBuild: 4 }); s = act(s, 'A', { type: 'BUILD', level: 4 }); expect([s.owners[3]!.level, cash(s, 'A')]).toEqual([4, 288]); expect(maxLevel(s.options)).toBe(4); }); }); // ---------------------------------------------------------------- tolls describe('통행료 (§5.2, §3.2/3.3)', () => { test.each([ [39, 0, 8], [39, 1, 24], [39, 2, 60], [39, 3, 100], [1, 0, 1], [9, 1, 7], [36, 3, 88], ] as [number, Level, number][])('칸 %i 단계 %i → %i', (pos, level, toll) => { let s = make(); own(s, pos, 'B', level); put(s, 'A', (pos - 3 + 40) % 40); s = roll(s, 1, 2); const salary = pos < 3 ? 20 : 0; expect([cash(s, 'A'), cash(s, 'B')]).toEqual([300 + salary - toll, 300 + toll]); expect(s.log.find((e) => e.type === 'tollPaid')).toMatchObject({ from: 'A', to: 'B', amount: toll, waived: false }); }); test('랜드마크 통행료 (옵션)', () => { let s = make({ landmark: true }); own(s, 39, 'B', 4); put(s, 'A', 36); s = roll(s, 1, 2); expect(cash(s, 'A')).toBe(140); }); test('통행료 면제권: 자동 사용 후 더미 맨 아래로', () => { let s = make(); own(s, 39, 'B', 3); s.players.A!.heldCards = ['L12']; s.deck = s.deck.filter((c) => c !== 'L12'); put(s, 'A', 36); s = roll(s, 1, 2); expect([cash(s, 'A'), cash(s, 'B'), s.players.A!.heldCards, s.deck.at(-1), s.deck.length]).toEqual([300, 300, [], 'L12', 20]); expect(s.log.find((e) => e.type === 'tollPaid')).toMatchObject({ waived: true, amount: 100 }); }); test('관광지 2곳 이상 소유 시 각 관광지 통행료 2배', () => { let s = make(); own(s, 5, 'B'); s = roll(s, 1, 4); expect(cash(s, 'A')).toBe(290); s = make(); own(s, 5, 'B'); own(s, 35, 'B'); s = roll(s, 1, 4); expect(cash(s, 'A')).toBe(280); s = make(); own(s, 25, 'B'); own(s, 15, 'B'); own(s, 5, 'B'); put(s, 'A', 22); s = roll(s, 1, 2); expect(cash(s, 'A')).toBe(270); }); }); // ---------------------------------------------------------------- debt & bankruptcy describe('빚 갚기와 파산 (§5.3)', () => { test('§5.9 예시 A: 리스본 빌딩·별장·땅을 팔아 102 → 자동으로 100 지불, 현금 2', () => { let s = make(); own(s, 39, 'B', 3); own(s, 26, 'A', 2); // 리스본 빌딩: 13 + 6 + 13 = 32 own(s, 5, 'A'); // 제주도 10 own(s, 15, 'A'); // 경주 10 → 확보 가능액 70 + 52 = 122 setCash(s, 'A', 70); put(s, 'A', 36); s = roll(s, 1, 2); expect(s.phase).toBe('debt'); expect(s.debt).toMatchObject({ debtor: 'A', amount: 100, to: { kind: 'player', id: 'B' }, reason: 'toll' }); const v = marble.view(s, 'A'); expect(v.debt).toMatchObject({ short: 30, raisable: 122 }); expect(v.my!.sell).toContainEqual({ pos: 26, what: 'top', amount: 13, level: 2 }); expect(v.my!.sell).toContainEqual({ pos: 26, what: 'all', amount: 32, level: 2 }); expect(reject(s, 'A', { type: 'ROLL' })).toBe('먼저 모자란 포인트를 채워야 해요.'); expect(reject(s, 'B', { type: 'SELL', pos: 26, what: 'top' })).toContain('지금은 팔 수 없어요'); expect(reject(s, 'A', { type: 'SELL', pos: 39, what: 'all' })).toBe('내 땅만 팔 수 있어요.'); expect(reject(s, 'A', { type: 'SELL', pos: 5, what: 'top' })).toBe('그 땅에는 팔 건물이 없어요.'); s = act(s, 'A', { type: 'SELL', pos: 26, what: 'top' }); s = act(s, 'A', { type: 'SELL', pos: 26, what: 'top' }); expect([cash(s, 'A'), s.owners[26]!.level, s.phase]).toEqual([89, 0, 'debt']); s = act(s, 'A', { type: 'SELL', pos: 26, what: 'all' }); expect([cash(s, 'A'), cash(s, 'B'), s.owners[26], s.phase, curId(s)]).toEqual([2, 400, undefined, 'turnStart', 'B']); }); test('확보 가능액 < 빚 → 즉시 파산, 채권자가 매각 후 현금 전부, 땅은 은행으로', () => { let s = make({}, 3); own(s, 39, 'B', 3); own(s, 1, 'A', 1); // 3 + 1 s.players.A!.heldCards = ['L11']; s.deck = s.deck.filter((c) => c !== 'L11'); setCash(s, 'A', 50); put(s, 'A', 36); s = roll(s, 1, 2); expect([s.players.A!.alive, s.players.A!.cash, cash(s, 'B'), s.owners[1], s.players.A!.bankruptAtRound]).toEqual([false, 0, 354, undefined, 1]); expect([s.deck.length, s.deck.at(-1)]).toEqual([20, 'L11']); expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); expect(s.log.find((e) => e.type === 'bankrupt')).toMatchObject({ player: 'A', creditor: { kind: 'player', id: 'B' }, amount: 54 }); }); test('2명 중 1명 파산 → 생존자 1위로 끝', () => { let s = make(); own(s, 39, 'B', 3); setCash(s, 'A', 10); put(s, 'A', 36); s = roll(s, 1, 2); expect(s.phase).toBe('finished'); const r = marble.result(s)!; expect(r.ranking).toEqual([['B'], ['A']]); expect(r.reason).toBe('normal'); expect(s.final!.reason).toBe('lastSurvivor'); }); test('L19(여행 경비) 중 파산: 좌석 순 앞사람만 받고 이후 사람은 못 받음', () => { let s = make({}, 4); setCash(s, 'A', 7); put(s, 'A', 0); topCard(s, 'L19'); s = roll(s, 1, 1); // → 2 행운카드 expect([cash(s, 'B'), cash(s, 'C'), cash(s, 'D')]).toEqual([305, 302, 300]); expect(s.players.A!.alive).toBe(false); expect(curId(s)).toBe('B'); }); test('L04(생일 축하): 다른 사람이 모자라면 그 사람이 빚 갚기, 다 갚으면 내 차례가 이어짐', () => { let s = make({}, 3); setCash(s, 'B', 2); own(s, 6, 'B'); // 방콕: sale 5 topCard(s, 'L04'); s = roll(s, 1, 1); // → 2 행운카드, double expect(s.phase).toBe('debt'); expect(marble.activePlayers(s)).toEqual(['B']); s = act(s, 'B', { type: 'SELL', pos: 6, what: 'all' }); expect([cash(s, 'A'), cash(s, 'B'), cash(s, 'C'), s.phase, curId(s)]).toEqual([310, 2, 295, 'turnStart', 'A']); }); }); // ---------------------------------------------------------------- cards describe('행운카드 (§3.5)', () => { /** A draws `card` from the card square 7 (rolled from 4 with 1+2) — or from `from`. */ const draw = (card: CardId, setup?: (s: MarbleState) => void, from = 4, d: [number, number] = [1, 2], opts: Partial = {}, n = 3) => { let s = make(opts, n); put(s, 'A', from); topCard(s, card); setup?.(s); s = roll(s, d[0], d[1]); expect(s.lastCard?.id).toBe(card); return s; }; test.each([ ['L01', 20], ['L02', 10], ['L03', 15], ['L05', 10], ] as [CardId, number][])('%s: 은행에서 %i 받기, 카드는 맨 아래로', (card, n) => { const s = draw(card); expect([cash(s, 'A'), s.deck.at(-1), s.deck.length]).toEqual([300 + n, card, 20]); }); test('L04: 다른 사람 모두에게서 5씩', () => { const s = draw('L04'); expect([cash(s, 'A'), cash(s, 'B'), cash(s, 'C')]).toEqual([310, 295, 295]); }); test('L06/L08: 기금 적립, L07: 은행에 10', () => { expect([cash(draw('L06'), 'A'), draw('L06').fund]).toEqual([295, 5]); expect([cash(draw('L08'), 'A'), draw('L08').fund]).toEqual([290, 10]); expect([cash(draw('L07'), 'A'), draw('L07').fund]).toEqual([290, 0]); }); test('L09 건물 수리비: 호텔 도시 19, 별장 3, 땅만 0', () => { const s = draw('L09', (s) => { own(s, 1, 'A', 3); own(s, 3, 'A', 1); own(s, 4, 'A', 0); own(s, 6, 'B', 3); }); expect(cash(s, 'A')).toBe(278); expect(draw('L09', (s) => own(s, 39, 'A', 4), 4, [1, 2], { landmark: true }).players.A!.cash).toBe(266); }); test('L10 폭풍 경보: 무인도로 바로(월급 없음), 더블 연속 끊김', () => { const s = draw('L10', undefined, 5, [1, 1]); expect([s.players.A!.pos, s.players.A!.islandLeft, cash(s, 'A'), curId(s), s.extraRoll]).toEqual([10, 3, 300, 'B', false]); }); test('L11/L12: 보관(더미에서 빠짐)', () => { const s = draw('L11'); expect([s.players.A!.heldCards, s.deck.length, s.deck.includes('L11')]).toEqual([['L11'], 19, false]); expect(marble.view(s, 'B').players.find((p) => p.id === 'A')!.heldCards).toEqual(['L11']); }); test('L13 출발지로: 0으로 이동, 월급', () => { const s = draw('L13'); expect([s.players.A!.pos, cash(s, 'A')]).toEqual([0, 320]); }); test('L14 우주 초대장: 33에서 우주여행(30)으로 앞으로 → 출발을 지나 월급, 우주여행 대기', () => { const s = draw('L14', undefined, 31, [1, 1]); expect([s.players.A!.pos, cash(s, 'A'), s.players.A!.spacePending, curId(s), s.extraRoll]).toEqual([30, 320, true, 'B', false]); }); test('L15 서울 관광: 서울로 이동, 도착 효과(통행료)', () => { const s = draw('L15', (s) => own(s, 39, 'B', 1)); expect([s.players.A!.pos, cash(s, 'A'), cash(s, 'B')]).toEqual([39, 276, 324]); }); test('L16 뒤로 3칸: 7 → 4 하노이 도착 효과', () => { const s = draw('L16'); expect([s.players.A!.pos, s.phase, s.decision?.pos]).toEqual([4, 'decide', 4]); }); test('L17 기부 천사: 20으로 가서 적립금 받기', () => { const s = draw('L17', (s) => (s.fund = 45)); expect([s.players.A!.pos, cash(s, 'A'), s.fund]).toEqual([20, 345, 0]); }); test('L18 관광 안내: 앞쪽 가장 가까운 관광지 (7 → 경주 15, 33 → 부산 35)', () => { expect(draw('L18').players.A!.pos).toBe(15); expect(draw('L18', undefined, 31, [1, 1]).players.A!.pos).toBe(35); }); test('L19 여행 경비: 다른 사람 모두에게 5씩', () => { const s = draw('L19'); expect([cash(s, 'A'), cash(s, 'B'), cash(s, 'C')]).toEqual([290, 305, 305]); }); test('L20 재산세: 땅 1곳당 2', () => { const s = draw('L20', (s) => { own(s, 1, 'A', 3); own(s, 5, 'A'); own(s, 6, 'B'); }); expect(cash(s, 'A')).toBe(296); }); test('카드는 뽑기 전에는 보이지 않고, 같은 차례 5번째 카드부터 카드 칸 효과 무시', () => { let s = make(); s.cardsThisTurn = 5; s = roll(s, 1, 1); expect([s.lastCard, s.deck.length, s.log.some((e) => e.type === 'cardIgnored')]).toEqual([null, 20, true]); }); }); // ---------------------------------------------------------------- fund describe('사회복지기금', () => { test('38 도착 15 적립, 20 도착 시 전액 수령 후 0', () => { let s = make(); put(s, 'A', 35); s = roll(s, 1, 2); expect([s.fund, cash(s, 'A')]).toEqual([15, 285]); put(s, 'B', 17); s = roll(s, 1, 2); expect([s.fund, cash(s, 'B')]).toEqual([0, 315]); // Nothing collected: no event noise beyond the receipt of 0. put(s, 'A', 17); s = roll(s, 1, 2); expect([s.fund, cash(s, 'A')]).toEqual([0, 285]); }); }); // ---------------------------------------------------------------- space travel describe('우주여행 (§5.5)', () => { const ready = () => { const s = make(); put(s, 'A', 30); s.players.A!.spacePending = true; return s; }; test('우주센터 타인 소유 → 탑승료 20, 30 → 5 이동 시 출발 통과 월급', () => { let s = ready(); own(s, 25, 'B'); const v = marble.view(s, 'A'); expect(v.spacePreview!.find((p) => p.dest === 5)).toEqual({ dest: 5, salary: true, owner: null, toll: 0, fare: 20 }); expect(v.spacePreview!.find((p) => p.dest === 25)).toMatchObject({ salary: true, owner: 'B', toll: 15 }); expect(v.spacePreview!.find((p) => p.dest === 39)).toMatchObject({ salary: false, owner: null }); expect(v.spacePreview).toHaveLength(39); s = act(s, 'A', { type: 'SPACE_TRAVEL', dest: 5 }); expect([cash(s, 'A'), cash(s, 'B'), s.players.A!.pos, s.phase]).toEqual([300, 320, 5, 'decide']); }); test('우주센터가 내 것이거나 은행 것이면 무료; 0에 도착해도 월급', () => { let s = ready(); own(s, 25, 'A'); s = act(s, 'A', { type: 'SPACE_TRAVEL', dest: 0 }); expect([cash(s, 'A'), s.players.A!.pos, curId(s)]).toEqual([320, 0, 'B']); }); test('탑승료가 모자라면 빚 갚기 후 이동', () => { let s = ready(); own(s, 25, 'B'); own(s, 39, 'A'); setCash(s, 'A', 15); s = act(s, 'A', { type: 'SPACE_TRAVEL', dest: 3 }); s = act(s, 'A', { type: 'SELL', pos: 39, what: 'all' }); expect([cash(s, 'A'), cash(s, 'B'), s.players.A!.pos, s.phase]).toEqual([35 - 20 + 20, 320, 3, 'decide']); }); }); // ---------------------------------------------------------------- options describe('인수·독점·라운드 제한 (§5.6, §5.7)', () => { test('인수: 통행료 지불 후 인수가 = 2 × 투자액; 랜드마크·관광지 거부; 옵션 꺼짐이면 거부', () => { let s = make({ takeover: true }); own(s, 14, 'B', 2); // 16 + 8 + 16 = 40 → 80 put(s, 'A', 11); s = roll(s, 1, 2); expect([cash(s, 'A'), s.phase, s.decision?.canTakeover]).toEqual([276, 'decide', 80]); s = act(s, 'A', { type: 'TAKEOVER' }); expect([s.owners[14], cash(s, 'A'), cash(s, 'B'), curId(s)]).toEqual([{ owner: 'A', level: 2 }, 196, 404, 'B']); s = make({ takeover: true, landmark: true }); own(s, 14, 'B', 4); put(s, 'A', 11); s = roll(s, 1, 2); expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); s = make({ takeover: true }); own(s, 15, 'B'); put(s, 'A', 12); s = roll(s, 1, 2); expect([s.phase, curId(s)]).toEqual(['turnStart', 'B']); s = make(); own(s, 14, 'B', 2); put(s, 'A', 11); s = roll(s, 1, 2); expect(curId(s)).toBe('B'); s.current = 0; s.phase = 'decide'; s.decision = { pos: 14, canBuy: false, canBuild: null, canTakeover: 80, boughtThisVisit: false }; expect(reject(s, 'A', { type: 'TAKEOVER' })).toBe('인수 규칙을 쓰지 않는 방이에요.'); }); test('인수가가 모자라면 제안하지 않음(빚 갚기로 만들 수 없음)', () => { let s = make({ takeover: true }); own(s, 14, 'B', 2); setCash(s, 'A', 90); put(s, 'A', 11); s = roll(s, 1, 2); expect([cash(s, 'A'), s.phase, curId(s)]).toEqual([66, 'turnStart', 'B']); }); test('독점 승리(옵션): 관광지 4곳 소유 순간 즉시 종료', () => { let s = make({ monopolyWin: true }); for (const p of [5, 15, 25]) own(s, p, 'A'); put(s, 'A', 32); s = roll(s, 1, 2); s = act(s, 'A', { type: 'BUY' }); expect([s.phase, s.final?.reason, s.final?.monopolyType, s.final?.winners]).toEqual(['finished', 'monopoly', 'tour', ['A']]); expect(marble.result(s)!.ranking).toEqual([['A'], ['B']]); }); test('독점 판정: 트리플(그룹 3개)·라인(한 줄 도시 6곳); 옵션이 꺼져 있으면 계속', () => { let s = make({ monopolyWin: true }); for (const p of [...GROUPS.A, ...GROUPS.B, 11, 13]) own(s, p, 'A'); put(s, 'A', 11); s = roll(s, 1, 2); // → 14 로마, completes group C s = act(s, 'A', { type: 'BUY' }); expect(s.final?.monopolyType).toBe('triple'); s = make({ monopolyWin: true }); for (const p of [31, 32, 34, 36, 37]) own(s, p, 'A'); put(s, 'A', 36); s = roll(s, 1, 2); s = act(s, 'A', { type: 'BUY' }); expect(s.final?.monopolyType).toBe('line'); s = make(); for (const p of [5, 15, 25]) own(s, p, 'A'); put(s, 'A', 32); s = roll(s, 1, 2); s = act(s, 'A', { type: 'BUY' }); expect(s.phase).not.toBe('finished'); }); test('라운드 제한 20: 20라운드 마지막 사람 차례 후 총자산 순위, 동점이면 현금 비교', () => { let s = make(); s.round = 20; s.current = 1; // B is the last seat own(s, 39, 'A'); // A: 100 + 40 = 140 setCash(s, 'A', 100); setCash(s, 'B', 140); // B: 140 points, no land put(s, 'B', 0); s = roll(s, 1, 2); // B → 3 마닐라 s = act(s, 'B', PASS); expect([s.phase, s.final?.reason]).toEqual(['finished', 'roundLimit']); expect(s.final!.ranking.map((r) => [r.rank, r.player, r.totalAssets, r.cash])).toEqual([ [1, 'B', 140, 140], [2, 'A', 140, 100], ]); expect(s.final!.summary).toBe('20라운드 끝! 총자산 140P로 1등'); }); test('완전히 같으면 공동 순위; 파산자는 생존자보다 아래(늦게 파산한 사람이 위)', () => { let s = make({ maxRounds: 15 }, 4); s.round = 15; s.current = 3; s.players.B!.alive = false; s.players.B!.outSeq = 1; s.players.B!.cash = 0; s.players.C!.alive = false; s.players.C!.outSeq = 2; s.players.C!.cash = 0; s = roll(s, 1, 2); s = act(s, 'D', PASS); expect(marble.result(s)!.ranking).toEqual([['A', 'D'], ['C'], ['B']]); expect(s.final!.summary).toContain('공동 1등'); }); test('라운드: 선이 파산하면 다음 생존자 차례 뒤에 라운드가 넘어감', () => { let s = make({}, 3); s.players.A!.alive = false; s.players.A!.outSeq = 1; s.current = 1; s = roll(s, 1, 2); s = act(s, 'B', PASS); expect([curId(s), s.round]).toEqual(['C', 1]); s = roll(s, 1, 2); s = act(s, 'C', PASS); expect([curId(s), s.round]).toEqual(['B', 2]); }); }); // ---------------------------------------------------------------- trade describe('교환 (§5.8, 옵션)', () => { const offer = (to: string, give: number[], gc: number, take: number[], tc: number): MarbleAction => ({ type: 'PROPOSE_TRADE', to, give: { lands: give, cash: gc }, take: { lands: take, cash: tc }, }); test('옵션 꺼짐이면 거부; 제안 → 상대 미응답 20초 → 거절', () => { let s = make(); own(s, 1, 'A'); expect(reject(s, 'A', offer('B', [1], 0, [], 10))).toBe('교환 규칙을 쓰지 않는 방이에요.'); s = make({ trade: true }); own(s, 1, 'A'); own(s, 3, 'B', 1); expect(reject(s, 'A', offer('B', [3], 0, [], 0))).toBe('내 땅만 줄 수 있어요.'); expect(reject(s, 'A', offer('B', [], 0, [1], 0))).toBe('그 사람의 땅만 받을 수 있어요.'); expect(reject(s, 'A', offer('B', [], 400, [3], 0))).toBe('가진 포인트보다 많이 줄 수 없어요.'); expect(reject(s, 'A', offer('B', [], 0, [], 0))).toBe('주거나 받을 것을 하나 이상 골라 주세요.'); expect(reject(s, 'A', offer('A', [1], 0, [], 0))).toBe('그 사람에게는 제안할 수 없어요.'); const { state, events } = actE(s, 'A', offer('B', [1], 10, [3], 0)); s = state; expect([s.phase, marble.activePlayers(s), s.deadlineAt! - clock]).toEqual(['tradeResponse', ['B'], 20_000]); expect(events[0]).toMatchObject({ type: 'tradeProposed', from: 'A', to: 'B' }); expect(marble.onTimeout(s, 'B')).toEqual({ type: 'TIMEOUT' }); expect(autoAction(s, 'B')).toEqual({ type: 'TRADE_RESPOND', accept: false }); s = act(s, 'B', { type: 'TIMEOUT' }); expect([s.phase, curId(s), s.owners[1]!.owner, s.owners[3]!.owner]).toEqual(['turnStart', 'A', 'A', 'B']); }); test('수락: 땅(건물째)과 포인트가 오감; 한 차례 2번까지', () => { let s = make({ trade: true }); own(s, 1, 'A'); own(s, 3, 'B', 1); s = act(s, 'A', offer('B', [1], 10, [3], 0)); s = act(s, 'B', { type: 'TRADE_RESPOND', accept: true }); expect([s.owners[1], s.owners[3], cash(s, 'A'), cash(s, 'B')]).toEqual([{ owner: 'B', level: 0 }, { owner: 'A', level: 1 }, 290, 310]); s = act(s, 'A', offer('B', [], 5, [1], 0)); s = act(s, 'B', { type: 'TRADE_RESPOND', accept: false }); expect(reject(s, 'A', offer('B', [], 5, [1], 0))).toBe('이번 차례에는 더 제안할 수 없어요(2번까지).'); expect(reject(s, 'B', { type: 'TRADE_RESPOND', accept: true })).toBe('답할 교환 제안이 없어요.'); }); test('수락 시점에 현금이 모자라면 실패 처리', () => { let s = make({ trade: true }); own(s, 3, 'B'); s = act(s, 'A', offer('B', [], 100, [3], 0)); s.players.A!.cash = 50; // e.g. changed meanwhile s = act(s, 'B', { type: 'TRADE_RESPOND', accept: true }); expect([s.owners[3]!.owner, cash(s, 'A'), cash(s, 'B')]).toEqual(['B', 50, 300]); expect(s.log.at(-1)).toMatchObject({ type: 'tradeResolved', accepted: false, failed: true }); }); }); // ---------------------------------------------------------------- timeout, leaving describe('시간 초과·연결 끊김 (§8.5)', () => { test('turnStart → ROLL, 우주여행 → 출발로, decide → PASS(포인트를 쓰지 않음)', () => { let s = make(); expect(autoAction(s, 'A')).toEqual({ type: 'ROLL' }); s = act(s, 'A', { type: 'TIMEOUT' }, dice(1, 2)); expect([s.players.A!.pos, s.phase]).toEqual([3, 'decide']); s = act(s, 'A', { type: 'TIMEOUT' }); expect([cash(s, 'A'), s.owners[3], curId(s)]).toEqual([300, undefined, 'B']); const t = make(); put(t, 'A', 30); t.players.A!.spacePending = true; expect(autoAction(t, 'A')).toEqual({ type: 'SPACE_TRAVEL', dest: 0 }); }); test('debt → 매각가가 작은 건물부터, 건물이 없으면 땅값 작은 땅부터', () => { let s = make(); own(s, 39, 'B', 3); own(s, 36, 'A', 1); // villa sale 9 own(s, 3, 'A', 2); // building sale 3 own(s, 1, 'A'); // land 6 → 3 own(s, 5, 'A'); // 20 → 10 setCash(s, 'A', 90); put(s, 'A', 36); s = roll(s, 1, 2); expect(s.phase).toBe('debt'); const sold: string[] = []; while (s.phase === 'debt') { const a = autoAction(s, 'A') as { pos: number; what: string }; sold.push(`${a.pos}:${a.what}`); s = act(s, 'A', { type: 'TIMEOUT' }); } expect(sold).toEqual(['3:top', '3:top', '36:top']); expect(cash(s, 'A')).toBe(90 + 3 + 1 + 9 - 100); }); test('세 번 연속 시간 초과 → 자리 비움(5초); 끊긴 사람의 차례도 자동으로 진행되고 통행료는 정상 수령', () => { let s = make(); own(s, 3, 'B', 2); expect(s.deadlineAt).toBe(30_000); s.current = 1; for (let i = 0; i < 3; i++) { put(s, 'B', 20); s = act(s, 'B', { type: 'TIMEOUT' }, dice(1, 2)); // B → 23, decide if (s.phase === 'decide') s = act(s, 'B', { type: 'TIMEOUT' }); s.current = 1; } expect(marble.view(s, 'A').players.find((p) => p.id === 'B')!.away).toBe(true); s.current = 0; s = roll(s, 1, 2); // A → 3, pays B expect(cash(s, 'B')).toBe(309); expect(curId(s)).toBe('B'); expect(s.deadlineAt! - clock).toBe(5000); s = act(s, 'B', { type: 'ROLL' }, dice(1, 2)); expect(s.timeouts.B).toBe(0); }); test('onLeave = CONCEDE: 땅은 은행으로, 차례 넘김; 빚 갚는 중이면 그 빚은 먼저 갚음', () => { let s = make({}, 3); own(s, 1, 'A'); expect(marble.onLeave!(s, 'A')).toEqual({ type: 'CONCEDE' }); s = act(s, 'A', { type: 'CONCEDE' }); expect([s.players.A!.alive, s.owners[1], curId(s), s.ledger.burned]).toEqual([false, undefined, 'B', 303]); expect(marble.onLeave!(s, 'A')).toBeNull(); expect(reject(s, 'A', { type: 'ROLL' })).toBe('이미 게임에서 빠졌어요.'); s = make({}, 3); own(s, 39, 'B', 3); own(s, 36, 'A', 3); setCash(s, 'A', 50); put(s, 'A', 36); s = roll(s, 1, 2); expect(s.phase).toBe('debt'); s = act(s, 'A', { type: 'CONCEDE' }); expect([cash(s, 'B'), s.players.A!.alive, curId(s)]).toEqual([400, false, 'B']); }); test('다른 사람이 기권해도 진행 중인 차례는 그대로; 마지막 1명이 남으면 끝(기권승)', () => { let s = make({}, 3); s = roll(s, 1, 2); s = act(s, 'C', { type: 'CONCEDE' }); expect([s.phase, curId(s)]).toEqual(['decide', 'A']); s = act(s, 'B', { type: 'CONCEDE' }); expect(s.phase).toBe('finished'); const r = marble.result(s)!; expect([r.ranking, r.reason]).toEqual([[['A'], ['B'], ['C']], 'resign']); }); }); // ---------------------------------------------------------------- view / leaks describe('정보 공개 (§8.3)', () => { test('view에 카드 더미 순서와 RNG가 없음, 남은 장 수만; 뽑힌 카드만 ID 공개', () => { let s = marble.setup({ players: ['A', 'B'], options: marble.defaultOptions, rng: SeededRng.fromSeed(seedN(9)), now: 0 }); for (const viewer of ['A', 'B', null]) { const v = marble.view(s, viewer); const json = JSON.stringify(v); expect(v.deckCount).toBe(20); expect('deck' in v).toBe(false); expect(json).not.toMatch(/"L\d\d"/); expect(json).not.toMatch(/rng|seed/i); } s.order = ['A', 'B']; s.current = 0; s.deck = [...CARD_IDS]; put(s, 'A', 5); s = roll(s, 1, 1); // → 7 card L01 const json = JSON.stringify(marble.view(s, 'B')); expect(json).toContain('"L01"'); expect(json).not.toContain('"L02"'); expect(marble.secretsOf!(s, 'A')).not.toContain('L01'); expect(marble.secretsOf!(s, 'A')).toHaveLength(19); }); test('my: 지금 할 수 있는 일과 비용은 본인에게만', () => { let s = make(); s = roll(s, 1, 2); const va = marble.view(s, 'A'); expect(va.my!.decide).toEqual({ pos: 3, buy: 6, build: null, takeover: null, boughtThisVisit: false }); expect(marble.view(s, 'B').my!.decide).toBeNull(); expect(marble.view(s, null).my).toBeNull(); expect(va.players.map((p) => p.seat)).toEqual([0, 1]); }); }); // ---------------------------------------------------------------- random playouts const CONFIGS: { n: number; opts: Partial }[] = [ { n: 2, opts: {} }, { n: 3, opts: { startMoney: 200, maxRounds: 15 } }, { n: 4, opts: { takeover: true, landmark: true, monopolyWin: true } }, { n: 2, opts: { buildOnPurchase: true, islandFee: 10, islandTurns: 1, salary: 10, startMoney: 200 } }, { n: 3, opts: { trade: true, maxRounds: 30, turnSeconds: null } }, { n: 4, opts: { startMoney: 200, takeover: true, trade: true, islandFee: 20, maxRounds: 15 } }, { n: 2, opts: { maxRounds: 0, startMoney: 200, takeover: true, landmark: true } }, { n: 2, opts: { monopolyWin: true, startMoney: 500, maxRounds: 30 } }, ]; function invariant(s: MarbleState): void { const n = s.order.length; const total = Object.values(s.players).reduce((a, p) => a + p.cash, 0) + s.fund; if (total !== s.options.startMoney * n + s.ledger.minted - s.ledger.burned) throw new Error('points bookkeeping broken'); for (const [id, p] of Object.entries(s.players)) { if (!Number.isInteger(p.cash) || p.cash < 0) throw new Error(`bad cash for ${id}: ${p.cash}`); if (!p.alive && (p.cash !== 0 || p.heldCards.length || p.islandLeft || p.spacePending)) throw new Error(`out player ${id} still holds something`); if (p.pos < 0 || p.pos > 39) throw new Error('bad pos'); if (p.islandLeft > 0 && p.pos !== 10) throw new Error('island state off the island'); if (p.spacePending && p.pos !== 30) throw new Error('space state off the space square'); } for (const [k, o] of Object.entries(s.owners)) { const pos = Number(k); if (!PROPERTY_POSITIONS.includes(pos)) throw new Error(`owner on non-property ${pos}`); if (!s.players[o.owner]?.alive) throw new Error(`land ${pos} owned by an out player`); if (o.level < 0 || o.level > maxLevel(s.options)) throw new Error('bad level'); if (SPACES[pos]!.kind === 'tour' && o.level !== 0) throw new Error('building on a tourist spot'); } const cards = [...s.deck, ...Object.values(s.players).flatMap((p) => p.heldCards)].sort(); if (JSON.stringify(cards) !== JSON.stringify(CARD_IDS)) throw new Error('card count broken'); if (s.phase === 'finished') { if (!s.final || s.deadlineAt !== null) throw new Error('finished without result'); return; } if (!s.players[curId(s)]!.alive) throw new Error('turn on an out player'); if (s.options.maxRounds > 0 && s.round > s.options.maxRounds) throw new Error('round limit exceeded'); if (s.phase === 'decide' && !s.decision) throw new Error('decide without decision'); if (s.phase === 'debt' && (!s.debt || !s.players[s.debt.debtor]!.alive || s.players[s.debt.debtor]!.cash >= s.debt.amount)) throw new Error('bad debt phase'); if (s.phase === 'tradeResponse' && !s.pendingTrade) throw new Error('trade phase without trade'); if (s.phase !== 'debt' && s.debt) throw new Error('stale debt'); for (const p of marble.timeoutPlayers!(s)) { if (!marble.validate(s, p, marble.onTimeout(s, p)).ok || !marble.validate(s, p, autoAction(s, p)).ok) throw new Error(`onTimeout illegal in ${s.phase}`); if (marble.legalActions!(s, p).length === 0) throw new Error(`no legal action in ${s.phase}`); } } describe('random playouts', () => { test('1,000 seeds (fast loop): invariants, onTimeout legal everywhere, games end, results rank everyone', () => { let steps = 0; const reasons: Record = {}; for (let i = 1; i <= 1000; i++) { const cfg = CONFIGS[i % CONFIGS.length]!; const options = marble.optionsSchema.parse(cfg.opts); const players = PLAYERS.slice(0, cfg.n); const rng = SeededRng.fromSeed(seedN(i)); const chooser = SeededRng.fromSeed(seedN(i + 77_777)); let s = marble.setup({ players, options, rng, now: 0 }); let k = 0; for (; s.phase !== 'finished' && k < 6000; k++) { const actor = marble.activePlayers(s)[0]!; const legal = marble.legalActions!(s, actor); let a: MarbleAction; const r = chooser.next(); if (r < 0.0015) { // Now and then somebody concedes (any player), or the timer fires. const q = players[chooser.int(players.length)]!; if (marble.validate(s, q, { type: 'CONCEDE' }).ok) { s = marble.apply(s, q, { type: 'CONCEDE' }, { rng, now: k * 1000 }).state; invariant(s); continue; } a = { type: 'TIMEOUT' }; } else if (r < 0.05) a = { type: 'TIMEOUT' }; else a = legal[chooser.int(legal.length)]!; const v = marble.validate(s, actor, a); if (!v.ok) throw new Error(`seed ${i}: ${v.reason} ${JSON.stringify(a)}`); s = marble.apply(s, actor, a, { rng, now: k * 1000 }).state; invariant(s); } if (s.phase !== 'finished') throw new Error(`seed ${i} did not finish`); steps += k; reasons[s.final!.reason] = (reasons[s.final!.reason] ?? 0) + 1; const res = marble.result(s)!; expect(res.ranking.flat().sort()).toEqual([...players].sort()); } expect(steps).toBeGreaterThan(1000 * 50); // Every way of ending shows up. expect(Object.keys(reasons).sort()).toEqual(['lastSurvivor', 'monopoly', 'roundLimit']); }, 60_000); test('engine randomPlayout (round trip, leak check, 5 ms apply), deterministic and replayable', () => { for (let i = 1; i <= 40; i++) { const cfg = CONFIGS[i % CONFIGS.length]!; const options = marble.optionsSchema.parse(cfg.opts); const players = PLAYERS.slice(0, cfg.n); const a = randomPlayout(marble, { players, options, seed: seedN(i), maxSteps: 6000, invariant: (x) => invariant(x as MarbleState), maxApplyMs: 30 }); if (!a.finished) throw new Error(`seed ${i} did not finish`); if (i % 8 === 0) { const b = randomPlayout(marble, { players, options, seed: seedN(i), maxSteps: 6000, maxApplyMs: 30 }); expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState)); const r = SeededRng.fromSeed(seedN(i)); let now = 1_700_000_000_000; let s = marble.setup({ players, options, rng: r, now }); for (const { actor, action } of a.trace) { now += 1000; s = marble.apply(s, actor, action as MarbleAction, { rng: r, now }).state; } expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState)); } } }, 60_000); test('timeout-only games finish (default 20 rounds) and apply is fast (p99 < 5 ms)', () => { const times: number[] = []; for (let i = 1; i <= 60; i++) { const rng = SeededRng.fromSeed(seedN(i + 9000)); let s = marble.setup({ players: PLAYERS, options: marble.defaultOptions, rng, now: 0 }); let n = 0; for (; s.phase !== 'finished' && n < 5000; n++) { const p = marble.timeoutPlayers!(s)[0]!; const t0 = performance.now(); s = marble.apply(s, p, marble.onTimeout(s, p), { rng, now: n }).state; times.push(performance.now() - t0); } expect(s.phase).toBe('finished'); // Timeouts never buy: nobody owns land. expect(Object.keys(s.owners)).toHaveLength(0); } times.sort((a, b) => a - b); expect(times[Math.floor(times.length * 0.99)]!).toBeLessThan(5); }, 60_000); });