Merge branch 'm7/splendor' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-05 14:56:12 +09:00
10 changed files with 3322 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
'halli-galli': () => import('./halli-galli'), 'halli-galli': () => import('./halli-galli'),
marble: () => import('./marble'), marble: () => import('./marble'),
dalmuti: () => import('./dalmuti'), dalmuti: () => import('./dalmuti'),
splendor: () => import('./splendor'),
}; };
const cache = new Map<string, GameUI>(); const cache = new Map<string, GameUI>();

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ export const GAME_ICON: Record<string, string> = {
bang: '🤠', bang: '🤠',
'halli-galli': '🔔', 'halli-galli': '🔔',
dalmuti: '👑', dalmuti: '👑',
splendor: '💎',
}; };
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */

View File

@@ -325,6 +325,97 @@ async function dalmutiStep(p: Page, isHost: boolean, st: Record<string, number>)
return 'play'; return 'play';
} }
/**
* 보석 상인: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when
* asked, otherwise buy a highlighted card, reserve now and then, take 2 of a colour, or 3 different gems.
* Returns the kind of action or null when it isn't this page's turn.
*/
async function splendorStep(p: Page, stats: Record<string, number>, turn: number): Promise<string | null> {
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.startsWith('내 차례예요')) return null;
const phone = (p.viewportSize()?.width ?? 0) < 600;
const press = async (l: ReturnType<Page['locator']>) => {
if (phone) await l.tap({ timeout: 2000 });
else if (turn % 2 === 0) {
// Keyboard path on PC every other turn: focus + Enter.
await l.focus({ timeout: 2000 });
await p.keyboard.press('Enter');
} else await l.click({ timeout: 2000 });
};
const panel = p.locator('.sp-panel');
if (status.includes('돌려놓아')) {
const need = Number(status.match(/(\d+)개를 돌려놓아/)?.[1] ?? 0);
for (let i = 0; i < need; i++) await press(panel.locator('.sp-basket').first().locator('button.sp-token').first());
await press(panel.getByRole('button', { name: `돌려놓기 (${need}/${need})` }));
return 'discard';
}
if (status.includes('귀족 중 한 명')) {
await press(panel.getByRole('button', { name: /이 귀족 고르기$/ }).first());
return 'noble';
}
if (status.includes('넘겨야')) {
await press(panel.getByRole('button', { name: '차례 넘기기' }));
const ok = p.getByRole('button', { name: '넘기기', exact: true });
if (await ok.isVisible({ timeout: 500 }).catch(() => false)) await ok.click();
return 'pass';
}
const affordable = p.locator('.sp-market button.sp-card.can, .sp-market button.sp-card.gold, .sp-mine button.sp-card.can, .sp-mine button.sp-card.gold');
const reserveTurn = (stats.reserve ?? 0) < 2 && turn >= 2 && turn % 5 === 2;
// The phone player hoards gems early on (no buying) until it has had to give some back once.
const hoard = phone && !stats.discard && turn < 30;
if ((await affordable.count()) && !reserveTurn && !hoard) {
const card = affordable.first();
await press(card);
if ((await p.locator('.sp-card.gold.sel').count()) && !stats.shotPay) {
stats.shotPay = 1;
await p.waitForTimeout(250);
await p.screenshot({ path: join(SHOTS, `splendor-pay-${phone ? 'phone' : 'pc'}.png`), fullPage: phone });
}
await press(panel.getByRole('button', { name: '🛒 사기' }));
return 'buy';
}
const reserveBtn = panel.getByRole('button', { name: /^📌 예약/ });
if (reserveTurn) {
// Tier 1 open card on even turns, the tier 2 deck (blind) otherwise.
const target = (stats.reserve ?? 0) === 0 ? p.locator('.sp-market .sp-row').nth(2).locator('button.sp-card').first() : p.locator('.sp-market .sp-row').nth(1).locator('button.sp-deck');
await press(target);
if (await reserveBtn.isEnabled({ timeout: 800 }).catch(() => false)) {
await press(reserveBtn);
return 'reserve';
}
await press(panel.getByRole('button', { name: '닫기' }));
}
const tokens = p.locator('.sp-bank button.sp-token:not([disabled])');
const counts = await tokens.evaluateAll((els) => els.map((e) => Number(e.querySelector('.ct')?.textContent ?? 0)));
const four = counts.findIndex((n) => n >= 4);
if ((stats.same ?? 0) < 2 && four >= 0 && turn % 3 === 1) {
await press(tokens.nth(four));
await press(tokens.nth(four));
await press(panel.getByRole('button', { name: '💎 가져오기 (2개)' }));
return 'same';
}
const want = Number((await panel.locator('.sp-basket').innerText()).match(/서로 다른 색 (\d)개/)?.[1] ?? 3);
let picked = 0;
for (let i = 0; i < counts.length && picked < want; i++) {
if (counts[i]! > 0) {
await press(tokens.nth(i));
picked++;
}
}
const takeBtn = panel.getByRole('button', { name: /^💎 가져오기/ });
if (picked > 0 && (await takeBtn.isEnabled().catch(() => false))) {
await press(takeBtn);
return 'different';
}
// Nothing to take: reserve whatever is first, or buy anything.
await press(p.locator('.sp-market button.sp-card').first());
if (await reserveBtn.isEnabled({ timeout: 500 }).catch(() => false)) {
await press(reserveBtn);
return 'reserve';
}
return null;
}
const PLANS: Record<string, Plan> = { const PLANS: Record<string, Plan> = {
rummikub: { rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -1008,6 +1099,62 @@ const PLANS: Record<string, Plan> = {
console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`); console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`);
}, },
}, },
splendor: {
// PC host + phone guest, short game (10 points) through the UI to the result: 3 different gems, 2 of a
// colour, reserve an open card and a blind deck card (gold), buy highlighted cards (with gold too),
// return tokens over 10 and choose nobles when it happens. Mid-game shots in light and dark.
lobby: async (host) => {
await host.getByRole('group', { name: '끝나는 점수' }).getByRole('button', { name: '10점(짧은 판)' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^10점 · 한 차례 60초/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const stats: Record<string, number> = {};
let turns = 0;
let shot = false;
const until = Date.now() + 480_000;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
while (Date.now() < until && !(await over())) {
let acted: string | null = null;
for (const p of pages) {
const before = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
acted = await splendorStep(p, stats, turns);
if (acted) {
stats[acted] = (stats[acted] ?? 0) + 1;
turns++;
// Wait until the server answered (status or log changed).
await p
.waitForFunction((b) => (document.querySelector('.turn-status')?.textContent ?? '').trim() !== b || !!document.querySelector('[role="dialog"]'), before, { timeout: 5000 })
.catch(() => {});
break;
}
}
if (turns >= 14 && !shot) {
shot = true;
await host.waitForTimeout(700);
await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-dark.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await host.waitForTimeout(150);
}
if (!(await over())) throw new Error(`game did not finish (${turns} UI turns: ${JSON.stringify(stats)})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'splendor-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-result-phone.png') });
if (turns < 15 || !stats.buy || !stats.reserve || !stats.different || !stats.same || !stats.discard) throw new Error(`too few kinds of turns: ${JSON.stringify(stats)}`);
console.log(` splendor: finished after ${turns} UI turns ${JSON.stringify(stats)}`);
},
},
}; };
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);

View File

@@ -17,6 +17,7 @@ import { bang } from './bang';
import { halliGalli } from './halli-galli'; import { halliGalli } from './halli-galli';
import { marble } from './marble'; import { marble } from './marble';
import { dalmuti } from './dalmuti'; import { dalmuti } from './dalmuti';
import { splendor } from './splendor';
export const GAMES: Record<string, AnyGameDefinition> = { export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok, [omok.id]: omok,
@@ -37,4 +38,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[halliGalli.id]: halliGalli, [halliGalli.id]: halliGalli,
[marble.id]: marble, [marble.id]: marble,
[dalmuti.id]: dalmuti, [dalmuti.id]: dalmuti,
[splendor.id]: splendor,
}; };

View File

@@ -0,0 +1,76 @@
/**
* 보석 상인 component data (docs/games/splendor.md §3.1, §3.2). Kept in a separate data file so the card
* list can be swapped for an own list with the same structure (§12). Encoding: `<id>:<points>:<W U G R K cost digits>`.
*/
export const COLORS = ['W', 'U', 'G', 'R', 'K'] as const;
export type Color = (typeof COLORS)[number];
export type TokenColor = Color | 'gold';
export const TOKEN_COLORS: readonly TokenColor[] = [...COLORS, 'gold'];
export type Gems = Record<Color, number>;
export type Tokens = Record<TokenColor, number>;
export type Tier = 1 | 2 | 3;
export const TIERS: readonly Tier[] = [1, 2, 3];
export interface DevCard {
id: string;
tier: Tier;
bonus: Color;
points: number;
cost: Gems;
}
export interface Noble {
id: string;
points: 3;
req: Gems;
}
const RAW_CARDS = [
// tier 1
'1W01:0:01111', '1W02:0:01211', '1W03:0:02201', '1W04:0:31001', '1W05:0:00021', '1W06:0:02002', '1W07:0:03000', '1W08:1:00400',
'1U01:0:10111', '1U02:0:10121', '1U03:0:10220', '1U04:0:01310', '1U05:0:10002', '1U06:0:00202', '1U07:0:00003', '1U08:1:00040',
'1G01:0:11011', '1G02:0:11012', '1G03:0:01022', '1G04:0:13100', '1G05:0:21000', '1G06:0:02020', '1G07:0:00030', '1G08:1:00004',
'1R01:0:11101', '1R02:0:21101', '1R03:0:20102', '1R04:0:10013', '1R05:0:02100', '1R06:0:20020', '1R07:0:30000', '1R08:1:40000',
'1K01:0:11110', '1K02:0:12110', '1K03:0:22010', '1K04:0:00131', '1K05:0:00210', '1K06:0:20200', '1K07:0:00300', '1K08:1:04000',
// tier 2
'2W01:1:00322', '2W02:1:23030', '2W03:2:00142', '2W04:2:00053', '2W05:2:00050', '2W06:3:60000', '2U01:1:02230', '2U02:1:02303',
'2U03:2:53000', '2U04:2:20014', '2U05:2:05000', '2U06:3:06000', '2G01:1:30230', '2G02:1:23002', '2G03:2:42001', '2G04:2:05300',
'2G05:2:00500', '2G06:3:00600', '2R01:1:20023', '2R02:1:03023', '2R03:2:14200', '2R04:2:30005', '2R05:2:00005', '2R06:3:00060',
'2K01:1:32200', '2K02:1:30302', '2K03:2:01420', '2K04:2:00530', '2K05:2:50000', '2K06:3:00006',
// tier 3
'3W01:3:03353', '3W02:4:00007', '3W03:4:30036', '3W04:5:30007', '3U01:3:30335', '3U02:4:70000', '3U03:4:63003', '3U04:5:73000',
'3G01:3:53033', '3G02:4:07000', '3G03:4:36300', '3G04:5:07300', '3R01:3:35303', '3R02:4:00700', '3R03:4:03630', '3R04:5:00730',
'3K01:3:33530', '3K02:4:00070', '3K03:4:00363', '3K04:5:00073',
];
const RAW_NOBLES = [
'N01:00440', 'N02:04400', 'N03:44000', 'N04:40004', 'N05:00044', 'N06:03330', 'N07:33300', 'N08:33003', 'N09:30033', 'N10:00333',
];
const gems = (digits: string): Gems => ({ W: +digits[0]!, U: +digits[1]!, G: +digits[2]!, R: +digits[3]!, K: +digits[4]! });
export const CARDS: readonly DevCard[] = RAW_CARDS.map((raw) => {
const [id, points, cost] = raw.split(':') as [string, string, string];
return { id, tier: Number(id[0]) as Tier, bonus: id[1] as Color, points: Number(points), cost: gems(cost) };
});
export const NOBLES: readonly Noble[] = RAW_NOBLES.map((raw) => {
const [id, req] = raw.split(':') as [string, string];
return { id, points: 3, req: gems(req) };
});
const CARD_BY_ID = new Map(CARDS.map((c) => [c.id, c]));
const NOBLE_BY_ID = new Map(NOBLES.map((n) => [n.id, n]));
export function cardOf(id: string): DevCard {
const c = CARD_BY_ID.get(id);
if (!c) throw new Error(`unknown card ${id}`);
return c;
}
export function nobleOf(id: string): Noble {
const n = NOBLE_BY_ID.get(id);
if (!n) throw new Error(`unknown noble ${id}`);
return n;
}
export const isCardId = (id: string): boolean => CARD_BY_ID.has(id);
export const emptyGems = (): Gems => ({ W: 0, U: 0, G: 0, R: 0, K: 0 });
export const emptyTokens = (): Tokens => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0 });
export const sumTokens = (t: Partial<Record<TokenColor, number>>): number => TOKEN_COLORS.reduce((a, c) => a + (t[c] ?? 0), 0);

View File

@@ -0,0 +1,968 @@
/**
* 보석 상인 (스플렌더 방식, docs/games/splendor.md). Pure rules module: all randomness is in setup
* (turn order, nobles, decks), state is plain JSON, the view hides deck contents and blind reservations.
*/
import { z } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine';
import {
CARDS,
COLORS,
NOBLES,
TIERS,
TOKEN_COLORS,
cardOf,
emptyGems,
emptyTokens,
nobleOf,
sumTokens,
type Color,
type DevCard,
type Gems,
type Noble,
type Tier,
type TokenColor,
type Tokens,
} from './cards';
export * from './cards';
const lit = <T extends number | null>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const splendorOptionsSchema = z.object({
targetPoints: lit(15, 10)
.default(15)
.meta({ title: '끝나는 점수', labels: { 15: '15점(원작)', 10: '10점(짧은 판)' } }),
allowFewerTokens: z.boolean().default(true).meta({ title: '남은 색이 3가지보다 적으면 서로 다른 색 2개나 1개만 가져오기 허용' }),
turnSeconds: lit(30, 60, 90, null)
.default(60)
.meta({ title: '한 차례 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', null: '제한 없음' } }),
showHints: z.boolean().default(true).meta({ title: '살 수 있는 카드와 귀족까지 남은 보너스 보여 주기' }),
});
export type SplendorOptions = z.infer<typeof splendorOptionsSchema>;
const colorSchema = z.enum(COLORS);
const tierSchema = lit(1, 2, 3);
const count = z.number().int().min(0).max(10);
const tokensSchema = z.object({ W: count, U: count, G: count, R: count, K: count, gold: count });
export const splendorActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('TAKE_DIFFERENT'), colors: z.array(colorSchema).min(1).max(3) }),
z.object({ type: z.literal('TAKE_SAME'), color: colorSchema }),
z.object({
type: z.literal('RESERVE'),
from: z.union([z.object({ tier: tierSchema, slot: z.number().int().min(0).max(3) }), z.object({ tier: tierSchema, deck: z.literal(true) })]),
}),
z.object({
type: z.literal('BUY'),
from: z.union([z.object({ tier: tierSchema, slot: z.number().int().min(0).max(3) }), z.object({ reservedIndex: z.number().int().min(0).max(2) })]),
payment: tokensSchema.optional(),
}),
z.object({ type: z.literal('PASS') }),
z.object({ type: z.literal('DISCARD'), tokens: tokensSchema }),
z.object({ type: z.literal('CHOOSE_NOBLE'), nobleId: z.string().max(8) }),
z.object({ type: z.literal('CONCEDE') }),
]);
/** Client actions plus the system-only timeout action (not in the schema, docs/09 §2). */
export type SplendorAction = z.infer<typeof splendorActionSchema> | { type: 'TIMEOUT' };
type PlayAction = Extract<SplendorAction, { type: 'TAKE_DIFFERENT' | 'TAKE_SAME' | 'RESERVE' | 'BUY' | 'PASS' }>;
export type BuyFrom = Extract<SplendorAction, { type: 'BUY' }>['from'];
export type ReserveFrom = Extract<SplendorAction, { type: 'RESERVE' }>['from'];
export const TOKEN_LIMIT = 10;
export const MAX_RESERVED = 3;
export const BOARD_SLOTS = 4;
/** Consecutive automatic turns before a player is shown as "자리 비움" (§8.5). */
export const AWAY_AFTER = 3;
/** Turn length for away players. */
export const AWAY_MS = 5000;
/** Extra time when the turn goes on to returning tokens or choosing a noble (§8.5). */
export const EXTRA_PHASE_MS = 15_000;
const LOG_MAX = 20;
export interface PlayerState {
tokens: Tokens;
/** Bought development cards. */
cards: string[];
/** `blind` = taken face down from a deck (only the owner sees it). */
reserved: { cardId: string; blind: boolean }[];
nobles: string[];
}
export type LogEntry =
| { n: number; k: 'take'; p: PlayerId; tokens: Partial<Tokens>; auto: boolean }
| { n: number; k: 'reserve'; p: PlayerId; tier: Tier; cardId: string | null; gold: boolean; auto: boolean }
| { n: number; k: 'buy'; p: PlayerId; cardId: string; paid: Partial<Tokens>; fromReserve: boolean; auto: boolean }
| { n: number; k: 'pass'; p: PlayerId; auto: boolean }
| { n: number; k: 'discard'; p: PlayerId; tokens: Partial<Tokens>; auto: boolean }
| { n: number; k: 'noble'; p: PlayerId; nobleId: string }
| { n: number; k: 'concede'; p: PlayerId }
| { n: number; k: 'away'; p: PlayerId }
| { n: number; k: 'end'; p: PlayerId; points: number };
export interface SplendorState {
options: SplendorOptions;
/** Turn order, order[0] = first player (shuffled at setup). */
order: PlayerId[];
current: number;
phase: 'action' | 'discard' | 'noble' | 'finished';
/** Somebody reached the target: the round is played to its end (§6). */
endTriggered: boolean;
endReason: 'target' | 'stalemate' | 'resign' | null;
supply: Tokens;
/** Hidden, index 0 = top. */
decks: Record<Tier, string[]>;
/** Four open slots per tier, null = empty (deck ran out). */
board: Record<Tier, (string | null)[]>;
/** Open nobles still waiting for a visit. */
nobles: string[];
players: Record<PlayerId, PlayerState>;
pendingNobleChoices: string[] | null;
turnsTaken: Record<PlayerId, number>;
turnSeq: number;
deadlineAt: number | null;
conceded: PlayerId[];
/** Reserved cards of players who conceded (out of the game). */
removed: { cardId: string; blind: boolean }[];
timeoutsInRow: Record<PlayerId, number>;
/** Consecutive PASS turns; a full round of passes ends the game (nothing can change any more). */
passStreak: number;
logSeq: number;
log: LogEntry[];
}
export type ReservedView = { hidden: true; tier: Tier } | (DevCard & { hidden: false; blind: boolean });
export interface BuyOption {
/** Automatic payment (colour tokens first, gold only for the rest). */
payment: Tokens;
/** Tokens per colour still needed after bonuses. */
need: Gems;
gold: number;
total: number;
}
export interface SplendorPlayerView {
id: PlayerId;
tokens: Tokens;
tokenCount: number;
bonuses: Gems;
cards: string[];
points: number;
nobles: string[];
reservedCount: number;
reserved: ReservedView[];
conceded: boolean;
away: boolean;
turnsTaken: number;
}
export interface SplendorMeView {
id: PlayerId;
myTurn: boolean;
/** Action phase only: the colours that can be picked for "different colours" and how many to pick. */
takeDifferent: { available: Color[]; count: number } | null;
takeSame: Color[];
canReserve: boolean;
reserveDecks: Tier[];
goldOnReserve: boolean;
/** Affordable cards for me, keyed `b<tier>-<slot>` (board) or `r<index>` (my reservations). */
buy: Record<string, BuyOption>;
canPass: boolean;
/** Discard phase: how many tokens must go back. */
discardCount: number;
nobleChoices: string[] | null;
/** Hints: bonuses still missing per open noble (empty when hints are off). */
nobleNeed: Record<string, number>;
}
export interface SplendorRankRow {
rank: number;
player: PlayerId;
points: number;
cards: number;
nobles: number;
bonuses: Gems;
conceded: boolean;
}
export interface SplendorResult {
ranking: SplendorRankRow[];
winners: PlayerId[];
revealedReserved: Record<PlayerId, string[]>;
summary: string;
reason: GameResult['reason'];
endReason: SplendorState['endReason'];
}
export interface SplendorView {
options: SplendorOptions;
phase: SplendorState['phase'];
order: PlayerId[];
current: PlayerId | null;
turnSeq: number;
deadlineAt: number | null;
/** Full length of the running timer (for the ring). */
turnMs: number;
endTriggered: boolean;
supply: Tokens;
board: Record<Tier, (DevCard | null)[]>;
deckCounts: Record<Tier, number>;
nobles: Noble[];
players: Record<PlayerId, SplendorPlayerView>;
me: SplendorMeView | null;
log: LogEntry[];
result: SplendorResult | null;
}
// ---------------------------------------------------------------- helpers
export const tokensPerColor = (players: number): number => (players <= 2 ? 4 : players === 3 ? 5 : 7);
export function bonusesOf(ps: PlayerState): Gems {
const b = emptyGems();
for (const id of ps.cards) b[cardOf(id).bonus]++;
return b;
}
export function pointsOf(ps: PlayerState): number {
return ps.cards.reduce((a, id) => a + cardOf(id).points, 0) + ps.nobles.length * 3;
}
/** Tokens per colour still needed for `card` after my bonuses. */
export function needFor(card: DevCard, bonuses: Gems): Gems {
const n = emptyGems();
for (const c of COLORS) n[c] = Math.max(0, card.cost[c] - bonuses[c]);
return n;
}
/** §5 "색 토큰 먼저, 부족분만 황금"; null when I can't afford the card. */
export function autoPayment(card: DevCard, ps: PlayerState, bonuses = bonusesOf(ps)): Tokens | null {
const need = needFor(card, bonuses);
const pay = emptyTokens();
let short = 0;
for (const c of COLORS) {
pay[c] = Math.min(need[c], ps.tokens[c]);
short += need[c] - pay[c];
}
if (short > ps.tokens.gold) return null;
pay.gold = short;
return pay;
}
/** A manual payment: never more than I hold, no colour overpaid, gold covers exactly the rest. */
export function paymentProblem(card: DevCard, ps: PlayerState, payment: Tokens, bonuses = bonusesOf(ps)): string | null {
const need = needFor(card, bonuses);
let short = 0;
for (const c of TOKEN_COLORS) {
const v = payment[c];
if (!Number.isInteger(v) || v < 0) return '지불 내역이 올바르지 않아요.';
if (v > ps.tokens[c]) return '보석이 부족해요.';
}
for (const c of COLORS) {
if (payment[c] > need[c]) return '필요한 것보다 많이 낼 수는 없어요.';
short += need[c] - payment[c];
}
if (payment.gold > short) return '필요한 것보다 많이 낼 수는 없어요.';
if (payment.gold < short) return '보석이 부족해요.';
return null;
}
const satisfies = (bonuses: Gems, req: Gems) => COLORS.every((c) => bonuses[c] >= req[c]);
const tokenCount = (ps: PlayerState) => sumTokens(ps.tokens);
const isActive = (s: SplendorState, p: PlayerId) => !s.conceded.includes(p);
const activePlayersOf = (s: SplendorState) => s.order.filter((p) => isActive(s, p));
const currentPlayer = (s: SplendorState): PlayerId | null => (s.phase === 'finished' ? null : s.order[s.current]!);
const compact = (t: Tokens): Partial<Tokens> => {
const out: Partial<Tokens> = {};
for (const c of TOKEN_COLORS) if (t[c]) out[c] = t[c];
return out;
};
const buyKey = (from: BuyFrom) => ('reservedIndex' in from ? `r${from.reservedIndex}` : `b${from.tier}-${from.slot}`);
/** Colours that still have tokens, and how many "different colours" must be taken (0 = not possible). */
function differentRule(s: SplendorState): { available: Color[]; count: number } {
const available = COLORS.filter((c) => s.supply[c] > 0);
if (available.length >= 3) return { available, count: 3 };
return { available, count: s.options.allowFewerTokens ? available.length : 0 };
}
function cardAt(s: SplendorState, p: PlayerId, from: BuyFrom): { id: string; reserved: boolean } | null {
if ('reservedIndex' in from) {
const r = s.players[p]!.reserved[from.reservedIndex];
return r ? { id: r.cardId, reserved: true } : null;
}
const id = s.board[from.tier]?.[from.slot];
return id ? { id, reserved: false } : null;
}
/** Every card I could buy right now (board tier 1→3, left to right, then my reservations). */
function buyables(s: SplendorState, p: PlayerId): { from: BuyFrom; card: DevCard; payment: Tokens }[] {
const ps = s.players[p]!;
const bonuses = bonusesOf(ps);
const out: { from: BuyFrom; card: DevCard; payment: Tokens }[] = [];
for (const tier of TIERS) {
s.board[tier].forEach((id, slot) => {
if (!id) return;
const card = cardOf(id);
const payment = autoPayment(card, ps, bonuses);
if (payment) out.push({ from: { tier, slot }, card, payment });
});
}
ps.reserved.forEach((r, i) => {
const card = cardOf(r.cardId);
const payment = autoPayment(card, ps, bonuses);
if (payment) out.push({ from: { reservedIndex: i }, card, payment });
});
return out;
}
function reserveTargets(s: SplendorState, p: PlayerId): ReserveFrom[] {
if (s.players[p]!.reserved.length >= MAX_RESERVED) return [];
const out: ReserveFrom[] = [];
for (const tier of TIERS) s.board[tier].forEach((id, slot) => id && out.push({ tier, slot }));
for (const tier of TIERS) if (s.decks[tier].length) out.push({ tier, deck: true });
return out;
}
/** Can the player do any of the four actions (§5 "행동 불가")? */
function canAct(s: SplendorState, p: PlayerId): boolean {
if (differentRule(s).count > 0) return true;
if (COLORS.some((c) => s.supply[c] >= 4)) return true;
if (reserveTargets(s, p).length) return true;
return buyables(s, p).length > 0;
}
function deadlineFor(s: SplendorState, p: PlayerId, now: number): number | null {
if (s.options.turnSeconds === null) return null;
if ((s.timeoutsInRow[p] ?? 0) >= AWAY_AFTER) return now + AWAY_MS;
return now + s.options.turnSeconds * 1000;
}
type DistOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
function pushLog(s: SplendorState, e: DistOmit<LogEntry, 'n'>): void {
s.log.push({ ...e, n: ++s.logSeq } as LogEntry);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
}
function refill(s: SplendorState, tier: Tier, slot: number, events: GameEvent[]): void {
const next = s.decks[tier].shift() ?? null;
s.board[tier][slot] = next;
events.push({ type: 'refilled', tier, slot, cardId: next });
}
// ---------------------------------------------------------------- turn steps (mutate a cloned state)
function doPlay(s: SplendorState, p: PlayerId, a: PlayAction, auto: boolean, events: GameEvent[]): void {
const ps = s.players[p]!;
s.passStreak = a.type === 'PASS' ? s.passStreak + 1 : 0;
switch (a.type) {
case 'TAKE_DIFFERENT':
case 'TAKE_SAME': {
const got = emptyTokens();
const colors = a.type === 'TAKE_SAME' ? [a.color, a.color] : a.colors;
for (const c of colors) {
if (s.supply[c] <= 0) throw new Error('supply empty');
s.supply[c]--;
ps.tokens[c]++;
got[c]++;
}
events.push({ type: 'tookTokens', player: p, tokens: compact(got) });
pushLog(s, { k: 'take', p, tokens: compact(got), auto });
break;
}
case 'RESERVE': {
if (ps.reserved.length >= MAX_RESERVED) throw new Error('reserve limit');
let cardId: string;
const blind = 'deck' in a.from;
if ('deck' in a.from) {
const top = s.decks[a.from.tier].shift();
if (!top) throw new Error('deck empty');
cardId = top;
} else {
const id = s.board[a.from.tier][a.from.slot];
if (!id) throw new Error('empty slot');
cardId = id;
}
ps.reserved.push({ cardId, blind });
const gold = s.supply.gold > 0;
if (gold) {
s.supply.gold--;
ps.tokens.gold++;
}
events.push({ type: 'reserved', player: p, tier: a.from.tier, cardId: blind ? null : cardId, gold });
pushLog(s, { k: 'reserve', p, tier: a.from.tier, cardId: blind ? null : cardId, gold, auto });
if (!blind && 'slot' in a.from) refill(s, a.from.tier, a.from.slot, events);
break;
}
case 'BUY': {
const at = cardAt(s, p, a.from);
if (!at) throw new Error('no card');
const card = cardOf(at.id);
const payment = a.payment ?? autoPayment(card, ps);
if (!payment || paymentProblem(card, ps, payment)) throw new Error('cannot pay');
for (const c of TOKEN_COLORS) {
ps.tokens[c] -= payment[c];
s.supply[c] += payment[c];
}
ps.cards.push(at.id);
if ('reservedIndex' in a.from) ps.reserved.splice(a.from.reservedIndex, 1);
events.push({ type: 'bought', player: p, cardId: at.id, paid: compact(payment), fromReserve: at.reserved });
pushLog(s, { k: 'buy', p, cardId: at.id, paid: compact(payment), fromReserve: at.reserved, auto });
if (!('reservedIndex' in a.from)) refill(s, a.from.tier, a.from.slot, events);
break;
}
case 'PASS':
events.push({ type: 'passed', player: p });
pushLog(s, { k: 'pass', p, auto });
break;
}
}
function doDiscard(s: SplendorState, p: PlayerId, tokens: Tokens, auto: boolean, events: GameEvent[]): void {
const ps = s.players[p]!;
for (const c of TOKEN_COLORS) {
if (tokens[c] > ps.tokens[c]) throw new Error('discard more than held');
ps.tokens[c] -= tokens[c];
s.supply[c] += tokens[c];
}
if (tokenCount(ps) !== TOKEN_LIMIT) throw new Error('discard must leave exactly 10');
events.push({ type: 'discarded', player: p, tokens: compact(tokens) });
pushLog(s, { k: 'discard', p, tokens: compact(tokens), auto });
}
function visitNoble(s: SplendorState, p: PlayerId, nobleId: string, events: GameEvent[]): void {
const i = s.nobles.indexOf(nobleId);
if (i < 0) throw new Error('noble not open');
s.nobles.splice(i, 1);
s.players[p]!.nobles.push(nobleId);
events.push({ type: 'nobleVisited', player: p, nobleId });
pushLog(s, { k: 'noble', p, nobleId });
}
/** After an action (or a discard): token limit → noble → end of turn (§8.2 "차례 마무리 순서"). */
function continueTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[], afterDiscard = false): void {
const ps = s.players[p]!;
if (!afterDiscard && tokenCount(ps) > TOKEN_LIMIT) {
s.phase = 'discard';
if (s.deadlineAt !== null) s.deadlineAt = Math.max(s.deadlineAt, now) + EXTRA_PHASE_MS;
return;
}
const bonuses = bonusesOf(ps);
const eligible = s.nobles.filter((id) => satisfies(bonuses, nobleOf(id).req)).sort();
if (eligible.length === 1) visitNoble(s, p, eligible[0]!, events);
else if (eligible.length > 1) {
s.phase = 'noble';
s.pendingNobleChoices = eligible;
if (s.deadlineAt !== null) s.deadlineAt = Math.max(s.deadlineAt, now) + EXTRA_PHASE_MS;
return;
}
endTurn(s, p, now, events);
}
function endTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
s.turnsTaken[p] = (s.turnsTaken[p] ?? 0) + 1;
s.pendingNobleChoices = null;
const pts = pointsOf(s.players[p]!);
if (!s.endTriggered && pts >= s.options.targetPoints) {
s.endTriggered = true;
events.push({ type: 'endTriggered', player: p, points: pts });
pushLog(s, { k: 'end', p, points: pts });
}
advance(s, now, events);
}
function finish(s: SplendorState, reason: NonNullable<SplendorState['endReason']>, events: GameEvent[]): void {
s.phase = 'finished';
s.endReason = reason;
s.deadlineAt = null;
s.pendingNobleChoices = null;
events.push({ type: 'gameOver', reason });
}
/** Next active seat; the game ends after the last seat of a triggered round, on a full round of passes, or with one player left. */
function advance(s: SplendorState, now: number, events: GameEvent[]): void {
const active = activePlayersOf(s);
if (s.order.length > 1 ? active.length <= 1 : active.length === 0) return finish(s, 'resign', events);
const n = s.order.length;
const next = nextSeat(s.current, n, (i) => !isActive(s, s.order[i]!));
if (s.endTriggered && next <= s.current) return finish(s, 'target', events);
if (s.passStreak >= active.length) return finish(s, 'stalemate', events);
s.current = next;
s.phase = 'action';
s.pendingNobleChoices = null;
s.turnSeq++;
s.deadlineAt = deadlineFor(s, s.order[next]!, now);
events.push({ type: 'turnChanged', player: s.order[next]! });
}
function concede(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
const ps = s.players[p]!;
for (const c of TOKEN_COLORS) {
s.supply[c] += ps.tokens[c];
ps.tokens[c] = 0;
}
s.removed.push(...ps.reserved);
ps.reserved = [];
s.conceded.push(p);
events.push({ type: 'conceded', player: p });
pushLog(s, { k: 'concede', p });
const wasCurrent = s.order[s.current] === p;
const active = activePlayersOf(s);
if (s.order.length > 1 ? active.length <= 1 : active.length === 0) return finish(s, 'resign', events);
if (wasCurrent) {
// Their turn ends without an action; passes so far still count only for players who are in.
s.passStreak = 0;
advance(s, now, events);
}
}
// ---------------------------------------------------------------- automatic play (§8.5)
/** Deterministic default move: best buy → 3 different (most supply) → 2 same → reserve → pass. */
export function autoPlay(s: SplendorState, p: PlayerId): PlayAction {
const buys = buyables(s, p);
if (buys.length) {
let best = buys[0]!;
for (const b of buys) {
const better = b.card.points > best.card.points || (b.card.points === best.card.points && sumTokens(b.payment) < sumTokens(best.payment));
if (better) best = b;
}
return { type: 'BUY', from: best.from };
}
const rule = differentRule(s);
if (rule.count > 0) {
// Stable sort keeps W,U,G,R,K order among equal supplies.
const colors = [...rule.available].sort((a, b) => s.supply[b] - s.supply[a]).slice(0, rule.count);
return { type: 'TAKE_DIFFERENT', colors };
}
const same = [...COLORS].sort((a, b) => s.supply[b] - s.supply[a]).find((c) => s.supply[c] >= 4);
if (same) return { type: 'TAKE_SAME', color: same };
const res = reserveTargets(s, p)[0];
if (res) return { type: 'RESERVE', from: res };
return { type: 'PASS' };
}
const DISCARD_ORDER: Color[] = ['K', 'R', 'G', 'U', 'W'];
/** One token at a time from the colour I hold most of (ties K,R,G,U,W), gold last. */
export function autoDiscard(ps: PlayerState): Tokens {
const left = { ...ps.tokens };
const out = emptyTokens();
let extra = sumTokens(left) - TOKEN_LIMIT;
while (extra > 0) {
let pick: TokenColor = 'gold';
let most = 0;
for (const c of DISCARD_ORDER) {
if (left[c] > most) {
most = left[c];
pick = c;
}
}
left[pick]--;
out[pick]++;
extra--;
}
return out;
}
function autoTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void {
if (s.phase === 'action') {
doPlay(s, p, autoPlay(s, p), true, events);
continueTurn(s, p, now, events);
}
// Discard and noble choices of an automatic (or abandoned) turn are resolved right away.
for (let guard = 0; guard < 3 && s.phase !== 'finished' && s.order[s.current] === p; guard++) {
if (s.phase === 'discard') {
doDiscard(s, p, autoDiscard(s.players[p]!), true, events);
continueTurn(s, p, now, events, true);
} else if (s.phase === 'noble') {
visitNoble(s, p, [...s.pendingNobleChoices!].sort()[0]!, events);
endTurn(s, p, now, events);
} else break;
}
}
// ---------------------------------------------------------------- result
function rankRows(s: SplendorState): SplendorRankRow[] {
const rows = s.order.map((p) => {
const ps = s.players[p]!;
return { player: p, points: pointsOf(ps), cards: ps.cards.length, nobles: ps.nobles.length, bonuses: bonusesOf(ps), conceded: s.conceded.includes(p) };
});
// Conceded players rank last; then points high → fewer cards (§6).
rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.points - a.points || a.cards - b.cards);
const out: SplendorRankRow[] = [];
rows.forEach((r, i) => {
const prev = out[i - 1];
const tied = prev && prev.conceded === r.conceded && prev.points === r.points && prev.cards === r.cards;
out.push({ ...r, rank: tied ? prev.rank : i + 1 });
});
return out;
}
function buildResult(s: SplendorState): SplendorResult {
const ranking = rankRows(s);
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
const top = ranking[0]!;
const detail = (r: SplendorRankRow) => `${r.points}점 (카드 ${r.cards}장${r.nobles ? `, 귀족 ${r.nobles}` : ''})`;
let summary: string;
if (winners.length > 1) summary = `공동 1위 ${winners.length}명, ${detail(top)}`;
else if (ranking.length > 1) summary = `1위 ${detail(top)} · 2위 ${ranking[1]!.points}점`;
else summary = `${detail(top)}`;
if (s.endReason === 'resign') summary = `다른 사람이 모두 기권했어요. ${summary}`;
if (s.endReason === 'stalemate') summary = `더 할 수 있는 행동이 없어 끝났어요. ${summary}`;
const revealedReserved: Record<PlayerId, string[]> = {};
for (const p of s.order) revealedReserved[p] = s.players[p]!.reserved.map((r) => r.cardId);
const reason: GameResult['reason'] = s.endReason === 'resign' ? (activePlayersOf(s).length === 0 ? 'abandoned' : 'resign') : 'normal';
return { ranking, winners, revealedReserved, summary, reason, endReason: s.endReason };
}
// ---------------------------------------------------------------- view helpers
function meView(s: SplendorState, me: PlayerId): SplendorMeView {
const ps = s.players[me]!;
const myTurn = s.phase !== 'finished' && s.order[s.current] === me && isActive(s, me);
const acting = myTurn && s.phase === 'action';
const buy: Record<string, BuyOption> = {};
if (isActive(s, me) && s.phase !== 'finished') {
const bonuses = bonusesOf(ps);
for (const b of buyables(s, me)) {
buy[buyKey(b.from)] = { payment: b.payment, need: needFor(b.card, bonuses), gold: b.payment.gold, total: sumTokens(b.payment) };
}
}
const rule = differentRule(s);
const nobleNeed: Record<string, number> = {};
if (s.options.showHints) {
const bonuses = bonusesOf(ps);
for (const id of s.nobles) nobleNeed[id] = COLORS.reduce((a, c) => a + Math.max(0, nobleOf(id).req[c] - bonuses[c]), 0);
}
return {
id: me,
myTurn,
takeDifferent: acting && rule.count > 0 ? rule : null,
takeSame: acting ? COLORS.filter((c) => s.supply[c] >= 4) : [],
canReserve: acting && ps.reserved.length < MAX_RESERVED && reserveTargets(s, me).length > 0,
reserveDecks: acting && ps.reserved.length < MAX_RESERVED ? TIERS.filter((t) => s.decks[t].length > 0) : [],
goldOnReserve: s.supply.gold > 0,
buy,
canPass: acting && !canAct(s, me),
discardCount: myTurn && s.phase === 'discard' ? tokenCount(ps) - TOKEN_LIMIT : 0,
nobleChoices: myTurn && s.phase === 'noble' ? s.pendingNobleChoices : null,
nobleNeed,
};
}
// ---------------------------------------------------------------- definition
export const splendor: GameDefinition<SplendorState, SplendorAction, SplendorView, SplendorOptions> = {
id: 'splendor',
nameKo: '보석 상인',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: splendorOptionsSchema.parse({}),
optionsSchema: splendorOptionsSchema,
actionSchema: splendorActionSchema as z.ZodType<SplendorAction>,
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 4) throw new Error('splendor: 2~4 players');
const order = rng.shuffle(players);
const nobles = rng.shuffle(NOBLES.map((n) => n.id)).slice(0, players.length + 1);
const decks = {} as Record<Tier, string[]>;
const board = {} as Record<Tier, (string | null)[]>;
for (const tier of TIERS) {
const deck = rng.shuffle(CARDS.filter((c) => c.tier === tier).map((c) => c.id));
board[tier] = deck.splice(0, BOARD_SLOTS);
decks[tier] = deck;
}
const per = tokensPerColor(players.length);
const supply: Tokens = { W: per, U: per, G: per, R: per, K: per, gold: 5 };
const ps: Record<PlayerId, PlayerState> = {};
for (const p of players) ps[p] = { tokens: emptyTokens(), cards: [], reserved: [], nobles: [] };
const s: SplendorState = {
options,
order,
current: 0,
phase: 'action',
endTriggered: false,
endReason: null,
supply,
decks,
board,
nobles,
players: ps,
pendingNobleChoices: null,
turnsTaken: Object.fromEntries(players.map((p) => [p, 0])),
turnSeq: 1,
deadlineAt: null,
conceded: [],
removed: [],
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
passStreak: 0,
logSeq: 0,
log: [],
};
s.deadlineAt = deadlineFor(s, order[0]!, now);
return s;
},
validate(s, actor, a): Validation {
const ps = s.players[actor];
if (!ps) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (s.conceded.includes(actor)) return fail('이미 기권했어요.');
if (a.type === 'CONCEDE') return ok;
if (s.order[s.current] !== actor) return fail('지금은 내 차례가 아니에요.');
if (a.type === 'TIMEOUT') return ok;
if (a.type === 'DISCARD') {
if (s.phase !== 'discard') return fail('지금은 보석을 돌려놓을 때가 아니에요.');
for (const c of TOKEN_COLORS) {
const v = a.tokens[c];
if (!Number.isInteger(v) || v < 0) return fail('잘못된 요청이에요.');
if (v > ps.tokens[c]) return fail('가진 것보다 많이 돌려놓을 수 없어요.');
}
const extra = tokenCount(ps) - TOKEN_LIMIT;
if (sumTokens(a.tokens) !== extra) return fail(`보석이 10개가 되도록 ${extra}개를 골라 주세요.`);
return ok;
}
if (a.type === 'CHOOSE_NOBLE') {
if (s.phase !== 'noble') return fail('지금은 귀족을 고를 때가 아니에요.');
return s.pendingNobleChoices?.includes(a.nobleId) ? ok : fail('고를 수 없는 귀족이에요.');
}
if (s.phase === 'discard') return fail('먼저 보석을 10개가 되도록 돌려놓아 주세요.');
if (s.phase === 'noble') return fail('먼저 방문할 귀족을 골라 주세요.');
switch (a.type) {
case 'TAKE_DIFFERENT': {
const colors = a.colors;
const rule = differentRule(s);
if (!Array.isArray(colors) || new Set(colors).size !== colors.length) return fail('서로 다른 색 3개를 골라 주세요.');
if (colors.some((c) => !COLORS.includes(c))) return fail('서로 다른 색 3개를 골라 주세요.');
if (colors.some((c) => s.supply[c] < 1)) return fail('남아 있지 않은 색이 있어요.');
if (rule.count === 0) return fail('서로 다른 색 3개를 가져올 수 없어요.');
if (colors.length !== rule.count) return fail(rule.count === 3 ? '서로 다른 색 3개를 골라 주세요.' : `남은 색이 ${rule.count}가지라 ${rule.count}개를 골라 주세요.`);
return ok;
}
case 'TAKE_SAME':
if (!COLORS.includes(a.color)) return fail('잘못된 요청이에요.');
return s.supply[a.color] >= 4 ? ok : fail('같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.');
case 'RESERVE': {
if (ps.reserved.length >= MAX_RESERVED) return fail('예약은 3장까지만 할 수 있어요.');
if ('deck' in a.from) return s.decks[a.from.tier]?.length ? ok : fail('그 단계 더미에 카드가 없어요.');
return s.board[a.from.tier]?.[a.from.slot] ? ok : fail('그 자리에 카드가 없어요.');
}
case 'BUY': {
const at = cardAt(s, actor, a.from);
if (!at) return fail('그 자리에 카드가 없어요.');
const card = cardOf(at.id);
if (a.payment) {
const err = paymentProblem(card, ps, a.payment);
return err ? fail(err) : ok;
}
return autoPayment(card, ps) ? ok : fail('보석이 부족해요.');
}
case 'PASS':
return canAct(s, actor) ? fail('할 수 있는 행동이 있어요.') : ok;
}
},
apply(prev, actor, a, { now }) {
// The state is plain JSON by contract; this is much faster than structuredClone.
const s = JSON.parse(JSON.stringify(prev)) as SplendorState;
const events: GameEvent[] = [];
if (a.type !== 'TIMEOUT' && a.type !== 'CONCEDE') s.timeoutsInRow[actor] = 0;
switch (a.type) {
case 'CONCEDE':
concede(s, actor, now, events);
break;
case 'TIMEOUT': {
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
events.push({ type: 'timeoutAuto', player: actor });
if (s.timeoutsInRow[actor] === AWAY_AFTER) pushLog(s, { k: 'away', p: actor });
autoTurn(s, actor, now, events);
break;
}
case 'DISCARD':
if (s.phase !== 'discard') throw new Error('not in discard phase');
doDiscard(s, actor, a.tokens, false, events);
continueTurn(s, actor, now, events, true);
break;
case 'CHOOSE_NOBLE':
if (s.phase !== 'noble' || !s.pendingNobleChoices?.includes(a.nobleId)) throw new Error('illegal noble');
visitNoble(s, actor, a.nobleId, events);
endTurn(s, actor, now, events);
break;
default:
if (s.phase !== 'action') throw new Error('not in action phase');
doPlay(s, actor, a, false, events);
continueTurn(s, actor, now, events);
}
return { state: s, events };
},
view(s, viewer) {
const finished = s.phase === 'finished';
const me = viewer && s.players[viewer] ? viewer : null;
const board = {} as Record<Tier, (DevCard | null)[]>;
const deckCounts = {} as Record<Tier, number>;
for (const t of TIERS) {
board[t] = s.board[t].map((id) => (id ? cardOf(id) : null));
deckCounts[t] = s.decks[t].length;
}
const players: Record<PlayerId, SplendorPlayerView> = {};
for (const p of s.order) {
const ps = s.players[p]!;
players[p] = {
id: p,
tokens: ps.tokens,
tokenCount: tokenCount(ps),
bonuses: bonusesOf(ps),
cards: ps.cards,
points: pointsOf(ps),
nobles: ps.nobles,
reservedCount: ps.reserved.length,
reserved: ps.reserved.map((r): ReservedView => {
const card = cardOf(r.cardId);
return r.blind && p !== me && !finished ? { hidden: true, tier: card.tier } : { ...card, hidden: false, blind: r.blind };
}),
conceded: s.conceded.includes(p),
away: (s.timeoutsInRow[p] ?? 0) >= AWAY_AFTER,
turnsTaken: s.turnsTaken[p] ?? 0,
};
}
const cur = currentPlayer(s);
const away = cur ? (s.timeoutsInRow[cur] ?? 0) >= AWAY_AFTER : false;
const base = s.options.turnSeconds === null ? 0 : away ? AWAY_MS : s.options.turnSeconds * 1000;
return {
options: s.options,
phase: s.phase,
order: s.order,
current: cur,
turnSeq: s.turnSeq,
deadlineAt: s.deadlineAt,
turnMs: base + (s.phase === 'discard' || s.phase === 'noble' ? EXTRA_PHASE_MS : 0),
endTriggered: s.endTriggered,
supply: s.supply,
board,
deckCounts,
nobles: s.nobles.map(nobleOf),
players,
me: me ? meView(s, me) : null,
log: s.log,
result: finished ? buildResult(s) : null,
};
},
activePlayers(s) {
const cur = currentPlayer(s);
return cur ? [cur] : [];
},
deadline(s) {
return s.phase === 'finished' ? null : s.deadlineAt;
},
onTimeout() {
return { type: 'TIMEOUT' };
},
onLeave(s, player) {
if (s.phase === 'finished' || !s.players[player] || s.conceded.includes(player)) return null;
return { type: 'CONCEDE' };
},
result(s): GameResult | null {
if (s.phase !== 'finished') return null;
const r = buildResult(s);
const groups = new Map<number, PlayerId[]>();
for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]);
const scores: Record<PlayerId, number> = {};
for (const row of r.ranking) scores[row.player] = row.points;
return { ranking: [...groups.values()], scores, summary: r.summary, reason: r.reason };
},
/**
* Test helper. Buying is preferred whenever possible (with a manual all-gold variant), so random games
* make progress; otherwise every take / reserve, or PASS when nothing is possible. Concede is left out.
*/
legalActions(s, p) {
if (s.phase === 'finished' || s.order[s.current] !== p || s.conceded.includes(p)) return [];
const ps = s.players[p]!;
if (s.phase === 'noble') return (s.pendingNobleChoices ?? []).map((nobleId) => ({ type: 'CHOOSE_NOBLE', nobleId }));
if (s.phase === 'discard') return discardOptions(ps.tokens, tokenCount(ps) - TOKEN_LIMIT).map((tokens) => ({ type: 'DISCARD', tokens }));
const out: SplendorAction[] = [];
const buys = buyables(s, p);
const bonuses = bonusesOf(ps);
for (const b of buys) {
out.push({ type: 'BUY', from: b.from });
// Same card paid with as much gold as possible (manual payment path).
const need = needFor(b.card, bonuses);
const manual = { ...b.payment };
for (const c of COLORS) {
const swap = Math.min(manual[c], ps.tokens.gold - manual.gold);
manual[c] -= swap;
manual.gold += swap;
}
if (manual.gold !== b.payment.gold && COLORS.every((c) => manual[c] <= need[c])) out.push({ type: 'BUY', from: b.from, payment: manual });
}
if (out.length && s.turnSeq % 6 !== 0) return out;
const rule = differentRule(s);
if (rule.count > 0) for (const colors of combos(rule.available, rule.count)) out.push({ type: 'TAKE_DIFFERENT', colors });
for (const c of COLORS) if (s.supply[c] >= 4) out.push({ type: 'TAKE_SAME', color: c });
for (const from of reserveTargets(s, p)) out.push({ type: 'RESERVE', from });
if (!out.length) out.push({ type: 'PASS' });
return out;
},
/** Hidden from others: my blind reservations; hidden from everyone: deck contents, unused nobles. */
secretsOf(s, player) {
const out: string[] = [];
if (s.phase !== 'finished') for (const r of s.players[player]?.reserved ?? []) if (r.blind) out.push(r.cardId);
for (const r of s.removed) if (r.blind) out.push(r.cardId);
for (const t of TIERS) out.push(...s.decks[t]);
const seen = new Set([...s.nobles, ...Object.values(s.players).flatMap((x) => x.nobles)]);
for (const n of NOBLES) if (!seen.has(n.id)) out.push(n.id);
return out;
},
};
function combos<T>(arr: readonly T[], k: number): T[][] {
if (k === 0) return [[]];
const out: T[][] = [];
arr.forEach((x, i) => {
for (const rest of combos(arr.slice(i + 1), k - 1)) out.push([x, ...rest]);
});
return out;
}
/** Every way to give back `k` tokens from `held`. */
function discardOptions(held: Tokens, k: number): Tokens[] {
const out: Tokens[] = [];
const cur = emptyTokens();
const go = (i: number, left: number) => {
if (left === 0) return void out.push({ ...cur });
if (i >= TOKEN_COLORS.length) return;
const c = TOKEN_COLORS[i]!;
for (let v = Math.min(left, held[c]); v >= 0; v--) {
cur[c] = v;
go(i + 1, left - v);
}
cur[c] = 0;
};
go(0, k);
return out;
}
/** Component totals for invariants: tokens per colour and cards / nobles accounted for. */
export function componentCounts(s: SplendorState): { tokens: Tokens; cards: number; nobles: number } {
const tokens = { ...s.supply };
let cards = s.removed.length;
for (const t of TIERS) cards += s.decks[t].length + s.board[t].filter(Boolean).length;
let nobles = s.nobles.length;
for (const p of s.order) {
const ps = s.players[p]!;
for (const c of TOKEN_COLORS) tokens[c] += ps.tokens[c];
cards += ps.cards.length + ps.reserved.length;
nobles += ps.nobles.length;
}
return { tokens, cards, nobles };
}

View File

@@ -0,0 +1,135 @@
/** Random playouts for 보석 상인 (docs/09 §7): 1,000 seeds plus a few full engine randomPlayout runs. */
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId } from '@bg/engine';
import { COLORS, NOBLES, TIERS, componentCounts, splendor, type SplendorAction, type SplendorOptions, type SplendorState, type Tokens } from './index';
const CONFIGS: Partial<SplendorOptions>[] = [
{},
{ targetPoints: 10 },
{ allowFewerTokens: false },
{ turnSeconds: null, showHints: false },
{ turnSeconds: 30, targetPoints: 10, allowFewerTokens: false },
];
function conserved(state: unknown): void {
const s = state as SplendorState;
const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7;
const c = componentCounts(s);
const want: Tokens = { W: n, U: n, G: n, R: n, K: n, gold: 5 };
for (const k of [...COLORS, 'gold'] as const) if (c.tokens[k] !== want[k]) throw new Error(`token ${k}: ${c.tokens[k]} != ${want[k]}`);
if (c.cards !== 90) throw new Error(`cards: ${c.cards}`);
if (c.nobles !== s.order.length + 1) throw new Error(`nobles: ${c.nobles}`);
for (const p of s.order) {
const ps = s.players[p]!;
if (ps.reserved.length > 3) throw new Error('more than 3 reservations');
for (const k of [...COLORS, 'gold'] as const) if (ps.tokens[k] < 0) throw new Error('negative tokens');
const total = Object.values(ps.tokens).reduce((a, b) => a + b, 0);
if (total > 10 && !(s.phase === 'discard' && s.order[s.current] === p)) throw new Error(`${p} holds ${total} tokens outside the discard phase`);
}
for (const t of TIERS) if (s.board[t].length !== 4) throw new Error('board must keep 4 slots');
}
const ID_RE = /"(?:[123][WUGRK]\d\d|N\d\d)"/g;
/** Who may see each hidden id: '*' = nobody, otherwise the owner of a blind reservation. */
function hiddenOwners(s: SplendorState): Map<string, string> {
const m = new Map<string, string>();
for (const t of TIERS) for (const id of s.decks[t]) m.set(id, '*');
for (const r of s.removed) if (r.blind) m.set(r.cardId, '*');
const seen = new Set([...s.nobles, ...s.order.flatMap((p) => s.players[p]!.nobles)]);
for (const n of NOBLES) if (!seen.has(n.id)) m.set(n.id, '*');
if (s.phase !== 'finished') for (const p of s.order) for (const r of s.players[p]!.reserved) if (r.blind) m.set(r.cardId, p);
return m;
}
function run(seed: number): { state: SplendorState; steps: number } {
const options = splendor.optionsSchema.parse(CONFIGS[seed % CONFIGS.length]);
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (seed % 3));
const rng = SeededRng.fromSeed(seedN(seed));
const chooser = SeededRng.fromSeed(seedN(seed + 700_000));
let now = 1_700_000_000_000;
let s = splendor.setup({ players, options, rng, now });
const concedeAt = seed % 13 === 0 ? 30 + (seed % 40) : -1;
for (let step = 0; step < 2000; step++) {
if (step % 10 === 0) {
const json = JSON.stringify(s);
if (JSON.stringify(JSON.parse(json)) !== json) throw new Error('not JSON round-trippable');
}
conserved(s);
const owners = hiddenOwners(s);
for (const viewer of [...players, null]) {
const ids = JSON.stringify(splendor.view(s, viewer)).match(ID_RE) ?? [];
for (const q of ids) {
const o = owners.get(q.slice(1, -1));
if (o && o !== viewer) throw new Error(`seed ${seed} step ${step}: ${viewer} sees ${q} (${o})`);
}
}
if (splendor.result(s)) return { state: s, steps: step };
const waiting = splendor.activePlayers(s);
if (waiting.length !== 1) throw new Error('exactly one player should be waited for');
for (const p of waiting) {
const v = splendor.validate(s, p, splendor.onTimeout(s, p));
if (!v.ok) throw new Error(`seed ${seed}: onTimeout illegal: ${v.reason}`);
}
let actor = waiting[0]!;
let a: SplendorAction;
if (step === concedeAt) {
const inGame = players.filter((p) => !s.conceded.includes(p));
actor = inGame[chooser.int(inGame.length)]!;
a = splendor.onLeave!(s, actor)!;
} else {
const legal = splendor.legalActions!(s, actor);
a = legal.length && chooser.int(8) !== 0 ? legal[chooser.int(legal.length)]! : splendor.onTimeout(s, actor);
}
const v = splendor.validate(s, actor, a);
if (!v.ok) throw new Error(`seed ${seed}: generated action rejected: ${v.reason} ${JSON.stringify(a)}`);
now += 1000;
const t0 = performance.now();
const out = splendor.apply(s, actor, a, { rng, now });
const ms = performance.now() - t0;
if (ms > 20) throw new Error(`apply took ${ms.toFixed(1)}ms`);
const after = hiddenOwners(out.state);
for (const q of JSON.stringify(out.events).match(ID_RE) ?? []) if (after.has(q.slice(1, -1))) throw new Error(`seed ${seed}: event leaks ${q}`);
s = out.state;
}
throw new Error(`seed ${seed}: not finished within 2000 steps`);
}
describe('random playouts (docs/09 §7)', () => {
test('engine randomPlayout (round trip, checkNoLeak via secretsOf, invariants, determinism)', () => {
for (let i = 0; i < 8; i++) {
const options = splendor.optionsSchema.parse(CONFIGS[i % CONFIGS.length]);
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3));
const a = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, invariant: conserved, maxApplyMs: 20 });
expect(a.finished).toBe(true);
if (i < 2) {
const b = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, maxApplyMs: 20 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
}
}
}, 60_000);
test('1,000 seeded playouts: conservation, no leaks in views or events, onTimeout always legal, every game ends', () => {
const ends: Record<string, number> = {};
let maxSteps = 0;
for (let seed = 1; seed <= 1000; seed++) {
const { state, steps } = run(seed);
maxSteps = Math.max(maxSteps, steps);
const res = splendor.result(state)!;
expect(res.ranking.flat().sort()).toEqual([...state.order].sort());
expect(res.summary.length).toBeGreaterThan(0);
const k = state.endReason ?? 'none';
ends[k] = (ends[k] ?? 0) + 1;
if (seed % 100 === 0) expect(JSON.stringify(run(seed).state)).toBe(JSON.stringify(state));
if (state.endReason === 'target') {
// Everyone in the game had the same number of turns (the round was played to its end).
const inGame = state.order.filter((p: PlayerId) => !state.conceded.includes(p));
const turns = new Set(inGame.map((p) => state.turnsTaken[p]));
if (!state.conceded.length) expect(turns.size).toBe(1);
}
}
expect(ends.target ?? 0).toBeGreaterThan(900);
expect(ends.resign ?? 0).toBeGreaterThan(0);
expect(maxSteps).toBeLessThan(2000);
}, 60_000);
});

View File

@@ -0,0 +1,753 @@
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();
});
});

View File

@@ -29,6 +29,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true }, { id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true }, { id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
{ id: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true }, { id: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true },
{ id: 'splendor', nameKo: '보석 상인', minPlayers: 2, maxPlayers: 4, minutes: '20~40', level: '보통', blurb: '보석을 모아 카드를 사고, 할인으로 더 비싼 카드까지! 귀족 방문은 덤', available: true },
]; ];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id); export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);