- 규칙 모듈 packages/games/src/splendor: 카드 90장·귀족 10장 데이터 파일 분리(cards.ts), 보석 가져오기(3색/같은 색 2개, allowFewerTokens), 예약(공개/더미, 황금), 구매(자동·수동 지불), 10개 한도 반납, 귀족 방문·선택, 라운드 끝까지 진행 후 종료, 동점은 카드 적은 쪽, 넘기기와 모두 넘기면 종료, 기권, 시간 초과 자동 행동(3번 연속이면 자리 비움), view는 더미·블라인드 예약·제외 귀족을 숨기고 살 수 있는 카드·지불 미리보기를 계산 - 테스트: 문서 10절 시나리오 42개 + 무작위 대국 1,000판(보존·정보 유출·onTimeout 합법·결정성) + 엔진 randomPlayout - 화면 apps/web/src/games/splendor: 자체 카드·귀족·보석 그림(색+모양+글자), 3단계 시장과 더미, 보석 은행, 내 보너스·보석·예약, 상대 요약, 살 수 있는 카드 강조, 반납·귀족 선택, PC/휴대폰·다크 모드 - 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘 - E2E: PC 방장 + 휴대폰 친구가 10점 판을 결과까지 진행 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
6.7 KiB
TypeScript
136 lines
6.7 KiB
TypeScript
/** 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);
|
|
});
|