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 = {}, 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 => ({ ...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): 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 = {}; 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); 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 } }> }; 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(); }); });