- 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>
1132 lines
48 KiB
TypeScript
1132 lines
48 KiB
TypeScript
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);
|
||
});
|