컴퓨터 방 자동 정리 + 게스트 계정 삭제(로그아웃·하루 미접속) + 테스트 10분→1분

- 방 화면을 벗어나면(홈·규칙) detach: 자리·접속 상태는 유지, 방 접속 수에서 빠짐
- 컴퓨터가 있는 방: 사람이 모두 [방 나가기]하면 바로 닫고, 아무도 안 보면 대기실·끝난 방 1분, 게임 중 30분 뒤 정리
- 게스트 로그아웃 = 계정 삭제(자리·전적·세션·소켓), 사이트를 닫고 24시간 지난 게스트도 1분 주기로 삭제
- users.last_seen_at을 요청·소켓 종료 때 갱신(이전엔 갱신 안 됨), 003 마이그레이션에서 기존 게스트 시계 초기화
- 테스트: 무작위 대국 시드를 playoutSeeds()로 평소 1/10, test:full은 전부. bun test --parallel=4, 테스트 서버 종료 대기 1s→50ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-07 17:10:52 +09:00
parent 373231b553
commit 39223bacdd
49 changed files with 629 additions and 155 deletions

View File

@@ -78,3 +78,13 @@ export function checkNoLeak(def: AnyGameDefinition, state: unknown, viewer: Play
export function seedN(n: number): RngState {
return [n >>> 0, (n * 2654435761) >>> 0, (n ^ 0xdeadbeef) >>> 0, (n + 1) >>> 0];
}
/**
* How many random-playout seeds a test runs. Plain `bun test` runs a tenth of `full` so the suite stays quick;
* `FULL_TESTS=1 bun test` (`bun run test:full`, before a release) runs all of them.
*/
export function playoutSeeds(full: number): number {
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
const fullRun = !!env?.FULL_TESTS;
return fullRun ? full : Math.max(1, Math.ceil(full / 10));
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
CARDS,
CHARACTER_LIFE,
@@ -1263,11 +1263,13 @@ const PRESETS: Partial<BangOptions>[] = [
{ characterDraft: 'choose2', autoBarrel: false, autoDefend: false, reactionSeconds: 8 },
];
describe('무작위 대국 (시드 1,000개, 4~7명)', () => {
test('1,000 games finish; cards conserved, life in bounds, no leaks, onTimeout always legal, deterministic', () => {
const SEEDS = playoutSeeds(1000);
describe(`무작위 대국 (시드 ${SEEDS}개, 4~7명)`, () => {
test(`${SEEDS} games finish; cards conserved, life in bounds, no leaks, onTimeout always legal, deterministic`, () => {
let finished = 0;
const wins = { law: 0, outlaws: 0, renegade: 0 };
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const n = 4 + (i % 4);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = bang.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
@@ -1283,7 +1285,7 @@ describe('무작위 대국 (시드 1,000개, 4~7명)', () => {
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(finished).toBe(SEEDS);
// Every team wins sometimes.
expect(wins.law).toBeGreaterThan(0);
expect(wins.outlaws).toBeGreaterThan(0);
@@ -1298,7 +1300,7 @@ describe('무작위 대국 (시드 1,000개, 4~7명)', () => {
] as const)('randomPlayout with the engine per-step leak / round-trip checks: %i players', (n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = bang.optionsSchema.parse(opts);
for (let i = 1; i <= 3; i++) {
for (let i = 1; i <= playoutSeeds(3); i++) {
const a = randomPlayout(bang, { players, options, seed: seedN(2000 + i), maxSteps: 5000, invariant: (st) => invariants(st as BangState), maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i === 1) {

View File

@@ -1,6 +1,6 @@
/** 그림 맞히기 rules tests (docs/games/catchmind.md §10) + seeded random playouts (docs/09 §7). */
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
BUILTIN_WORDS,
CHOOSE_MS,
@@ -592,10 +592,12 @@ function invariants(s: CatchmindState, poolSize: number): void {
}
describe('무작위 대국(§10, docs/09 §7)', () => {
test('1,000판: 예외 없음, 단어·점수 보존, 종료, 결정성', () => {
const GAMES = playoutSeeds(1000);
const DEEP_GAMES = playoutSeeds(150);
test(`${GAMES}판: 예외 없음, 단어·점수 보존, 종료, 결정성`, () => {
const t0 = performance.now();
let finished = 0;
for (let i = 0; i < 1000; i++) {
for (let i = 0; i < GAMES; i++) {
const pk = SeededRng.fromSeed(seedN(9000 + i));
const n = 2 + pk.int(9);
const options = opts({
@@ -609,7 +611,7 @@ describe('무작위 대국(§10, docs/09 §7)', () => {
speedBonus: pk.int(3) === 0,
difficulty: (['easy', 'normal', 'mixed'] as const)[pk.int(3)]!,
});
const run = fastPlayout(P(n), options, seedN(i), 4000, i < 150);
const run = fastPlayout(P(n), options, seedN(i), 4000, i < DEEP_GAMES);
if (run.state.finished) finished++;
else throw new Error(`seed ${i}: not finished after ${run.steps} steps`);
if (i % 100 === 0) {
@@ -617,7 +619,7 @@ describe('무작위 대국(§10, docs/09 §7)', () => {
expect(JSON.stringify(again.state)).toBe(JSON.stringify(run.state));
}
}
expect(finished).toBe(1000);
expect(finished).toBe(GAMES);
expect(performance.now() - t0).toBeLessThan(55_000);
}, 60_000);

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
KINDS,
allCardIds,
@@ -481,7 +481,8 @@ describe('비공개 정보 (§8.3)', () => {
});
test('무작위 진행 중 모든 view가 비공개 정보를 숨기고 이벤트에도 미공개 카드가 없음', () => {
for (let seed = 1; seed <= 150; seed++) {
const seeds = playoutSeeds(150);
for (let seed = 1; seed <= seeds; seed++) {
const n = 2 + (seed % 5);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const r = SeededRng.fromSeed(seedN(seed));
@@ -553,8 +554,10 @@ function invariant(st: unknown): void {
}
describe('무작위 대국 (docs/09 §7)', () => {
test('engine randomPlayout, seeds 1–200: 끝남, 카드 보존, 유출 없음, onTimeout 합법, 결정성', () => {
for (let i = 1; i <= 200; i++) {
const ENGINE_SEEDS = playoutSeeds(200);
const FAST_LAST = 200 + playoutSeeds(800);
test(`engine randomPlayout, seeds 1–${ENGINE_SEEDS}: 끝남, 카드 보존, 유출 없음, onTimeout 합법, 결정성`, () => {
for (let i = 1; i <= ENGINE_SEEDS; i++) {
const { players, options } = cfg(i);
const r = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, invariant, maxApplyMs: 50 });
expect(r.finished).toBe(true);
@@ -569,9 +572,9 @@ describe('무작위 대국 (docs/09 §7)', () => {
}
}, 60_000);
test('fast loop, seeds 201–1000 (+ timeouts): 끝남, 카드 보존, 상태 JSON 왕복, 같은 로그 재생 = 같은 결과', () => {
test(`fast loop, seeds 201–${FAST_LAST} (+ timeouts): 끝남, 카드 보존, 상태 JSON 왕복, 같은 로그 재생 = 같은 결과`, () => {
let maxSteps = 0;
for (let i = 201; i <= 1000; i++) {
for (let i = 201; i <= FAST_LAST; i++) {
const { players, options } = cfg(i);
const r = SeededRng.fromSeed(seedN(i));
const chooser = SeededRng.fromSeed(seedN(i + 5000));

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
import {
FULL_DECK,
allCardIds,
@@ -630,12 +630,14 @@ const PRESETS: Partial<DalmutiOptions>[] = [
{ rounds: 5, removeHighCards: true, passLocksOut: true },
];
describe('random playouts (1,000 seeds)', () => {
test('1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic', () => {
const SEEDS = playoutSeeds(1000);
describe(`random playouts (${SEEDS} seeds)`, () => {
test(`${SEEDS} seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
let finished = 0;
let revolutions = 0;
let great = 0;
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const n = 4 + (i % 5);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dalmuti.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
@@ -653,7 +655,7 @@ describe('random playouts (1,000 seeds)', () => {
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(finished).toBe(SEEDS);
expect(revolutions).toBeGreaterThan(0);
expect(great).toBeGreaterThan(0);
}, 60_000);

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
allTileIds,
autoAction,
@@ -521,7 +521,8 @@ describe('시간 초과·연결 끊김 (§8.5)', () => {
describe('정보 유출 (§8.3)', () => {
test('관전자·상대 view와 이벤트에 남의 숨은 숫자·ID·이번 턴 타일 숫자가 없음', () => {
for (let seed = 1; seed <= 300; seed++) {
const seeds = playoutSeeds(300);
for (let seed = 1; seed <= seeds; seed++) {
const n = 2 + (seed % 3);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const options = davinci.optionsSchema.parse({ jokers: seed % 2 === 0, chooseColors: seed % 3 !== 0, deductionHelper: seed % 4 === 0 });
@@ -615,7 +616,9 @@ describe('random playouts (docs/09 §7)', () => {
}
};
for (const [from, to] of [[1, 250], [251, 500], [501, 750], [751, 1000]] as const) {
const total = playoutSeeds(1000);
for (let from = 1; from <= total; from += 250) {
const to = Math.min(from + 249, total);
test(`seeds ${from}–${to}: finish, conserve tiles, sorted rows, no leaks, onTimeout legal`, () => {
for (let i = from; i <= to; i++) {
const { players, options } = cfg(i);

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import { SeededRng, checkNoLeak, playoutSeeds, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_STEP_MS,
COUNTDOWN_MS,
@@ -791,9 +791,10 @@ const PRESETS: Partial<DobbleOptions>[] = [
];
describe('random playouts (§10, docs/09 §7)', () => {
test('1,000 seeded games: finish, 57 cards conserved, no leaks, onTimeout always legal, deterministic', () => {
const SEEDS = playoutSeeds(1000);
test(`${SEEDS} seeded games: finish, 57 cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
const tally: Record<string, number> = { correct: 0, wrong: 0, forfeit: 0, left: 0 };
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const n = 2 + (i % 7);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dobble.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
@@ -815,8 +816,8 @@ describe('random playouts (§10, docs/09 §7)', () => {
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(tally.correct).toBeGreaterThan(10_000);
expect(tally.wrong).toBeGreaterThan(1000);
expect(tally.correct).toBeGreaterThan(10 * SEEDS);
expect(tally.wrong).toBeGreaterThan(SEEDS);
expect(tally.forfeit).toBeGreaterThan(0);
expect(tally.left).toBeGreaterThan(0);
}, 60_000);
@@ -831,7 +832,7 @@ describe('random playouts (§10, docs/09 §7)', () => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dobble.optionsSchema.parse(opts);
const inv = (st: unknown) => invariants(st as DobbleState);
for (let i = 1; i <= 4; i++) {
for (let i = 1; i <= playoutSeeds(4); i++) {
const a = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, invariant: inv, maxApplyMs: 20 });
expect(a.finished).toBe(true);
if (i === 1) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine';
import {
autoPlay,
bestScore,
@@ -854,10 +854,12 @@ const CONFIGS: [string, Partial<GostopOptions>][] = [
];
describe('random playouts', () => {
test('1,000 seeds: invariants, every legal action validates, onTimeout legal in every state', () => {
const SEEDS = playoutSeeds(1000);
const LEAK_SEEDS = playoutSeeds(300);
test(`${SEEDS} seeds: invariants, every legal action validates, onTimeout legal in every state`, () => {
let rounds = 0;
let stops = 0;
for (let seed = 1; seed <= 1000; seed++) {
for (let seed = 1; seed <= SEEDS; seed++) {
const n = 2 + (seed % 4);
const ps = players(n);
const [, opts] = CONFIGS[seed % CONFIGS.length]!;
@@ -894,8 +896,8 @@ describe('random playouts', () => {
invariants(s);
expect(gostop.result(s)!.ranking.flat().sort()).toEqual([...ps].sort());
}
expect(rounds).toBeGreaterThan(900);
expect(stops).toBeGreaterThan(100);
expect(rounds).toBeGreaterThan(SEEDS * 0.9);
expect(stops).toBeGreaterThan(SEEDS / 10);
}, 60_000);
test('engine randomPlayout (leaks, JSON round-trip, determinism) in every mode', () => {
@@ -920,7 +922,7 @@ describe('random playouts', () => {
}, 60_000);
test('leak property: views never contain hidden hands / deck, events only reveal public cards', () => {
for (let seed = 1; seed <= 300; seed++) {
for (let seed = 1; seed <= LEAK_SEEDS; seed++) {
const n = 2 + (seed % 4);
const ps = players(n);
const options = parse({ ...CONFIGS[seed % CONFIGS.length]![1], rounds: 1 });

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import { SeededRng, checkNoLeak, playoutSeeds, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_FLIP_MS,
CARDS,
@@ -660,11 +660,12 @@ const PRESETS: Partial<HalliGalliOptions>[] = [
];
describe('random playouts (§10, docs/09 §7)', () => {
test('1,000 seeded games: finish, 56 cards conserved, no leaks, onTimeout always legal, deterministic', () => {
const SEEDS = playoutSeeds(1000);
test(`${SEEDS} seeded games: finish, 56 cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
let finished = 0;
let collects = 0;
let wrongs = 0;
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const n = 2 + (i % 5);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = hg.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
@@ -681,9 +682,9 @@ describe('random playouts (§10, docs/09 §7)', () => {
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(collects).toBeGreaterThan(1000);
expect(wrongs).toBeGreaterThan(1000);
expect(finished).toBe(SEEDS);
expect(collects).toBeGreaterThan(SEEDS);
expect(wrongs).toBeGreaterThan(SEEDS);
}, 60_000);
test.each([

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { SeededRng, randomPlayout, seedN, playoutSeeds } from '@bg/engine';
import { janggi, type JanggiAction, type JanggiOptions, type JanggiState } from './index';
import { idx, inCheck, initialBoard, kingsFacing, makeMove, positionKey, type Board, type PieceKind, type Side } from './rules';
@@ -546,7 +546,7 @@ describe('random playouts', () => {
['no scoring, kja30, simultaneous, limit 150', { scoring: false, bikjang: 'kja30', setupOrder: 'simultaneous', moveLimit: 150 }],
['repetition lose, no pass, limit 250', { repetition: 'lose', allowPass: false, moveLimit: 250 }],
] as const)('%s: finish, deterministic, JSON-safe', (_n, o) => {
for (let i = 0; i < 6; i++) {
for (let i = 0; i < playoutSeeds(6); i++) {
const options = opts(o as Partial<JanggiOptions>);
const a = randomPlayout(janggi, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50, invariant: kings, maxSteps: 400 });
const b = randomPlayout(janggi, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50, maxSteps: 400 });

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
ALL_NOTES,
AWAY_AFTER,
@@ -639,7 +639,9 @@ describe('random playouts (docs/09 §7)', () => {
}
}
test('1,000 seeds (custom fast loop with timeouts and concedes): finish, conservation, no leaks, onTimeout legal, deterministic', () => {
const SEEDS = playoutSeeds(1000);
const ENGINE_SEEDS = playoutSeeds(100);
test(`${SEEDS} seeds (custom fast loop with timeouts and concedes): finish, conservation, no leaks, onTimeout legal, deterministic`, () => {
let finished = 0;
const play = (i: number, check: boolean): string => {
const n = 2 + (i % 4) + (i % 7 === 0 ? 1 : 0);
@@ -686,16 +688,16 @@ describe('random playouts (docs/09 §7)', () => {
}
throw new Error(`seed ${i} did not finish`);
};
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const a = play(i, true);
finished++;
if (i % 50 === 0) expect(play(i, false)).toBe(a);
}
expect(finished).toBe(1000);
expect(finished).toBe(SEEDS);
}, 60_000);
test('engine randomPlayout: 100 seeds with round-trip, leak checker and determinism', () => {
for (let i = 1; i <= 100; i++) {
test(`engine randomPlayout: ${ENGINE_SEEDS} seeds with round-trip, leak checker and determinism`, () => {
for (let i = 1; i <= ENGINE_SEEDS; i++) {
const players = ['A', 'B', 'C', 'D', 'E'].slice(0, 2 + (i % 4));
const opts = lasVegasOptionsSchema.parse(OPTS[i % OPTS.length]!.neutralDice === 'on' && players.length === 5 ? {} : OPTS[i % OPTS.length]!);
const r = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
@@ -703,7 +705,7 @@ describe('random playouts (docs/09 §7)', () => {
const st = r.finalState as LasVegasState;
expect(st.roundResults.length).toBe(opts.rounds);
expect(allBills(st)).toEqual(SORTED_NOTES);
if (i % 20 === 0) {
if (i % 20 === 0 || i === ENGINE_SEEDS) {
const again = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
expect(JSON.stringify(again.finalState)).toBe(JSON.stringify(st));
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_DAYS,
CHANNELS,
@@ -801,11 +801,13 @@ function fastPlayout(players: PlayerId[], options: MafiaOptions, seed: RngState,
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
}
describe('무작위 대국 (시드 1,000개, 4~12명)', () => {
test('1,000 games finish; roles conserved, no leaks, onTimeout always legal, deterministic', () => {
const SEEDS = playoutSeeds(1000);
describe(`무작위 대국 (시드 ${SEEDS}개, 4~12명)`, () => {
test(`${SEEDS} games finish; roles conserved, no leaks, onTimeout always legal, deterministic`, () => {
const wins = { town: 0, mafia: 0 };
const causes: Record<string, number> = {};
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const n = 4 + (i % 9);
const players = ids(n);
const options = presetFor(i, n);
@@ -821,8 +823,8 @@ describe('무작위 대국 (시드 1,000개, 4~12명)', () => {
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(wins.town).toBeGreaterThan(50);
expect(wins.mafia).toBeGreaterThan(50);
expect(wins.town).toBeGreaterThan(SEEDS / 20);
expect(wins.mafia).toBeGreaterThan(SEEDS / 20);
expect(Object.keys(causes).sort()).toEqual(['disconnect', 'night', 'vote']);
}, 60_000);

View File

@@ -1,28 +1,32 @@
/**
* 마작 무작위 대국 ③ 3인(산마) 360판 + 엔진 randomPlayout(직렬화 왕복·정보 누출 검사 내장) + 매 단계 누출 검사
* 마작 무작위 대국 ③ 3인(산마) 360판(FULL_TESTS, 기본은 36판) + 엔진 randomPlayout(직렬화 왕복·정보 누출 검사 내장) + 매 단계 누출 검사
* + 재현성 (docs/09-game-engine.md §7).
*/
import { describe, expect, test } from 'bun:test';
import { randomPlayout, seedN } from '@bg/engine';
import { playoutSeeds, randomPlayout, seedN } from '@bg/engine';
import { mahjong } from './index';
import { checkPoints, checkTiles, emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
/** 360 games with FULL_TESTS, a tenth otherwise; the count thresholds below scale with it. */
const GAMES = playoutSeeds(360);
const k = GAMES / 360;
describe('무작위 대국: 3인 산마', () => {
for (let chunk = 0; chunk < 18; chunk++) {
test(`시드 ${2000 + chunk * 20 + 1}~${2000 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
for (let chunk = 0; chunk * 20 < GAMES; chunk++) {
const size = Math.min(20, GAMES - chunk * 20);
test(`시드 ${2000 + chunk * 20 + 1}~${2000 + chunk * 20 + size}`, () => {
for (let i = 1; i <= size; i++) {
const seed = 2000 + chunk * 20 + i;
playGame({ seed, players: 3, options: { length: seed % 2 ? 'tonpuu' : 'hanchan', ...variedOptions(seed, 3) }, prefs: seed % 4 === 0, leaveAt: seed % 11 === 0 ? 150 : undefined, stats });
}
});
}
test('북 뽑기·화료·유국이 모두 나왔다', () => {
expect(stats.kita).toBeGreaterThan(500);
expect(stats.tsumo).toBeGreaterThan(50);
expect(stats.ron).toBeGreaterThan(50);
expect(stats.exhaustive).toBeGreaterThan(50);
expect(stats.kita).toBeGreaterThan(500 * k);
expect(stats.tsumo).toBeGreaterThan(50 * k);
expect(stats.ron).toBeGreaterThan(50 * k);
expect(stats.exhaustive).toBeGreaterThan(50 * k);
expect(stats.maxApplyMs).toBeLessThan(25);
});
});

View File

@@ -1,25 +1,30 @@
/** 마작 무작위 대국 ① 4인 반장전 240판 (docs/09-game-engine.md §7.2): 끝까지 합법 진행 + 불변식. */
/** 마작 무작위 대국 ① 4인 반장전 240판(FULL_TESTS, 기본은 24판) (docs/09-game-engine.md §7.2): 끝까지 합법 진행 + 불변식. */
import { describe, expect, test } from 'bun:test';
import { playoutSeeds } from '@bg/engine';
import { emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
/** 240 games with FULL_TESTS, a tenth otherwise; the count thresholds below scale with it. */
const GAMES = playoutSeeds(240);
const k = GAMES / 240;
describe('무작위 대국: 4인 반장전', () => {
for (let chunk = 0; chunk < 12; chunk++) {
test(`시드 ${0 + chunk * 20 + 1}~${0 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
for (let chunk = 0; chunk * 20 < GAMES; chunk++) {
const size = Math.min(20, GAMES - chunk * 20);
test(`시드 ${0 + chunk * 20 + 1}~${0 + chunk * 20 + size}`, () => {
for (let i = 1; i <= size; i++) {
const seed = 0 + chunk * 20 + i;
playGame({ seed, players: 4, options: { length: 'hanchan', ...variedOptions(seed, 4) }, prefs: seed % 5 === 0, leaveAt: seed % 17 === 0 ? 400 : undefined, stats });
}
});
}
test('다양한 결과가 나왔고 apply는 빠르다', () => {
expect(stats.tsumo).toBeGreaterThan(50);
expect(stats.ron).toBeGreaterThan(100);
expect(stats.exhaustive).toBeGreaterThan(100);
expect(stats.tsumo).toBeGreaterThan(50 * k);
expect(stats.ron).toBeGreaterThan(100 * k);
expect(stats.exhaustive).toBeGreaterThan(100 * k);
expect(stats.abortive).toBeGreaterThan(0);
expect(stats.riichi).toBeGreaterThan(200);
expect(stats.kans).toBeGreaterThan(50);
expect(stats.riichi).toBeGreaterThan(200 * k);
expect(stats.kans).toBeGreaterThan(50 * k);
expect(Object.keys(stats.endReasons).length).toBeGreaterThanOrEqual(3);
expect(stats.applyMs / stats.applies).toBeLessThan(1);
expect(stats.maxApplyMs).toBeLessThan(25);

View File

@@ -1,21 +1,27 @@
/** 마작 무작위 대국 ② 4인 동풍전 400판 (docs/09-game-engine.md §7.2). */
/** 마작 무작위 대국 ② 4인 동풍전 400판(FULL_TESTS, 기본은 40판) (docs/09-game-engine.md §7.2). */
import { describe, expect, test } from 'bun:test';
import { playoutSeeds } from '@bg/engine';
import { emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
/** 400 games with FULL_TESTS, a tenth otherwise; the count thresholds below scale with it. */
const GAMES = playoutSeeds(400);
const k = GAMES / 400;
describe('무작위 대국: 4인 동풍전', () => {
for (let chunk = 0; chunk < 20; chunk++) {
test(`시드 ${1000 + chunk * 20 + 1}~${1000 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
for (let chunk = 0; chunk * 20 < GAMES; chunk++) {
const size = Math.min(20, GAMES - chunk * 20);
test(`시드 ${1000 + chunk * 20 + 1}~${1000 + chunk * 20 + size}`, () => {
for (let i = 1; i <= size; i++) {
const seed = 1000 + chunk * 20 + i;
playGame({ seed, players: 4, options: { length: 'tonpuu', ...variedOptions(seed, 4) }, prefs: seed % 4 === 0, leaveAt: seed % 13 === 0 ? 200 : undefined, stats });
}
});
}
test('다양한 결과가 나왔고 apply는 빠르다', () => {
expect(stats.tsumo + stats.ron).toBeGreaterThan(150);
expect(stats.doubleRon).toBeGreaterThan(0);
expect(stats.tsumo + stats.ron).toBeGreaterThan(150 * k);
// 더블 론은 400판에 한 번 정도라 전체 실행에서만 확인한다.
if (GAMES === 400) expect(stats.doubleRon).toBeGreaterThan(0);
expect(stats.applyMs / stats.applies).toBeLessThan(1);
expect(stats.maxApplyMs).toBeLessThan(25);
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type Rng } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type Rng } from '@bg/engine';
import {
CARD_IDS,
GROUPS,
@@ -1044,10 +1044,11 @@ function invariant(s: MarbleState): void {
}
describe('random playouts', () => {
test('1,000 seeds (fast loop): invariants, onTimeout legal everywhere, games end, results rank everyone', () => {
const SEEDS = playoutSeeds(1000);
test(`${SEEDS} seeds (fast loop): invariants, onTimeout legal everywhere, games end, results rank everyone`, () => {
let steps = 0;
const reasons: Record<string, number> = {};
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const cfg = CONFIGS[i % CONFIGS.length]!;
const options = marble.optionsSchema.parse(cfg.opts);
const players = PLAYERS.slice(0, cfg.n);
@@ -1082,13 +1083,15 @@ describe('random playouts', () => {
const res = marble.result(s)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
}
expect(steps).toBeGreaterThan(1000 * 50);
// Every way of ending shows up.
expect(Object.keys(reasons).sort()).toEqual(['lastSurvivor', 'monopoly', 'roundLimit']);
expect(steps).toBeGreaterThan(SEEDS * 50);
// Every way of ending shows up (monopoly only once in the full 1,000 seeds, at seed 767).
expect(Object.keys(reasons).sort()).toEqual(SEEDS === 1000 ? ['lastSurvivor', 'monopoly', 'roundLimit'] : ['lastSurvivor', 'roundLimit']);
}, 60_000);
test('engine randomPlayout (round trip, leak check, 5 ms apply), deterministic and replayable', () => {
for (let i = 1; i <= 40; i++) {
// Every config at least once even in the quick run (seed 8 also does the replay check).
const games = Math.max(CONFIGS.length, playoutSeeds(40));
for (let i = 1; i <= games; i++) {
const cfg = CONFIGS[i % CONFIGS.length]!;
const options = marble.optionsSchema.parse(cfg.opts);
const players = PLAYERS.slice(0, cfg.n);

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import { SeededRng, checkNoLeak, playoutSeeds, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
cardTotal,
classicDeck,
@@ -664,9 +664,10 @@ const PRESETS: Partial<NNOptions>[] = [
];
describe('random playouts', () => {
test('1,000 seeded games finish: cards conserved, no leaks, onTimeout always legal, deterministic', () => {
const SEEDS = playoutSeeds(1000);
test(`${SEEDS} seeded games finish: cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
let finished = 0;
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const n = 2 + (i % 9);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = ninetyNine.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
@@ -681,7 +682,7 @@ describe('random playouts', () => {
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(finished).toBe(SEEDS);
}, 60_000);
test.each([
@@ -695,7 +696,7 @@ describe('random playouts', () => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = ninetyNine.optionsSchema.parse(opts);
const check = (st: unknown) => invariants(st as NinetyNineState);
for (let i = 1; i <= 3; i++) {
for (let i = 1; i <= playoutSeeds(3); i++) {
const a = randomPlayout(ninetyNine, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: check, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i === 1) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN } from '@bg/engine';
import { omok, type OmokOptions, type OmokState, type OmokAction } from './index';
const COLS = 'ABCDEFGHIJKLMNOPQRS';
@@ -269,8 +269,9 @@ describe('views and playouts', () => {
});
test('random playouts finish, are deterministic and fast', () => {
const games = playoutSeeds(15);
for (const ruleSet of ['korean', 'renju', 'free'] as const) {
for (let i = 0; i < 15; i++) {
for (let i = 0; i < games; i++) {
const options = omok.optionsSchema.parse({ ruleSet });
const a = randomPlayout(omok, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 });
const b = randomPlayout(omok, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 });

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
cardTotal,
classicDeck,
@@ -821,12 +821,14 @@ const PRESETS: Record<'classic' | 'uno-style', Partial<OneCardOptions>[]> = {
],
};
describe('random playouts (1,000 seeds per mode)', () => {
const SEEDS = playoutSeeds(1000);
describe(`random playouts (${SEEDS} seeds per mode)`, () => {
for (const mode of ['classic', 'uno-style'] as const) {
test(`${mode}: 1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
test(`${mode}: ${SEEDS} seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
const max = mode === 'classic' ? 9 : 10;
let finished = 0;
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const n = 2 + (i % (max - 1));
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = oneCard.optionsSchema.parse({ mode, ...PRESETS[mode][i % PRESETS[mode].length]! });
@@ -841,7 +843,7 @@ describe('random playouts (1,000 seeds per mode)', () => {
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(finished).toBe(SEEDS);
}, 120_000);
}
@@ -856,7 +858,7 @@ describe('random playouts (1,000 seeds per mode)', () => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = oneCard.optionsSchema.parse({ mode, ...opts });
const conserve = (st: unknown) => invariants(st as OneCardState);
for (let i = 1; i <= 3; i++) {
for (let i = 1; i <= playoutSeeds(3); i++) {
const a = randomPlayout(oneCard, { players, options, seed: seedN(1000 + i), maxSteps: 30_000, invariant: conserve, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i === 1) {

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, buildPots, randomPlayout, seedN, type PlayerId } from '@bg/engine';
import { SeededRng, buildPots, playoutSeeds, randomPlayout, seedN, type PlayerId } from '@bg/engine';
import { deck52, deckD20, type CardId } from './cards';
import { evaluate } from './showdown';
import { awardHiLo } from './showdown';
@@ -908,7 +908,8 @@ describe('random playouts', () => {
}
});
test('~1000 hands across modes/options: no leaks (views, spectator, events, foreheads), chips and cards conserved, apply < 5ms', () => {
const HANDS = playoutSeeds(1000);
test(`~${HANDS} hands across modes/options: no leaks (views, spectator, events, foreheads), chips and cards conserved, apply < 5ms`, () => {
const configs: [Partial<PokerOptions>, number][] = [
[{ mode: 'holdem' }, 6],
[{ mode: 'holdem', betting: 'potLimit', dealerRule: 'winner' }, 10],
@@ -927,7 +928,8 @@ describe('random playouts', () => {
];
let hands = 0;
const times: number[] = [];
for (let i = 0; hands < 1000; i++) {
// Every config at least once, even in the quick run.
for (let i = 0; hands < HANDS || i < configs.length; i++) {
const [o, n] = configs[i % configs.length]!;
const r = playout(poker.optionsSchema.parse({ handLimit: 10, ...o }), n, i + 1);
expect(r.finished).toBe(true);

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN } from '@bg/engine';
import {
legalWallsFor,
pawnMovesFor,
@@ -636,12 +636,13 @@ function fastPlayout(seed: number, n: 2 | 4, options: QuoridorOptions, checkEver
}
describe('random playouts (docs/09 §7)', () => {
test('1,000판 무작위 대국: 끝까지, 불변식, 시간 초과 합법, 정보 누출 없음', () => {
const GAMES = playoutSeeds(1000);
test(`${GAMES}판 무작위 대국: 끝까지, 불변식, 시간 초과 합법, 정보 누출 없음`, () => {
const t0 = performance.now();
let finished = 0;
let steps = 0;
const reasons: Record<string, number> = {};
for (let i = 0; i < 1000; i++) {
for (let i = 0; i < GAMES; i++) {
const n = i % 3 === 0 ? 4 : 2;
const options = quoridor.optionsSchema.parse({
seatOrder: i % 2 ? 'random' : 'joinOrder',
@@ -656,9 +657,9 @@ describe('random playouts (docs/09 §7)', () => {
steps += trace.length;
reasons[state.result?.reason ?? 'none'] = (reasons[state.result?.reason ?? 'none'] ?? 0) + 1;
}
console.log(` 1000 playouts: ${finished} finished, avg ${(steps / 1000).toFixed(1)} actions, ${(performance.now() - t0).toFixed(0)}ms`, reasons);
expect(finished).toBe(1000);
expect(reasons.reachedGoal).toBeGreaterThan(500);
console.log(` ${GAMES} playouts: ${finished} finished, avg ${(steps / GAMES).toFixed(1)} actions, ${(performance.now() - t0).toFixed(0)}ms`, reasons);
expect(finished).toBe(GAMES);
expect(reasons.reachedGoal).toBeGreaterThan(GAMES / 2);
}, 60_000);
test('결정성: 같은 시드·같은 행동이면 같은 결과, 기록 재생도 같은 상태', () => {
@@ -680,7 +681,9 @@ describe('random playouts (docs/09 §7)', () => {
}, 60_000);
test('엔진 randomPlayout(완전 무작위 합법 행동)으로도 끝까지', () => {
for (let i = 0; i < 24; i++) {
// At least the 6 player-count × continueForRanking combinations even in the quick run.
const games = Math.max(6, playoutSeeds(24));
for (let i = 0; i < games; i++) {
const players = i % 3 === 0 ? ['A', 'B', 'C', 'D'] : ['A', 'B'];
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 2 === 0, wallsPerPlayer: 6 });
const inv = (st: unknown) => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent, type PlayerId } from '@bg/engine';
import {
ALL_TILE_IDS,
findMeld,
@@ -666,7 +666,9 @@ function conserved(st: unknown): void {
describe('random playouts (docs/09 §7)', () => {
test('engine randomPlayout (round trip, checkNoLeak via secretsOf, determinism) across options', () => {
for (let i = 0; i < 25; i++) {
// At least one game per config even in the quick run.
const games = Math.max(CONFIGS.length, playoutSeeds(25));
for (let i = 0; i < games; i++) {
const options = rummikub.optionsSchema.parse(CONFIGS[i % CONFIGS.length]);
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3));
const a = randomPlayout(rummikub, { players, options, seed: seedN(9000 + i), maxSteps: 4000, invariant: conserved, maxApplyMs: 30 });
@@ -679,7 +681,7 @@ describe('random playouts (docs/09 §7)', () => {
}, 60_000);
/**
* 1,000 seeds, same checks as randomPlayout with each view computed once per step (fast leak check),
* 1,000 seeds (FULL_TESTS; 100 otherwise), same checks as randomPlayout with each view computed once per step (fast leak check),
* plus: onTimeout legal for every waiting player at every step, and 1 in 8 moves is a timeout.
*/
const TILE_RE = /"(?:[KRBO]\d{1,2}[ab]|JK[12])"/g;
@@ -734,8 +736,9 @@ describe('random playouts (docs/09 §7)', () => {
throw new Error(`seed ${seed}: not finished within 4000 steps`);
}
for (let chunk = 0; chunk < 10; chunk++) {
test(`1,000 seeded playouts: seeds ${chunk * 100 + 1}–${chunk * 100 + 100}`, () => {
const totalSeeds = playoutSeeds(1000);
for (let chunk = 0; chunk * 100 < totalSeeds; chunk++) {
test(`seeded playouts: seeds ${chunk * 100 + 1}–${chunk * 100 + 100}`, () => {
const ends: Record<string, number> = {};
for (let seed = chunk * 100 + 1; seed <= chunk * 100 + 100; seed++) {
const { state } = run(seed);

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { HWATU, SEOTDA_DECK, SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import { HWATU, SEOTDA_DECK, SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
bestChoice,
chipsTotal,
@@ -657,7 +657,7 @@ describe('random playouts', () => {
test.each(configs)('%s: finishes, chips conserved, no leaks, deterministic', (_n, opts, n, runs) => {
const options = seotda.optionsSchema.parse(opts);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
for (let i = 1; i <= runs; i++) {
for (let i = 1; i <= playoutSeeds(runs); i++) {
const a = randomPlayout(seotda, { players, options, seed: seedN(i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 30 });
expect(a.finished).toBe(true);
expect(seotda.result(a.finalState as SeotdaState)!.ranking.flat().sort()).toEqual([...players].sort());
@@ -668,8 +668,9 @@ describe('random playouts', () => {
}
}, 15_000);
test('leak property: 1,000 seeds × first hand, every viewer and every event', () => {
for (let seed = 1; seed <= 1000; seed++) {
const LEAK_SEEDS = playoutSeeds(1000);
test(`leak property: ${LEAK_SEEDS} seeds × first hand, every viewer and every event`, () => {
for (let seed = 1; seed <= LEAK_SEEDS; seed++) {
const n = 2 + (seed % 5);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const options = seotda.optionsSchema.parse({ cardMode: seed % 3 === 0 ? 'three' : 'two', dealMode: seed % 4 === 1 ? 'oneRound' : 'twoRounds' });

View File

@@ -1,6 +1,6 @@
/** Random playouts for 스플렌더 (docs/09 §7): 1,000 seeds plus a few full engine randomPlayout runs. */
/** Random playouts for 스플렌더 (docs/09 §7): 1,000 seeds (FULL_TESTS; 100 otherwise) plus a few full engine randomPlayout runs. */
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId } from '@bg/engine';
import { SeededRng, playoutSeeds, 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>[] = [
@@ -109,10 +109,11 @@ describe('random playouts (docs/09 §7)', () => {
}
}, 60_000);
test('1,000 seeded playouts: conservation, no leaks in views or events, onTimeout always legal, every game ends', () => {
const SEEDS = playoutSeeds(1000);
test(`${SEEDS} 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++) {
for (let seed = 1; seed <= SEEDS; seed++) {
const { state, steps } = run(seed);
maxSteps = Math.max(maxSteps, steps);
const res = splendor.result(state)!;
@@ -128,8 +129,9 @@ describe('random playouts (docs/09 §7)', () => {
if (!state.conceded.length) expect(turns.size).toBe(1);
}
}
expect(ends.target ?? 0).toBeGreaterThan(900);
expect(ends.resign ?? 0).toBeGreaterThan(0);
expect(ends.target ?? 0).toBeGreaterThan(SEEDS * 0.9);
// The first 2-player concede that ends the game is seed 195, so this needs the full run.
if (SEEDS === 1000) expect(ends.resign ?? 0).toBeGreaterThan(0);
expect(maxSteps).toBeLessThan(2000);
}, 60_000);
});

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
CATEGORIES,
autoCategory,
@@ -519,8 +519,10 @@ describe('random playouts (docs/09 §7)', () => {
};
}
test('1,000 seeds: finish, scores consistent, onTimeout legal, deterministic', () => {
for (let i = 1; i <= 1000; i++) {
const SEEDS = playoutSeeds(1000);
const CONCEDE_SEEDS = playoutSeeds(200);
test(`${SEEDS} seeds: finish, scores consistent, onTimeout legal, deterministic`, () => {
for (let i = 1; i <= SEEDS; i++) {
const n = i % 10 === 0 ? 8 : 1 + (i % 4);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = yacht.optionsSchema.parse({
@@ -539,8 +541,8 @@ describe('random playouts (docs/09 §7)', () => {
}
}, 60_000);
test('200 seeds with random concedes and timeouts', () => {
for (let i = 1; i <= 200; i++) {
test(`${CONCEDE_SEEDS} seeds with random concedes and timeouts`, () => {
for (let i = 1; i <= CONCEDE_SEEDS; i++) {
const players = Array.from({ length: 1 + (i % 5) }, (_, k) => `P${k}`);
const r = SeededRng.fromSeed(seedN(i));
const chooser = SeededRng.fromSeed(seedN(i + 999));

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine';
import { SeededRng, playoutSeeds, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine';
import {
FLAT_P,
advance,
@@ -661,9 +661,10 @@ function invariant(st: unknown): void {
}
describe('random playouts', () => {
test('1,000 seeds: finish within the step limit, invariants hold, onTimeout always legal, deterministic, replayable', () => {
const SEEDS = playoutSeeds(1000);
test(`${SEEDS} seeds: finish within the step limit, invariants hold, onTimeout always legal, deterministic, replayable`, () => {
let steps = 0;
for (let i = 1; i <= 1000; i++) {
for (let i = 1; i <= SEEDS; i++) {
const cfg = CONFIGS[i % CONFIGS.length]!;
const options = yut.optionsSchema.parse(cfg.opts);
const players = PLAYERS.slice(0, cfg.n);
@@ -687,7 +688,7 @@ describe('random playouts', () => {
expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState));
}
}
expect(steps).toBeGreaterThan(1000 * 20);
expect(steps).toBeGreaterThan(SEEDS * 20);
}, 120_000);
test('apply is fast: p99 under 5 ms (the per-step cap above only guards against GC/scheduler spikes)', () => {

View File

@@ -10,6 +10,8 @@ const userId = z.string().min(1).max(64);
export const ClientMessage = z.discriminatedUnion('t', [
z.object({ t: z.literal('join'), code, as: z.enum(['player', 'spectator']).default('player') }),
z.object({ t: z.literal('leave') }),
/** Left the room page without leaving the room: seat kept, no more room messages until the next join. */
z.object({ t: z.literal('detach') }),
z.object({ t: z.literal('seat'), seat: z.number().int().min(0).max(31).optional() }),
z.object({ t: z.literal('unseat') }),
z.object({ t: z.literal('ready'), ready: z.boolean() }),