Files
joke-app/packages/games/src/splendor/splendor.playout.test.ts
EJClaw 11ad2be147 M7: 보석 상인 규칙·화면·테스트
- 규칙 모듈 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>
2026-10-05 14:53:35 +09:00

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);
});