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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:31:22 +09:00

1132 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T>(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<MarbleOptions> = {}, 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<string, { meta(): { title?: string } | undefined }> }).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<MarbleOptions> = {}) => {
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<MarbleOptions> = {}, 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<MarbleOptions> }[] = [
{ 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<string, number> = {};
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);
});