- 규칙 모듈 packages/games/src/splendor: 카드 90장·귀족 10장 데이터 파일 분리(cards.ts), 보석 가져오기(3색/같은 색 2개, allowFewerTokens), 예약(공개/더미, 황금), 구매(자동·수동 지불), 10개 한도 반납, 귀족 방문·선택, 라운드 끝까지 진행 후 종료, 동점은 카드 적은 쪽, 넘기기와 모두 넘기면 종료, 기권, 시간 초과 자동 행동(3번 연속이면 자리 비움), view는 더미·블라인드 예약·제외 귀족을 숨기고 살 수 있는 카드·지불 미리보기를 계산 - 테스트: 문서 10절 시나리오 42개 + 무작위 대국 1,000판(보존·정보 유출·onTimeout 합법·결정성) + 엔진 randomPlayout - 화면 apps/web/src/games/splendor: 자체 카드·귀족·보석 그림(색+모양+글자), 3단계 시장과 더미, 보석 은행, 내 보너스·보석·예약, 상대 요약, 살 수 있는 카드 강조, 반납·귀족 선택, PC/휴대폰·다크 모드 - 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘 - E2E: PC 방장 + 휴대폰 친구가 10점 판을 결과까지 진행 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
754 lines
36 KiB
TypeScript
754 lines
36 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { SeededRng, seedN, type GameEvent, type PlayerId } from '@bg/engine';
|
|
import {
|
|
CARDS,
|
|
COLORS,
|
|
NOBLES,
|
|
TIERS,
|
|
cardOf,
|
|
componentCounts,
|
|
emptyTokens,
|
|
pointsOf,
|
|
splendor,
|
|
splendorActionSchema,
|
|
type Color,
|
|
type SplendorAction,
|
|
type SplendorOptions,
|
|
type SplendorState,
|
|
type Tier,
|
|
type Tokens,
|
|
} from './index';
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
|
|
let clock = 1_000_000;
|
|
const RNG = () => SeededRng.fromSeed([7, 7, 7, 7]);
|
|
|
|
function mk(players = ['A', 'B'], opts: Partial<SplendorOptions> = {}, seed = 1): SplendorState {
|
|
const options = splendor.optionsSchema.parse(opts);
|
|
return splendor.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock });
|
|
}
|
|
|
|
/** Fixes the turn order (setup shuffles it) and gives the turn to the first player. */
|
|
function inOrder(s: SplendorState, order: PlayerId[]): SplendorState {
|
|
s.order = [...order];
|
|
s.current = 0;
|
|
return s;
|
|
}
|
|
|
|
function act(s: SplendorState, who: PlayerId, a: SplendorAction): SplendorState {
|
|
return actE(s, who, a).state;
|
|
}
|
|
function actE(s: SplendorState, who: PlayerId, a: SplendorAction): { state: SplendorState; events: GameEvent[] } {
|
|
const v = splendor.validate(s, who, a);
|
|
if (!v.ok) throw new Error(`${who} ${JSON.stringify(a)} rejected: ${v.reason}`);
|
|
clock += 1000;
|
|
const out = splendor.apply(s, who, a, { rng: RNG(), now: clock });
|
|
conserved(out.state);
|
|
return out;
|
|
}
|
|
function reason(s: SplendorState, who: PlayerId, a: SplendorAction): string {
|
|
const v = splendor.validate(s, who, a);
|
|
if (v.ok) throw new Error(`expected rejection of ${JSON.stringify(a)}`);
|
|
return v.reason;
|
|
}
|
|
const cur = (s: SplendorState) => s.order[s.current]!;
|
|
const tk = (t: Partial<Tokens>): Tokens => ({ ...emptyTokens(), ...t });
|
|
|
|
/** Removes a card from wherever it is (deck or board, refilling the board slot from the deck). */
|
|
function pull(s: SplendorState, id: string): void {
|
|
const tier = cardOf(id).tier;
|
|
const di = s.decks[tier].indexOf(id);
|
|
if (di >= 0) return void s.decks[tier].splice(di, 1);
|
|
const bi = s.board[tier].indexOf(id);
|
|
if (bi < 0) throw new Error(`card ${id} not available`);
|
|
s.board[tier][bi] = s.decks[tier].shift() ?? null;
|
|
}
|
|
function giveCards(s: SplendorState, p: PlayerId, ids: string[]): void {
|
|
for (const id of ids) {
|
|
pull(s, id);
|
|
s.players[p]!.cards.push(id);
|
|
}
|
|
}
|
|
/** Moves tokens supply → player (keeps the totals). */
|
|
function giveTokens(s: SplendorState, p: PlayerId, t: Partial<Tokens>): void {
|
|
for (const [c, n] of Object.entries(t) as [keyof Tokens, number][]) {
|
|
if (s.supply[c] < n) throw new Error(`supply short of ${c}`);
|
|
s.supply[c] -= n;
|
|
s.players[p]!.tokens[c] += n;
|
|
}
|
|
}
|
|
/** Puts card `id` face up at board slot (tier, slot); the card that was there goes back to the deck top. */
|
|
function place(s: SplendorState, id: string, slot = 0): void {
|
|
const tier = cardOf(id).tier;
|
|
pull(s, id);
|
|
const old = s.board[tier][slot];
|
|
if (old) s.decks[tier].unshift(old);
|
|
s.board[tier][slot] = id;
|
|
}
|
|
/** Moves supply tokens of one colour away to a bystander so the supply shows `n`. */
|
|
function setSupply(s: SplendorState, c: Color | 'gold', n: number, holder: PlayerId): void {
|
|
const d = s.supply[c] - n;
|
|
if (d < 0) throw new Error('can only lower the supply');
|
|
s.supply[c] = n;
|
|
s.players[holder]!.tokens[c] += d;
|
|
}
|
|
|
|
function initialTokens(s: SplendorState): Tokens {
|
|
const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7;
|
|
return { W: n, U: n, G: n, R: n, K: n, gold: 5 };
|
|
}
|
|
function conserved(s: SplendorState): void {
|
|
const c = componentCounts(s);
|
|
expect(c.tokens).toEqual(initialTokens(s));
|
|
expect(c.cards).toBe(90);
|
|
expect(c.nobles).toBe(s.order.length + 1);
|
|
for (const p of s.order) for (const k of [...COLORS, 'gold'] as const) expect(s.players[p]!.tokens[k]).toBeGreaterThanOrEqual(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- setup & data
|
|
|
|
describe('셋업 (§4)', () => {
|
|
test.each([
|
|
[2, 4, 3, 36, 26, 16],
|
|
[3, 5, 4, 36, 26, 16],
|
|
[4, 7, 5, 36, 26, 16],
|
|
])('%i players: %i tokens per colour, gold 5, %i nobles, 12 open cards, decks %i/%i/%i', (n, per, nobles, d1, d2, d3) => {
|
|
const s = mk(['A', 'B', 'C', 'D'].slice(0, n));
|
|
expect(s.supply).toEqual({ W: per, U: per, G: per, R: per, K: per, gold: 5 });
|
|
expect(s.nobles).toHaveLength(nobles);
|
|
expect(TIERS.map((t) => s.board[t].filter(Boolean).length)).toEqual([4, 4, 4]);
|
|
expect([s.decks[1].length, s.decks[2].length, s.decks[3].length]).toEqual([d1, d2, d3]);
|
|
expect([...s.order].sort()).toEqual(['A', 'B', 'C', 'D'].slice(0, n));
|
|
expect(s.phase).toBe('action');
|
|
expect(s.deadlineAt).toBe(clock + 60_000);
|
|
conserved(s);
|
|
});
|
|
|
|
test('turn order, nobles and decks come from the seeded RNG', () => {
|
|
const a = mk(['A', 'B', 'C', 'D'], {}, 3);
|
|
const b = mk(['A', 'B', 'C', 'D'], {}, 3);
|
|
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
|
const differs = [4, 5, 6, 7].some((seed) => JSON.stringify(mk(['A', 'B', 'C', 'D'], {}, seed)) !== JSON.stringify(a));
|
|
expect(differs).toBe(true);
|
|
});
|
|
|
|
test('player count is limited to 2~4', () => {
|
|
expect(() => mk(['A'])).toThrow();
|
|
expect(() => mk(['A', 'B', 'C', 'D', 'E'])).toThrow();
|
|
expect([splendor.minPlayers, splendor.maxPlayers]).toEqual([2, 4]);
|
|
});
|
|
});
|
|
|
|
describe('카드 데이터 (§3.1, §3.2)', () => {
|
|
test('90 cards: 40/30/20, 8/6/4 per colour per tier, points as in §3.1', () => {
|
|
expect(CARDS).toHaveLength(90);
|
|
expect(new Set(CARDS.map((c) => c.id)).size).toBe(90);
|
|
const per = (t: Tier) => CARDS.filter((c) => c.tier === t);
|
|
expect(TIERS.map((t) => per(t).length)).toEqual([40, 30, 20]);
|
|
for (const t of TIERS) for (const c of COLORS) expect(per(t).filter((x) => x.bonus === c)).toHaveLength([8, 6, 4][t - 1]!);
|
|
const dist = (t: Tier) => {
|
|
const d: Record<number, number> = {};
|
|
for (const c of per(t)) d[c.points] = (d[c.points] ?? 0) + 1;
|
|
return d;
|
|
};
|
|
expect(dist(1)).toEqual({ 0: 35, 1: 5 });
|
|
expect(dist(2)).toEqual({ 1: 10, 2: 15, 3: 5 });
|
|
expect(dist(3)).toEqual({ 3: 5, 4: 10, 5: 5 });
|
|
});
|
|
|
|
test('the card and noble tables match the spec document row by row (snapshot of §3.1 / §3.2)', () => {
|
|
const doc = readFileSync(join(import.meta.dir, '../../../../docs/games/splendor.md'), 'utf8');
|
|
const num = (x: string) => (x === '·' ? 0 : Number(x));
|
|
const rows = [...doc.matchAll(/^\| ([123][WUGRK]\d\d) \| . \| (\d) \| (.) \| (.) \| (.) \| (.) \| (.) \| (\d+) \|$/gm)];
|
|
expect(rows).toHaveLength(90);
|
|
for (const m of rows) {
|
|
const card = cardOf(m[1]!);
|
|
expect(card.points).toBe(Number(m[2]));
|
|
expect(card.cost).toEqual({ W: num(m[3]!), U: num(m[4]!), G: num(m[5]!), R: num(m[6]!), K: num(m[7]!) });
|
|
expect(COLORS.reduce((a, c) => a + card.cost[c], 0)).toBe(Number(m[8]));
|
|
}
|
|
const nobles = [...doc.matchAll(/^\| (N\d\d) \| (.) \| (.) \| (.) \| (.) \| (.) \|$/gm)];
|
|
expect(nobles).toHaveLength(10);
|
|
expect(NOBLES).toHaveLength(10);
|
|
for (const m of nobles) {
|
|
const n = NOBLES.find((x) => x.id === m[1])!;
|
|
expect(n.points).toBe(3);
|
|
expect(n.req).toEqual({ W: num(m[2]!), U: num(m[3]!), G: num(m[4]!), R: num(m[5]!), K: num(m[6]!) });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- taking tokens
|
|
|
|
describe('보석 가져오기 (§5)', () => {
|
|
test('TAKE_SAME: allowed with 4 in the supply, refused with 3', () => {
|
|
const s = inOrder(mk(), ['A', 'B']);
|
|
const s2 = act(s, 'A', { type: 'TAKE_SAME', color: 'R' });
|
|
expect(s2.players.A!.tokens.R).toBe(2);
|
|
expect(s2.supply.R).toBe(2);
|
|
const t = inOrder(mk(), ['A', 'B']);
|
|
setSupply(t, 'R', 3, 'B');
|
|
expect(reason(t, 'A', { type: 'TAKE_SAME', color: 'R' })).toBe('같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.');
|
|
});
|
|
|
|
test('TAKE_DIFFERENT: duplicates and gold are refused', () => {
|
|
const s = inOrder(mk(), ['A', 'B']);
|
|
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'W', 'U'] })).toBe('서로 다른 색 3개를 골라 주세요.');
|
|
expect(splendorActionSchema.safeParse({ type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'gold'] }).success).toBe(false);
|
|
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'gold' as Color] })).toBe('서로 다른 색 3개를 골라 주세요.');
|
|
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U'] })).toBe('서로 다른 색 3개를 골라 주세요.');
|
|
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'K'] });
|
|
expect(s2.players.A!.tokens).toEqual(tk({ W: 1, U: 1, K: 1 }));
|
|
});
|
|
|
|
test('only 2 colours left: 2 allowed with allowFewerTokens, refused without; 3 left but only 2 taken → refused', () => {
|
|
const s = inOrder(mk(), ['A', 'B']);
|
|
setSupply(s, 'W', 0, 'B');
|
|
setSupply(s, 'U', 0, 'B');
|
|
setSupply(s, 'G', 0, 'B');
|
|
expect(act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] }).players.A!.tokens).toEqual(tk({ R: 1, K: 1 }));
|
|
expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R'] })).toBe('남은 색이 2가지라 2개를 골라 주세요.');
|
|
expect(splendor.view(s, 'A').me!.takeDifferent).toEqual({ available: ['R', 'K'], count: 2 });
|
|
const strict = inOrder(mk(['A', 'B'], { allowFewerTokens: false }), ['A', 'B']);
|
|
for (const c of ['W', 'U', 'G'] as const) setSupply(strict, c, 0, 'B');
|
|
expect(reason(strict, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] })).toBe('서로 다른 색 3개를 가져올 수 없어요.');
|
|
expect(splendor.view(strict, 'A').me!.takeDifferent).toBeNull();
|
|
const three = inOrder(mk(), ['A', 'B']);
|
|
setSupply(three, 'W', 0, 'B');
|
|
setSupply(three, 'U', 0, 'B');
|
|
expect(reason(three, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] })).toBe('서로 다른 색 3개를 골라 주세요.');
|
|
expect(reason(three, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'R', 'K'] })).toBe('남아 있지 않은 색이 있어요.');
|
|
});
|
|
|
|
test('only the current player may act', () => {
|
|
const s = inOrder(mk(), ['A', 'B']);
|
|
expect(reason(s, 'B', { type: 'TAKE_SAME', color: 'W' })).toBe('지금은 내 차례가 아니에요.');
|
|
expect(reason(s, 'Z', { type: 'TAKE_SAME', color: 'W' })).toBe('이 게임의 플레이어가 아니에요.');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- reserving
|
|
|
|
describe('예약 (§5)', () => {
|
|
test('reserving an open card gives a gold token and refills the slot from the deck', () => {
|
|
const s = inOrder(mk(), ['A', 'B']);
|
|
const id = s.board[2][1]!;
|
|
const top = s.decks[2][0]!;
|
|
const { state, events } = actE(s, 'A', { type: 'RESERVE', from: { tier: 2, slot: 1 } });
|
|
expect(state.players.A!.reserved).toEqual([{ cardId: id, blind: false }]);
|
|
expect(state.players.A!.tokens.gold).toBe(1);
|
|
expect(state.board[2][1]).toBe(top);
|
|
expect(events.find((e) => e.type === 'reserved')).toMatchObject({ cardId: id, gold: true });
|
|
expect(events.find((e) => e.type === 'refilled')).toMatchObject({ tier: 2, slot: 1, cardId: top });
|
|
});
|
|
|
|
test('a fourth reservation is refused; with no gold left reserving still works (without gold)', () => {
|
|
let s = inOrder(mk(), ['A', 'B']);
|
|
for (let i = 0; i < 3; i++) {
|
|
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, slot: 0 } });
|
|
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
if (s.phase === 'discard') s = act(s, 'B', { type: 'TIMEOUT' });
|
|
}
|
|
expect(s.players.A!.reserved).toHaveLength(3);
|
|
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 1, slot: 0 } })).toBe('예약은 3장까지만 할 수 있어요.');
|
|
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } })).toBe('예약은 3장까지만 할 수 있어요.');
|
|
const t = inOrder(mk(), ['A', 'B']);
|
|
setSupply(t, 'gold', 0, 'B');
|
|
const t2 = act(t, 'A', { type: 'RESERVE', from: { tier: 3, slot: 0 } });
|
|
expect(t2.players.A!.reserved).toHaveLength(1);
|
|
expect(t2.players.A!.tokens.gold).toBe(0);
|
|
expect(splendor.view(t, 'A').me!.goldOnReserve).toBe(false);
|
|
});
|
|
|
|
test('a blind deck reservation: only the owner sees the card; others and spectators get the tier only; events carry no id', () => {
|
|
const s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
|
const id = s.decks[3][0]!;
|
|
const { state, events } = actE(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } });
|
|
expect(state.players.A!.reserved).toEqual([{ cardId: id, blind: true }]);
|
|
expect(state.board[3]).toEqual(s.board[3]);
|
|
expect(splendor.view(state, 'A').players.A!.reserved[0]).toMatchObject({ id, hidden: false, blind: true });
|
|
for (const viewer of ['B', 'C', null]) {
|
|
const v = splendor.view(state, viewer);
|
|
expect(v.players.A!.reserved).toEqual([{ hidden: true, tier: 3 }]);
|
|
expect(v.players.A!.reservedCount).toBe(1);
|
|
expect(JSON.stringify(v)).not.toContain(`"${id}"`);
|
|
}
|
|
expect(JSON.stringify(events)).not.toContain(`"${id}"`);
|
|
expect(events.find((e) => e.type === 'reserved')).toMatchObject({ tier: 3, cardId: null });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- buying
|
|
|
|
/** §5.1 position: bonuses W2 U0 G1 R0 K1, tokens W0 U2 G1 R0 K0 gold 1. */
|
|
function example(): SplendorState {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
giveCards(s, 'A', ['1W07', '1W06', '1G07', '1K07']); // bonuses W2 G1 K1, 0 points
|
|
giveTokens(s, 'A', { U: 2, G: 1, gold: 1 });
|
|
return s;
|
|
}
|
|
|
|
describe('구매 (§5, §5.1)', () => {
|
|
test('example 1: 2W04 (R5 K3) needs 7 tokens → cannot buy', () => {
|
|
const s = example();
|
|
place(s, '2W04', 0);
|
|
expect(reason(s, 'A', { type: 'BUY', from: { tier: 2, slot: 0 } })).toBe('보석이 부족해요.');
|
|
expect(splendor.view(s, 'A').me!.buy['b2-0']).toBeUndefined();
|
|
});
|
|
|
|
test('example 2: 1U05 (W1 K2) → W covered by bonus, K 1 paid with gold; gold 0 afterwards', () => {
|
|
const s = example();
|
|
place(s, '1U05', 2);
|
|
const v = splendor.view(s, 'A').me!;
|
|
expect(v.buy['b1-2']).toMatchObject({ gold: 1, total: 1, need: { W: 0, U: 0, G: 0, R: 0, K: 1 } });
|
|
const { state, events } = actE(s, 'A', { type: 'BUY', from: { tier: 1, slot: 2 } });
|
|
expect(state.players.A!.tokens.gold).toBe(0);
|
|
expect(state.players.A!.cards).toContain('1U05');
|
|
expect(state.supply.gold).toBe(5);
|
|
expect(events.find((e) => e.type === 'bought')).toMatchObject({ cardId: '1U05', paid: { gold: 1 } });
|
|
});
|
|
|
|
test('bonuses that cover the whole cost: the card is free', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
giveCards(s, 'A', ['1U07', '1U06', '1U01']); // three blue bonuses
|
|
place(s, '1W07', 0); // cost U3
|
|
const s2 = act(s, 'A', { type: 'BUY', from: { tier: 1, slot: 0 } });
|
|
expect(s2.players.A!.tokens).toEqual(emptyTokens());
|
|
expect(s2.players.A!.cards).toContain('1W07');
|
|
});
|
|
|
|
test('manual payment: gold instead of colour tokens is allowed; paying more than needed is refused', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
giveTokens(s, 'A', { R: 3, gold: 2 });
|
|
place(s, '1G07', 1); // cost R3
|
|
const slot = { tier: 1 as const, slot: 1 };
|
|
const s2 = act(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 1, gold: 2 }) });
|
|
expect(s2.players.A!.tokens).toEqual(tk({ R: 2 }));
|
|
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 3, gold: 1 }) })).toBe('필요한 것보다 많이 낼 수는 없어요.');
|
|
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 2 }) })).toBe('보석이 부족해요.');
|
|
expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 1, U: 1, gold: 1 }) })).toBe('보석이 부족해요.');
|
|
});
|
|
|
|
test('buying a reserved card reveals it; the board is not refilled', () => {
|
|
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
pull(s, '1R07'); // cost W3
|
|
s.decks[1].unshift('1R07');
|
|
const id = '1R07';
|
|
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, deck: true } });
|
|
s = act(s, 'B', { type: 'TAKE_SAME', color: 'G' });
|
|
giveTokens(s, 'A', { W: 3 });
|
|
const board = JSON.stringify(s.board);
|
|
const { state, events } = actE(s, 'A', { type: 'BUY', from: { reservedIndex: 0 } });
|
|
expect(state.players.A!.cards).toEqual([id]);
|
|
expect(state.players.A!.reserved).toEqual([]);
|
|
expect(JSON.stringify(state.board)).toBe(board);
|
|
expect(events.find((e) => e.type === 'bought')).toMatchObject({ cardId: id, fromReserve: true });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- token limit
|
|
|
|
describe('토큰 한도 (§5)', () => {
|
|
test('9 tokens + 3 → discard phase; anything but exactly 10 left is refused', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
|
|
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
|
|
expect(s2.phase).toBe('discard');
|
|
expect(cur(s2)).toBe('A');
|
|
expect(splendor.view(s2, 'A').me!.discardCount).toBe(2);
|
|
expect(reason(s2, 'A', { type: 'TAKE_SAME', color: 'U' })).toBe('먼저 보석을 10개가 되도록 돌려놓아 주세요.');
|
|
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ W: 1 }) })).toBe('보석이 10개가 되도록 2개를 골라 주세요.');
|
|
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ W: 3 }) })).toBe('보석이 10개가 되도록 2개를 골라 주세요.');
|
|
expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ gold: 2 }) })).toBe('가진 것보다 많이 돌려놓을 수 없어요.');
|
|
// A token taken just now may go back.
|
|
const s3 = act(s2, 'A', { type: 'DISCARD', tokens: tk({ R: 1, K: 1 }) });
|
|
expect(s3.players.A!.tokens).toEqual(tk({ W: 4, U: 3, G: 3 }));
|
|
expect(cur(s3)).toBe('B');
|
|
expect(s3.phase).toBe('action');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- nobles
|
|
|
|
describe('귀족 (§5)', () => {
|
|
test('two nobles satisfied at once → choose one; the other comes automatically at the end of the next turn', () => {
|
|
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
s.nobles = ['N06', 'N07', 'N10']; // U3 G3 R3 / W3 U3 G3 / G3 R3 K3
|
|
// Keep the noble total (3 open for 2 players): swap in from whatever setup chose.
|
|
giveCards(s, 'A', ['1W07', '1W06', '1W05', '1U07', '1U06', '1U05', '1G07', '1G06']); // W3 U3 G2
|
|
place(s, '1G05', 0); // cost W2 U1 → free with bonuses; makes G3 → N07 (W3 U3 G3)
|
|
giveCards(s, 'A', ['1R07', '1R06', '1R05']); // R3 → with U3 G3 also N06
|
|
const { state, events } = actE(s, 'A', { type: 'BUY', from: { tier: 1, slot: 0 } });
|
|
expect(events.some((e) => e.type === 'nobleVisited')).toBe(false);
|
|
expect(state.phase).toBe('noble');
|
|
expect(state.pendingNobleChoices).toEqual(['N06', 'N07']);
|
|
expect(splendor.view(state, 'A').me!.nobleChoices).toEqual(['N06', 'N07']);
|
|
expect(reason(state, 'A', { type: 'CHOOSE_NOBLE', nobleId: 'N10' })).toBe('고를 수 없는 귀족이에요.');
|
|
s = act(state, 'A', { type: 'CHOOSE_NOBLE', nobleId: 'N07' });
|
|
expect(s.players.A!.nobles).toEqual(['N07']);
|
|
expect(pointsOf(s.players.A!)).toBe(3);
|
|
expect(cur(s)).toBe('B');
|
|
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
const out = actE(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'K'] });
|
|
expect(out.state.players.A!.nobles).toEqual(['N07', 'N06']);
|
|
expect(out.events.find((e) => e.type === 'nobleVisited')).toMatchObject({ player: 'A', nobleId: 'N06' });
|
|
expect(out.state.nobles).toEqual(['N10']);
|
|
});
|
|
|
|
test('hints: bonuses still missing per noble (only with showHints)', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
s.nobles = ['N01', 'N06', 'N10'];
|
|
giveCards(s, 'A', ['1G07', '1R07']);
|
|
expect(splendor.view(s, 'A').me!.nobleNeed).toEqual({ N01: 6, N06: 7, N10: 7 });
|
|
const off = inOrder(mk(['A', 'B'], { showHints: false }), ['A', 'B']);
|
|
expect(splendor.view(off, 'A').me!.nobleNeed).toEqual({});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- end & ranking
|
|
|
|
describe('종료와 순위 (§6)', () => {
|
|
test('the 2nd player reaches 15 → the 3rd and 4th still play, then the game ends', () => {
|
|
let s = inOrder(mk(['A', 'B', 'C', 'D']), ['A', 'B', 'C', 'D']);
|
|
giveCards(s, 'B', ['3W04', '3U04', '3G04']); // 15 points, bonuses W1 U1 G1
|
|
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
const out = actE(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
expect(out.events.find((e) => e.type === 'endTriggered')).toMatchObject({ player: 'B', points: 15 });
|
|
s = out.state;
|
|
expect(s.endTriggered).toBe(true);
|
|
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
expect(s.phase).toBe('action');
|
|
expect(cur(s)).toBe('D');
|
|
s = act(s, 'D', { type: 'TAKE_DIFFERENT', colors: ['R', 'U', 'K'] });
|
|
expect(s.phase).toBe('finished');
|
|
const r = splendor.result(s)!;
|
|
expect(r.ranking[0]).toEqual(['B']);
|
|
expect(r.reason).toBe('normal');
|
|
expect(r.summary).toBe('1위 15점 (카드 3장) · 2위 0점');
|
|
});
|
|
|
|
test('the player just before the first player reaches 15 → the game ends at once', () => {
|
|
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
|
giveCards(s, 'C', ['3W04', '3U04', '3G04']);
|
|
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['R', 'U', 'K'] });
|
|
expect(s.phase).toBe('finished');
|
|
expect(s.turnsTaken).toEqual({ A: 1, B: 1, C: 1 });
|
|
});
|
|
|
|
test('targetPoints=10 ends at 10 points', () => {
|
|
let s = inOrder(mk(['A', 'B'], { targetPoints: 10 }), ['A', 'B']);
|
|
giveCards(s, 'B', ['3W04', '3U04']);
|
|
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
expect(s.phase).toBe('finished');
|
|
});
|
|
|
|
test('ties: same points → fewer cards wins; same points and cards → shared rank (A 16/14, B 16/12, C 15)', () => {
|
|
const s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
|
const zeros = CARDS.filter((c) => c.tier === 1 && c.points === 0).map((c) => c.id);
|
|
giveCards(s, 'A', ['3W04', '3U04', '3G04', '1W08', ...zeros.slice(0, 10)]);
|
|
giveCards(s, 'B', ['3R04', '3K04', '3W03', '1U08', '1G08', ...zeros.slice(10, 17)]);
|
|
giveCards(s, 'C', ['3U03', '3G03', '3R03', '2W06']);
|
|
expect(['A', 'B', 'C'].map((p) => [pointsOf(s.players[p]!), s.players[p]!.cards.length])).toEqual([
|
|
[16, 14],
|
|
[16, 12],
|
|
[15, 4],
|
|
]);
|
|
s.phase = 'finished';
|
|
s.endReason = 'target';
|
|
expect(splendor.result(s)!.ranking).toEqual([['B'], ['A'], ['C']]);
|
|
expect(splendor.view(s, null).result!.ranking.map((x) => [x.player, x.rank])).toEqual([
|
|
['B', 1],
|
|
['A', 2],
|
|
['C', 3],
|
|
]);
|
|
giveCards(s, 'B', zeros.slice(17, 19)); // B: 16 points, 14 cards → same as A
|
|
const r = splendor.result(s)!;
|
|
expect(r.ranking).toEqual([['A', 'B'], ['C']]);
|
|
expect(r.summary).toBe('공동 1위 2명, 16점 (카드 14장)');
|
|
expect(splendor.view(s, 'A').result!.winners).toEqual(['A', 'B']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- pass, empty decks
|
|
|
|
describe('넘기기와 더미 소진 (§5)', () => {
|
|
/**
|
|
* Nobody can do anything: all colour tokens are held (10 each), both have 3 reservations they can't
|
|
* afford and the board is empty (its cards went back under the decks).
|
|
*/
|
|
function stuck(): SplendorState {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
const reserve = (p: PlayerId, ids: string[]) => {
|
|
for (const id of ids) {
|
|
pull(s, id);
|
|
s.players[p]!.reserved.push({ cardId: id, blind: false });
|
|
}
|
|
};
|
|
reserve('A', ['3W02', '3U02', '3G02']); // K7, W7, U7
|
|
reserve('B', ['3R02', '3K02', '3U04']); // G7, R7, W7 U3
|
|
giveTokens(s, 'A', { W: 4, U: 4, G: 2 });
|
|
giveTokens(s, 'B', { G: 2, R: 4, K: 4 });
|
|
for (const t of TIERS) {
|
|
for (const id of s.board[t]) if (id) s.decks[t].push(id);
|
|
s.board[t] = [null, null, null, null];
|
|
}
|
|
return s;
|
|
}
|
|
|
|
test('PASS is refused while any action is possible and allowed only when none is', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
expect(reason(s, 'A', { type: 'PASS' })).toBe('할 수 있는 행동이 있어요.');
|
|
expect(splendor.view(s, 'A').me!.canPass).toBe(false);
|
|
const t = stuck();
|
|
expect(splendor.view(t, 'A').me!.canPass).toBe(true);
|
|
expect(splendor.legalActions!(t, 'A')).toEqual([{ type: 'PASS' }]);
|
|
const t2 = act(t, 'A', { type: 'PASS' });
|
|
expect(cur(t2)).toBe('B');
|
|
expect(t2.passStreak).toBe(1);
|
|
expect(t2.phase).toBe('action');
|
|
// With one token back in the supply A could take it, so passing is refused again.
|
|
const u = stuck();
|
|
u.players.B!.tokens.K--;
|
|
u.supply.K++;
|
|
expect(reason(u, 'A', { type: 'PASS' })).toBe('할 수 있는 행동이 있어요.');
|
|
});
|
|
|
|
test('a full round of passes ends the game (nothing can change any more)', () => {
|
|
let s = act(stuck(), 'A', { type: 'PASS' });
|
|
expect(splendor.onTimeout(s, 'B')).toEqual({ type: 'TIMEOUT' });
|
|
s = act(s, 'B', { type: 'TIMEOUT' }); // automatic move is a pass as well
|
|
expect(s.phase).toBe('finished');
|
|
expect(s.endReason).toBe('stalemate');
|
|
expect(splendor.result(s)!.summary).toMatch(/^더 할 수 있는 행동이 없어 끝났어요\./);
|
|
});
|
|
|
|
test('empty deck: the board slot stays empty after a buy, and reserving from the empty deck is refused', () => {
|
|
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
// Move the rest of the tier-3 deck out of reach: B "owns" them as bought cards (keeps the 90 total).
|
|
s.players.B!.cards.push(...s.decks[3].splice(0));
|
|
const id = s.board[3][2]!;
|
|
giveTokens(s, 'A', { gold: 5 });
|
|
const need = COLORS.reduce((a, c) => a + cardOf(id).cost[c], 0);
|
|
giveTokens(s, 'A', Object.fromEntries(COLORS.map((c) => [c, Math.min(cardOf(id).cost[c], s.supply[c])])) as Partial<Tokens>);
|
|
expect(need).toBeGreaterThan(0);
|
|
expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } })).toBe('그 단계 더미에 카드가 없어요.');
|
|
expect(splendor.view(s, 'A').me!.reserveDecks).toEqual([1, 2]);
|
|
s = act(s, 'A', { type: 'BUY', from: { tier: 3, slot: 2 } });
|
|
if (s.phase === 'discard') s = act(s, 'A', { type: 'TIMEOUT' });
|
|
expect(s.board[3][2]).toBeNull();
|
|
expect(splendor.view(s, null).board[3][2]).toBeNull();
|
|
expect(reason(s, 'B', { type: 'BUY', from: { tier: 3, slot: 2 } })).toBe('그 자리에 카드가 없어요.');
|
|
expect(reason(s, 'B', { type: 'RESERVE', from: { tier: 3, slot: 2 } })).toBe('그 자리에 카드가 없어요.');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- timeouts
|
|
|
|
describe('시간 초과와 자리 비움 (§8.5)', () => {
|
|
test('TIMEOUT is system-only (not in the action schema) and legal for the current player', () => {
|
|
const s = mk();
|
|
expect(splendorActionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false);
|
|
expect(splendor.onTimeout(s, cur(s))).toEqual({ type: 'TIMEOUT' });
|
|
expect(splendor.validate(s, cur(s), { type: 'TIMEOUT' }).ok).toBe(true);
|
|
});
|
|
|
|
test('affordable card → automatic buy (most points, then fewest tokens)', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
place(s, '1W08', 0); // G4, 1 point
|
|
place(s, '1U07', 1); // K3, 0 points
|
|
giveTokens(s, 'A', { G: 4, K: 3 });
|
|
const { state, events } = actE(s, 'A', { type: 'TIMEOUT' });
|
|
expect(state.players.A!.cards).toEqual(['1W08']);
|
|
expect(events.some((e) => e.type === 'timeoutAuto')).toBe(true);
|
|
expect(state.log.at(-1)).toMatchObject({ k: 'buy', cardId: '1W08', auto: true });
|
|
});
|
|
|
|
test('nothing affordable → 3 different colours with the most supply (ties W,U,G,R,K)', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
setSupply(s, 'W', 2, 'B');
|
|
const s2 = act(s, 'A', { type: 'TIMEOUT' });
|
|
expect(s2.players.A!.tokens).toEqual(tk({ U: 1, G: 1, R: 1 }));
|
|
});
|
|
|
|
test('timeouts in discard and noble phases finish the turn so the game never stalls', () => {
|
|
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
|
|
s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
|
|
expect(s.phase).toBe('discard');
|
|
s = act(s, 'A', { type: 'TIMEOUT' });
|
|
// Most held first (W 4), then ties K,R,G,U,W: G (3) before U (3).
|
|
expect(s.players.A!.tokens).toEqual(tk({ W: 3, U: 3, G: 2, R: 1, K: 1 }));
|
|
expect(cur(s)).toBe('B');
|
|
|
|
let t = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
t.nobles = ['N06', 'N07', 'N10'];
|
|
giveCards(t, 'A', ['1W07', '1W06', '1W05', '1U07', '1U06', '1U05', '1G07', '1G06', '1G05', '1R07', '1R06', '1R05']);
|
|
t = act(t, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
expect(t.phase).toBe('noble');
|
|
t = act(t, 'A', { type: 'TIMEOUT' });
|
|
expect(t.players.A!.nobles).toEqual(['N06']); // smallest id
|
|
expect(cur(t)).toBe('B');
|
|
});
|
|
|
|
test('an automatic action that goes over 10 tokens also returns them in the same timeout', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
giveTokens(s, 'A', { W: 2, U: 2, G: 2, R: 2, K: 2 });
|
|
for (const t of [1, 2] as const) {
|
|
for (const id of s.board[t]) if (id) s.decks[t].push(id);
|
|
s.board[t] = [null, null, null, null]; // only tier-3 cards left: none affordable with 2 of each
|
|
}
|
|
const s2 = act(s, 'A', { type: 'TIMEOUT' });
|
|
expect(s2.log.find((e) => e.k === 'take')).toMatchObject({ auto: true });
|
|
expect(s2.log.find((e) => e.k === 'discard')).toMatchObject({ auto: true });
|
|
expect(cur(s2)).toBe('B');
|
|
expect(componentCounts(s2).tokens).toEqual(initialTokens(s2));
|
|
expect(Object.values(s2.players.A!.tokens).reduce((a, b) => a + b, 0)).toBe(10);
|
|
});
|
|
|
|
test('3 timeouts in a row → away (short deadline); acting again clears it', () => {
|
|
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
for (let i = 0; i < 3; i++) {
|
|
s = act(s, 'A', { type: 'TIMEOUT' });
|
|
s = act(s, 'B', { type: 'RESERVE', from: { tier: 1, slot: 0 } });
|
|
}
|
|
expect(splendor.view(s, 'B').players.A!.away).toBe(true);
|
|
expect(s.deadlineAt).toBe(clock + 5000);
|
|
expect(s.log.some((e) => e.k === 'away' && e.p === 'A')).toBe(true);
|
|
s = act(s, 'A', { type: 'RESERVE', from: { tier: 2, slot: 0 } });
|
|
if (s.phase === 'discard') s = act(s, 'A', { type: 'DISCARD', tokens: tk({ gold: 1 }) });
|
|
expect(splendor.view(s, 'B').players.A!.away).toBe(false);
|
|
expect(s.deadlineAt).toBe(clock + 60_000);
|
|
});
|
|
|
|
test('discard / noble phases get 15 extra seconds; no time limit → no deadline', () => {
|
|
const s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
giveTokens(s, 'A', { W: 3, U: 3, G: 3 });
|
|
const before = s.deadlineAt!;
|
|
const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] });
|
|
expect(s2.deadlineAt).toBe(before + 15_000);
|
|
const free = mk(['A', 'B'], { turnSeconds: null });
|
|
expect(free.deadlineAt).toBeNull();
|
|
expect(splendor.deadline!(free)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- concede / leave
|
|
|
|
describe('기권 (§8.2)', () => {
|
|
test('3 players: tokens go back, reservations leave the game, cards stay for the result (ranked last)', () => {
|
|
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
|
giveCards(s, 'B', ['3W04']);
|
|
s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, deck: true } });
|
|
giveTokens(s, 'B', { R: 2 });
|
|
s = act(s, 'B', { type: 'RESERVE', from: { tier: 2, slot: 0 } });
|
|
expect(splendor.onLeave!(s, 'B')).toEqual({ type: 'CONCEDE' });
|
|
s = act(s, 'B', { type: 'CONCEDE' });
|
|
expect(s.players.B!.tokens).toEqual(emptyTokens());
|
|
expect(s.players.B!.reserved).toEqual([]);
|
|
expect(s.players.B!.cards).toEqual(['3W04']);
|
|
expect(s.removed).toHaveLength(1);
|
|
expect(cur(s)).toBe('C');
|
|
s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] });
|
|
expect(cur(s)).toBe('A'); // B is skipped
|
|
expect(reason(s, 'B', { type: 'CONCEDE' })).toBe('이미 기권했어요.');
|
|
expect(splendor.onLeave!(s, 'B')).toBeNull();
|
|
});
|
|
|
|
test('the current player concedes → next player; 2 players → the game ends, the other one wins', () => {
|
|
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
|
s = act(s, 'A', { type: 'CONCEDE' });
|
|
expect(cur(s)).toBe('B');
|
|
let t = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
giveCards(t, 'A', ['3W04']);
|
|
t = act(t, 'A', { type: 'CONCEDE' });
|
|
expect(t.phase).toBe('finished');
|
|
const r = splendor.result(t)!;
|
|
expect(r.ranking).toEqual([['B'], ['A']]);
|
|
expect(r.reason).toBe('resign');
|
|
expect(r.summary).toMatch(/^다른 사람이 모두 기권했어요\./);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------- info hiding, determinism, options
|
|
|
|
describe('정보 공개 (§8.3)', () => {
|
|
test('views and events never contain deck contents, unused nobles, blind reservations of others or an rng', () => {
|
|
let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']);
|
|
const allEvents: GameEvent[] = [];
|
|
const step = (who: PlayerId, a: SplendorAction) => {
|
|
const out = actE(s, who, a);
|
|
allEvents.push(...out.events);
|
|
s = out.state;
|
|
};
|
|
step('A', { type: 'RESERVE', from: { tier: 1, deck: true } });
|
|
step('B', { type: 'RESERVE', from: { tier: 2, deck: true } });
|
|
step('C', { type: 'RESERVE', from: { tier: 3, deck: true } });
|
|
const blind = { A: s.players.A!.reserved[0]!.cardId, B: s.players.B!.reserved[0]!.cardId, C: s.players.C!.reserved[0]!.cardId };
|
|
for (const viewer of ['A', 'B', 'C', null] as const) {
|
|
const json = JSON.stringify(splendor.view(s, viewer));
|
|
for (const [owner, id] of Object.entries(blind)) {
|
|
if (owner === viewer) expect(json).toContain(`"${id}"`);
|
|
else expect(json).not.toContain(`"${id}"`);
|
|
}
|
|
for (const t of TIERS) for (const id of s.decks[t]) expect(json).not.toContain(`"${id}"`);
|
|
const used = new Set(s.nobles);
|
|
for (const n of NOBLES) if (!used.has(n.id)) expect(json).not.toContain(`"${n.id}"`);
|
|
expect(json).not.toMatch(/"rng"|"decks"/);
|
|
}
|
|
const ev = JSON.stringify(allEvents);
|
|
for (const id of Object.values(blind)) expect(ev).not.toContain(`"${id}"`);
|
|
// After the game all reservations are shown.
|
|
s.phase = 'finished';
|
|
s.endReason = 'target';
|
|
const res = splendor.view(s, null);
|
|
expect(res.result!.revealedReserved).toEqual({ A: [blind.A], B: [blind.B], C: [blind.C] });
|
|
expect(res.players.A!.reserved[0]).toMatchObject({ id: blind.A, hidden: false });
|
|
});
|
|
|
|
test('secretsOf: own blind reservations + every deck card + the unused nobles', () => {
|
|
let s = inOrder(mk(['A', 'B']), ['A', 'B']);
|
|
s = act(s, 'A', { type: 'RESERVE', from: { tier: 2, deck: true } });
|
|
const sec = splendor.secretsOf!(s, 'A');
|
|
expect(sec).toContain(s.players.A!.reserved[0]!.cardId);
|
|
expect(sec.filter((x) => x.startsWith('N'))).toHaveLength(7);
|
|
expect(sec.length).toBe(1 + s.decks[1].length + s.decks[2].length + s.decks[3].length + 7);
|
|
});
|
|
});
|
|
|
|
describe('결정성과 옵션', () => {
|
|
test('replay: same seed + same actions → same states and events', () => {
|
|
const play = () => {
|
|
let s = mk(['A', 'B', 'C'], {}, 42);
|
|
const log: string[] = [];
|
|
for (let i = 0; i < 60 && s.phase !== 'finished'; i++) {
|
|
const p = cur(s);
|
|
const legal = splendor.legalActions!(s, p);
|
|
const a = legal[(i * 7) % legal.length]!;
|
|
const out = splendor.apply(s, p, a, { rng: RNG(), now: 5000 + i });
|
|
log.push(JSON.stringify(out.events));
|
|
s = out.state;
|
|
}
|
|
return JSON.stringify(s) + log.join('|');
|
|
};
|
|
expect(play()).toBe(play());
|
|
});
|
|
|
|
test('every option has a Korean title (and labels for choices); defaults follow §2', () => {
|
|
expect(splendor.defaultOptions).toEqual({ targetPoints: 15, allowFewerTokens: true, turnSeconds: 60, showHints: true });
|
|
const shape = splendor.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string; labels?: Record<string, string> } }> };
|
|
for (const [key, field] of Object.entries(shape.shape)) {
|
|
const m = field.meta();
|
|
expect(m?.title, key).toMatch(/[가-힣]/);
|
|
if (key === 'targetPoints' || key === 'turnSeconds') expect(Object.keys(m.labels ?? {}).length).toBeGreaterThan(1);
|
|
}
|
|
expect(splendor.optionsSchema.safeParse({ targetPoints: 12 }).success).toBe(false);
|
|
expect(splendor.optionsSchema.parse({ turnSeconds: null }).turnSeconds).toBeNull();
|
|
});
|
|
});
|