From 39223bacdd32e3c10a16ab4c9bda5bd087d2df02 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Wed, 7 Oct 2026 17:10:52 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BB=B4=ED=93=A8=ED=84=B0=20=EB=B0=A9=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A0=95=EB=A6=AC=20+=20=EA=B2=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B3=84=EC=A0=95=20=EC=82=AD=EC=A0=9C(?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=C2=B7=ED=95=98=EB=A3=A8=20?= =?UTF-8?q?=EB=AF=B8=EC=A0=91=EC=86=8D)=20+=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=2010=EB=B6=84=E2=86=921=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방 화면을 벗어나면(홈·규칙) 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 --- README.md | 3 +- apps/server/src/auth/sessions.ts | 8 +- apps/server/src/auth/users.ts | 26 ++++ .../src/db/migrations/003_guest_cleanup.sql | 4 + apps/server/src/guests.test.ts | 128 ++++++++++++++++++ apps/server/src/http/app.ts | 7 +- apps/server/src/rooms/bots.room.test.ts | 85 ++++++++++++ apps/server/src/rooms/manager.ts | 19 +++ apps/server/src/rooms/room.ts | 43 +++++- apps/server/src/server.ts | 26 +++- apps/server/src/test-utils.ts | 2 +- apps/server/src/ws/gateway.ts | 20 +++ apps/web/src/components/SiteHeader.tsx | 2 +- apps/web/src/net/socket.ts | 7 + apps/web/src/pages/MePage.tsx | 2 +- apps/web/src/pages/RoomPage.tsx | 4 +- docs/03-realtime-protocol.md | 1 + docs/05-accounts-auth.md | 3 +- docs/06-lobby-rooms.md | 2 + docs/10-testing.md | 2 +- e2e/bots.e2e.ts | 47 +++++++ package.json | 3 +- packages/engine/src/testing.ts | 10 ++ packages/games/src/bang/bang.test.ts | 14 +- .../games/src/catchmind/catchmind.test.ts | 12 +- .../cockroach-poker/cockroach-poker.test.ts | 15 +- packages/games/src/dalmuti/dalmuti.test.ts | 12 +- .../src/davinci-code/davinci-code.test.ts | 9 +- packages/games/src/dobble/dobble.test.ts | 13 +- packages/games/src/gostop/gostop.test.ts | 14 +- .../games/src/halli-galli/halli-galli.test.ts | 13 +- packages/games/src/janggi/janggi.test.ts | 4 +- .../games/src/las-vegas/las-vegas.test.ts | 16 ++- packages/games/src/mafia/mafia.test.ts | 14 +- packages/games/src/mahjong/playout-3.test.ts | 22 +-- packages/games/src/mahjong/playout-4h.test.ts | 23 ++-- packages/games/src/mahjong/playout-4t.test.ts | 18 ++- packages/games/src/marble/marble.test.ts | 17 ++- .../games/src/ninety-nine/ninety-nine.test.ts | 11 +- packages/games/src/omok/omok.test.ts | 5 +- packages/games/src/one-card/one-card.test.ts | 14 +- packages/games/src/poker/poker.test.ts | 8 +- packages/games/src/quoridor/quoridor.test.ts | 17 ++- packages/games/src/rummikub/rummikub.test.ts | 13 +- packages/games/src/seotda/seotda.test.ts | 9 +- .../src/splendor/splendor.playout.test.ts | 14 +- packages/games/src/yacht/yacht.test.ts | 12 +- packages/games/src/yut/yut.test.ts | 9 +- packages/shared/src/protocol.ts | 2 + 49 files changed, 629 insertions(+), 155 deletions(-) create mode 100644 apps/server/src/db/migrations/003_guest_cleanup.sql create mode 100644 apps/server/src/guests.test.ts diff --git a/README.md b/README.md index 216bffb..fd99dc1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록 ## 확인 ```bash bun run typecheck -bun run test # 규칙·서버 통합·방 단위 테스트 (무작위 대국이 길어 제한 60초) +bun run test # 규칙·서버 통합·방 단위 테스트 (4개 병렬, 무작위 대국은 시드 1/10만, 1분 안쪽) +bun run test:full # 배포 전 전체: 무작위 대국 시드 전부(1,000개 등), 순차 실행이라 10분쯤 (cd apps/web && bunx vite build) # 웹 빌드 bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요) bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영 diff --git a/apps/server/src/auth/sessions.ts b/apps/server/src/auth/sessions.ts index cf17767..32a3b21 100644 --- a/apps/server/src/auth/sessions.ts +++ b/apps/server/src/auth/sessions.ts @@ -3,6 +3,8 @@ import type { Database } from 'bun:sqlite'; const DAY = 24 * 3600_000; export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const; export const SESSION_COOKIE = 'sid'; +/** users.last_seen_at is written at most this often per user. */ +const SEEN_EVERY = 60_000; export function randomToken(bytes = 32): string { const buf = new Uint8Array(bytes); @@ -30,8 +32,8 @@ export class Sessions { resolve(token: string, now = Date.now()): string | null { const hash = sha256Hex(token); const row = this.db - .query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>( - `SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id + .query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member'; last_seen_at: number }, [string]>( + `SELECT s.user_id, s.expires_at, s.last_used_at, u.kind, u.last_seen_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`, ) .get(hash); @@ -45,6 +47,8 @@ export class Sessions { .query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?') .run(now, now + SESSION_TTL[row.kind], hash); } + // Idle guests are deleted after a day (docs/05 §4), so remember when the user was last here. + if (now - row.last_seen_at > SEEN_EVERY) this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, row.user_id); return row.user_id; } diff --git a/apps/server/src/auth/users.ts b/apps/server/src/auth/users.ts index d7742b1..5fa29ef 100644 --- a/apps/server/src/auth/users.ts +++ b/apps/server/src/auth/users.ts @@ -150,6 +150,32 @@ export class Users { this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId); } + /** Guests not seen since `before`, oldest first. */ + idleGuests(before: number, limit = 500): string[] { + return this.db + .query<{ id: string }, [number, number]>( + "SELECT id FROM users WHERE kind = 'guest' AND deleted_at IS NULL AND last_seen_at < ? ORDER BY last_seen_at LIMIT ?", + ) + .all(before, limit) + .map((r) => r.id); + } + + /** + * Removes a guest account for good (logout, or idle for a day): the user row, its stats and sessions. + * Past games keep the seat (shown as an unknown player). Returns false for non-guests. + */ + deleteGuest(userId: string): boolean { + return this.db.transaction(() => { + const row = this.db.query<{ kind: string }, [string]>('SELECT kind FROM users WHERE id = ?').get(userId); + if (row?.kind !== 'guest') return false; + this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(userId); + this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId); + this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId); + this.db.query('DELETE FROM users WHERE id = ?').run(userId); + return true; + })(); + } + softDelete(userId: string, now = Date.now()): void { this.db.transaction(() => { this.db diff --git a/apps/server/src/db/migrations/003_guest_cleanup.sql b/apps/server/src/db/migrations/003_guest_cleanup.sql new file mode 100644 index 0000000..fdb9513 --- /dev/null +++ b/apps/server/src/db/migrations/003_guest_cleanup.sql @@ -0,0 +1,4 @@ +-- Idle guests are deleted a day after their last visit (docs/05 §4). +CREATE INDEX users_guest_seen ON users(kind, last_seen_at); +-- last_seen_at was not kept up to date before this migration: start every guest's idle clock now. +UPDATE users SET last_seen_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000 WHERE kind = 'guest'; diff --git a/apps/server/src/guests.test.ts b/apps/server/src/guests.test.ts new file mode 100644 index 0000000..b8bce30 --- /dev/null +++ b/apps/server/src/guests.test.ts @@ -0,0 +1,128 @@ +/** Guest accounts are deleted on logout and after a day away (docs/05 §5); leaving a room page detaches. */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { Client, api, boot, guest, sleep } from './test-utils'; +import { GUEST_IDLE_MS } from './server'; + +type Booted = ReturnType; +const running: Booted[] = []; +const clients: Client[] = []; + +afterEach(async () => { + for (const c of clients.splice(0)) c.close(); + for (const s of running.splice(0)) await s.stop(); +}); + +function start(): Booted { + const s = boot(); + running.push(s); + return s; +} + +async function connect(s: Booted, g: { cookie: string; id: string }): Promise { + const c = await new Client(s.base, g.cookie, g.id).connect(); + clients.push(c); + return c; +} + +async function practiceRoom(s: Booted, c: Client): Promise { + const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: c.cookie, body: JSON.stringify({ gameId: 'omok', practice: true }) }); + expect(r.res.status).toBe(200); + c.send({ t: 'join', code: r.body.code, as: 'player' }); + await c.waitFor((m) => m.t === 'room'); + return r.body.code; +} + +const userRow = (s: Booted, id: string) => s.db.query('SELECT id FROM users WHERE id = ?').get(id); +const setSeen = (s: Booted, id: string, at: number) => s.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(at, id); + +describe('guest logout', () => { + test('deletes the guest: account, sessions, seat (the practice room closes) and open sockets', async () => { + const s = start(); + const g = await guest(s.base, '지울게스트'); + const c = await connect(s, g); + const code = await practiceRoom(s, c); + expect(s.rooms.get(code)).toBeDefined(); + const out = await api(s.base, '/api/auth/logout', { method: 'POST', cookie: g.cookie }); + expect(out.res.status).toBe(200); + expect(userRow(s, g.id)).toBeNull(); + expect(s.db.query('SELECT 1 FROM sessions WHERE user_id = ?').get(g.id)).toBeNull(); + expect(s.rooms.get(code)).toBeUndefined(); + for (let i = 0; i < 50 && !c.closed; i++) await sleep(10); + expect(c.closed).not.toBeNull(); + expect((await api(s.base, '/api/me', { cookie: g.cookie })).body.me).toBeNull(); + }); + + test('a member logging out keeps the account', async () => { + const s = start(); + const g = await guest(s.base, '회원'); + s.users.promoteToMember(g.id, null); + await api(s.base, '/api/auth/logout', { method: 'POST', cookie: g.cookie }); + expect(userRow(s, g.id)).not.toBeNull(); + }); +}); + +describe('idle guests', () => { + test('a guest away for a day is deleted; one still on the site, holding a seat, or seen recently is kept', async () => { + const s = start(); + const old = Date.now() - GUEST_IDLE_MS - 60_000; + const gone = await guest(s.base, '떠난게스트'); + const online = await guest(s.base, '접속중'); + const seated = await guest(s.base, '자리있음'); + const recent = await guest(s.base, '최근'); + await connect(s, online); + const sc = await connect(s, seated); + // A human lobby (no computers): the seat survives the socket closing. + const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: seated.cookie, body: JSON.stringify({ gameId: 'omok' }) }); + sc.send({ t: 'join', code: r.body.code, as: 'player' }); + await sc.waitFor((m) => m.t === 'room'); + sc.close(); + for (let i = 0; i < 50 && !sc.closed; i++) await sleep(10); + await sleep(20); + for (const g of [gone, online, seated]) setSeen(s, g.id, old); + s.purgeIdleGuests(); + expect(userRow(s, gone.id)).toBeNull(); + expect(userRow(s, online.id)).not.toBeNull(); + expect(userRow(s, seated.id)).not.toBeNull(); + expect(userRow(s, recent.id)).not.toBeNull(); + // Coming back counts as activity. + setSeen(s, recent.id, old); + expect((await api(s.base, '/api/me', { cookie: recent.cookie })).body.me.id).toBe(recent.id); + s.purgeIdleGuests(); + expect(userRow(s, recent.id)).not.toBeNull(); + }); +}); + +describe('leaving the room page', () => { + test('detach keeps the seat but the room no longer counts the connection', async () => { + const s = start(); + const g = await guest(s.base, '홈으로'); + const c = await connect(s, g); + const code = await practiceRoom(s, c); + const room = s.rooms.get(code)!; + expect(room.conns.size).toBe(1); + c.send({ t: 'detach' }); + for (let i = 0; i < 50 && room.conns.size; i++) await sleep(10); + expect(room.conns.size).toBe(0); + expect(room.seatOf(g.id)).toBeGreaterThanOrEqual(0); + expect(room.emptySince).not.toBeNull(); + // Still on the site: counts as connected (no "연결 끊김", no auto moves). + expect(room.presence.get(g.id)?.connected).toBe(true); + // Detaching twice is harmless. + c.send({ t: 'detach' }); + c.send({ t: 'ping', ts: 1 }); + await c.waitFor((m) => m.t === 'pong'); + expect(c.messages.some((m) => m.t === 'error')).toBe(false); + // Coming back to the page re-attaches the same socket. + c.send({ t: 'join', code, as: 'player' }); + for (let i = 0; i < 50 && !room.conns.size; i++) await sleep(10); + expect(room.conns.size).toBe(1); + expect(room.emptySince).toBeNull(); + // Closing the site while detached: the room learns the person went offline. + c.send({ t: 'detach' }); + for (let i = 0; i < 50 && room.conns.size; i++) await sleep(10); + c.close(); + for (let i = 0; i < 50 && room.presence.get(g.id)?.connected; i++) await sleep(10); + expect(room.presence.get(g.id)?.connected).toBe(false); + expect(room.detached.size).toBe(0); + }); +}); diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 15daaa6..77f6b59 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -27,6 +27,8 @@ export interface HttpDeps { roles: Roles; settings: SettingsStore; admin: Omit; + /** Deletes a guest account everywhere (rooms, sockets, DB). */ + removeGuest: (userId: string) => void; } type Env = { Variables: { userId: string | null } }; @@ -169,7 +171,10 @@ export function createHttpApp(d: HttpDeps) { app.post('/api/auth/logout', (c) => { const token = getCookie(c, SESSION_COOKIE); - if (token) d.sessions.revoke(token); + const u = requireUser(c); + // A guest has no way to log back in, so logging out deletes the account (docs/05 §4). + if (u && d.users.get(u)?.kind === 'guest') d.removeGuest(u); + else if (token) d.sessions.revoke(token); deleteCookie(c, SESSION_COOKIE, { path: '/' }); return c.json({ ok: true }); }); diff --git a/apps/server/src/rooms/bots.room.test.ts b/apps/server/src/rooms/bots.room.test.ts index b030252..e1319dd 100644 --- a/apps/server/src/rooms/bots.room.test.ts +++ b/apps/server/src/rooms/bots.room.test.ts @@ -248,6 +248,91 @@ describe('playing with bots', () => { }); }); +describe('practice rooms close once nobody is there', () => { + function finishedPractice() { + const h = harness(); + const room = h.manager.createPractice('A', 'omok') as Room; + rooms.push(room); + const a = h.conn('A'); + room.join(a, 'player'); + expect(room.start('A')).toBeNull(); + room.act('A', 1, { type: 'resign' }); + expect(room.status).toBe('finished'); + return { ...h, room, a }; + } + + test('leaving on purpose (only computers left) closes the room right away', () => { + const { room, manager, store } = finishedPractice(); + room.leave('A'); + expect(manager.get(room.code)).toBeUndefined(); + expect(store.openRooms()).toHaveLength(0); + expect(manager.activeRoomFor('A')).toBeNull(); + }); + + test('leaving mid-game also closes it', () => { + const h = harness(); + const room = h.manager.createPractice('A', 'omok') as Room; + rooms.push(room); + room.join(h.conn('A'), 'player'); + room.start('A'); + room.leave('A'); + expect(h.manager.get(room.code)).toBeUndefined(); + }); + + test('going to another page (seat kept) or closing the site: closed a minute later by the sweep', () => { + const { room, a, manager, clock, conn } = finishedPractice(); + room.disconnect(a); + manager.sweep(); + expect(manager.get(room.code)).toBe(room); // a refresh reconnects within seconds + clock.advance(30_000); + room.join(conn('A'), 'player'); + room.disconnect(room.conns.get('A')!); + clock.advance(59_000); + manager.sweep(); + expect(manager.get(room.code)).toBe(room); // the minute counts from the last disconnect + clock.advance(1_000); + manager.sweep(); + expect(manager.get(room.code)).toBeUndefined(); + }); + + test('a running practice game with nobody connected waits 30 minutes', () => { + const h = harness(); + const room = h.manager.createPractice('A', 'omok') as Room; + rooms.push(room); + const a = h.conn('A'); + room.join(a, 'player'); + room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } }); + room.start('A'); + room.disconnect(a); + h.clock.advance(29 * 60_000); + h.manager.sweep(); + expect(h.manager.get(room.code)).toBe(room); + h.clock.advance(60_000); + h.manager.sweep(); + expect(h.manager.get(room.code)).toBeUndefined(); + }); + + test('rooms without computers keep the old timeouts; someone still connected keeps any room', () => { + const h = harness(); + const room = h.create('A'); + const a = h.conn('A'); + room.join(a, 'player'); + room.join(h.conn('B'), 'player'); + room.leave('B'); + expect(h.manager.get(room.code)).toBe(room); + const practice = h.manager.createPractice('C', 'omok') as Room; + rooms.push(practice); + practice.join(h.conn('C'), 'player'); + h.clock.advance(5 * 60_000); + h.manager.sweep(); + expect(h.manager.get(practice.code)).toBe(practice); + room.disconnect(a); + h.clock.advance(5 * 60_000); + h.manager.sweep(); + expect(h.manager.get(room.code)).toBe(room); // empty lobby: 10 minutes + }); +}); + describe('restore after restart', () => { test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => { const { manager, conn, store, clock, users } = harness(); diff --git a/apps/server/src/rooms/manager.ts b/apps/server/src/rooms/manager.ts index 0f0e102..e42e937 100644 --- a/apps/server/src/rooms/manager.ts +++ b/apps/server/src/rooms/manager.ts @@ -10,6 +10,9 @@ export const MAX_ROOMS = 2000; export const MAX_ROOMS_PER_USER = 3; const EMPTY_LOBBY_TTL = 10 * 60_000; const FINISHED_TTL = 30 * 60_000; +/** Rooms with computer players nobody is looking at: closed this long after the last person went away. */ +const PRACTICE_IDLE_TTL = 60_000; +const PRACTICE_GAME_TTL = 30 * 60_000; export interface ManagerDeps { store: RoomStore; @@ -42,6 +45,10 @@ export class RoomManager { log: this.deps.log, settings: this.deps.settings, botDelay: this.deps.botDelay, + onEmpty: (room, why) => { + // Everyone left a room with computer players on purpose: nobody can come back to it, close it now. + if (why === 'leave' && this.byCode.get(room.code) === room && room.hasBots() && !room.hasPeopleSeated()) this.close(room); + }, onSeatChange: (room, userId, seated) => { if (isBotId(userId)) return; const set = this.seatIndex.get(userId) ?? new Set(); @@ -150,6 +157,11 @@ export class RoomManager { } } + /** Frees every seat the user holds (account removed). */ + leaveAll(userId: string): void { + for (const code of [...(this.seatIndex.get(userId) ?? [])]) this.byCode.get(code)?.leave(userId); + } + publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] { return this.all() // Rooms with computer players are practice rooms: never listed, never filled by quick play. @@ -198,6 +210,13 @@ export class RoomManager { for (const room of this.all()) { if (room.conns.size > 0) continue; const idle = now - room.lastActiveAt; + if (room.hasBots()) { + // Practice rooms are private and only for the people who made them: once nobody is looking, close soon. + const empty = now - (room.emptySince ?? now); + const ttl = room.status === 'playing' || room.status === 'paused' ? PRACTICE_GAME_TTL : PRACTICE_IDLE_TTL; + if (empty >= ttl) this.close(room); + continue; + } const closable = (room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) || (room.status === 'finished' && idle > FINISHED_TTL) || diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index 6f4f5fc..e810214 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -76,7 +76,8 @@ export interface RoomDeps { users: { publicUser(id: string): PublicUser | null }; now?: () => number; log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record) => void; - onEmpty?: (room: Room) => void; + /** The last connection went away: `leave` = someone left on purpose, `disconnect` = a socket dropped or the page was left. */ + onEmpty?: (room: Room, why: 'leave' | 'disconnect') => void; onSeatChange?: (room: Room, userId: string, seated: boolean) => void; /** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */ settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings }; @@ -97,6 +98,8 @@ export class Room { spectators = new Set(); presence = new Map(); conns = new Map(); + /** Seated people still on the site but away from the room page (no room messages; still count as connected). */ + detached = new Map(); kicked = new Set(); /** Players who left during a game; their seat is freed when the game ends. */ leftDuringGame = new Set(); @@ -108,6 +111,8 @@ export class Room { game: GameSlot | null = null; chatLog: Extract[] = []; lastActiveAt: number; + /** When the last connection went away (null while someone is connected). */ + emptySince: number | null; private timer: ReturnType | null = null; /** Log entries of streaming actions not yet written (flushed with the next snapshot). */ private pendingLogs: LogEntry[] = []; @@ -130,6 +135,7 @@ export class Room { this.seats = new Array(config.maxPlayers).fill(null); this.now = deps.now ?? Date.now; this.lastActiveAt = this.now(); + this.emptySince = this.now(); const room = this; this.bots = new BotDriver( { @@ -205,6 +211,11 @@ export class Room { return this.seats.some((s) => !!s && isBotId(s.userId)); } + /** A person still holds a seat (not counting people who already left a running game). */ + hasPeopleSeated(): boolean { + return this.seats.some((s) => !!s && !isBotId(s.userId) && !this.leftDuringGame.has(s.userId)); + } + // ---------------------------------------------------------------- snapshot snapshot(): RoomSnapshot { @@ -409,6 +420,8 @@ export class Room { this.leftDuringGame.delete(u); this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now }); this.conns.set(u, conn); + this.detached.delete(u); + this.emptySince = null; if (wasSeated && prev && !prev.connected && this.inGame(u)) { this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`); } @@ -422,11 +435,25 @@ export class Room { return null; } + /** + * Left the room page but still on the site (홈, 규칙…): no more room messages and the room no longer counts the + * connection, but the seat stays and the person still counts as connected (no "연결 끊김", no auto moves). + */ + detach(conn: Conn): void { + const u = conn.userId; + if (this.conns.get(u) !== conn) return; + if (this.spectators.has(u)) return this.disconnect(conn); + this.conns.delete(u); + this.detached.set(u, conn); + if (this.conns.size === 0) this.becameEmpty('disconnect'); + } + /** Connection dropped (not an intentional leave). */ disconnect(conn: Conn): void { const u = conn.userId; - if (this.conns.get(u) !== conn) return; - this.conns.delete(u); + if (this.detached.get(u) === conn) this.detached.delete(u); + else if (this.conns.get(u) === conn) this.conns.delete(u); + else return; const p = this.presence.get(u); if (p) { p.connected = false; @@ -440,12 +467,13 @@ export class Room { } this.broadcastRoom(); this.scheduleTimer(); - if (this.conns.size === 0) this.deps.onEmpty?.(this); + if (this.conns.size === 0) this.becameEmpty('disconnect'); } leave(userId: string): void { const conn = this.conns.get(userId); this.conns.delete(userId); + this.detached.delete(userId); this.spectators.delete(userId); this.presence.delete(userId); const seat = this.seatOf(userId); @@ -471,7 +499,12 @@ export class Room { this.persist({ actorId: userId, kind: 'leave', data: null }); this.broadcastRoom(); conn?.send({ t: 'bye', reason: 'closed' }); - if (this.conns.size === 0) this.deps.onEmpty?.(this); + if (this.conns.size === 0) this.becameEmpty('leave'); + } + + private becameEmpty(why: 'leave' | 'disconnect'): void { + this.emptySince ??= this.now(); + this.deps.onEmpty?.(this, why); } private pickNewHost(): void { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index cfc94cd..ed486d6 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -16,6 +16,9 @@ import { Gateway, type WsData } from './ws/gateway'; import { Audit, Roles } from './admin/roles'; import { SettingsStore } from './admin/settings'; +/** A guest who closed the site and stayed away this long is deleted. */ +export const GUEST_IDLE_MS = 24 * 3600_000; + export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record) => void; export const jsonLog: LogFn = (level, msg, extra) => { @@ -31,6 +34,8 @@ export interface StartOptions { discordExchange?: HttpDeps['discordExchange']; /** Computer players' thinking time (tests shorten it). */ botDelay?: (choices: number, withPeople: boolean) => number; + /** Upper bound for waiting on Bun's server.stop() (tests shorten it). */ + stopWaitMs?: number; } export function startServer(opts: StartOptions) { @@ -53,7 +58,20 @@ export function startServer(opts: StartOptions) { const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings, botDelay: opts.botDelay }); const restored = rooms.restoreAll(); log('info', 'rooms restored', restored); - const gateway = new Gateway({ rooms, me: publicUser, log }); + const gateway = new Gateway({ rooms, me: publicUser, log, onClose: (id) => users.touch(id) }); + const removeGuest = (id: string) => { + rooms.leaveAll(id); + gateway.closeUser(id); + if (users.deleteGuest(id)) log('info', 'guest removed', { user: id }); + }; + /** Guests who closed the site and stayed away a day are deleted (docs/05 §4). */ + const purgeIdleGuests = (now = Date.now()) => { + for (const id of users.idleGuests(now - GUEST_IDLE_MS)) { + // Still on the site (socket open) or still holding a seat: not idle. + if (gateway.isOnline(id) || rooms.activeRoomFor(id)) continue; + removeGuest(id); + } + }; let ready = true; const ipOf = (req: Request) => @@ -72,6 +90,7 @@ export function startServer(opts: StartOptions) { health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }), roles, settings, + removeGuest, admin: { audit, games: GAMES, @@ -128,6 +147,7 @@ export function startServer(opts: StartOptions) { try { rooms.sweep(); sessions.purgeExpired(); + purgeIdleGuests(); store.purgeOldLogs(); } catch (err) { log('error', 'sweep failed', { err: String(err) }); @@ -141,11 +161,11 @@ export function startServer(opts: StartOptions) { rooms.shutdown(); // Bun's stop() promise can stay pending after server-initiated WS closes even though the // listener is already closed, so bound the wait. - await Promise.race([server.stop(true), Bun.sleep(1000)]); + await Promise.race([server.stop(true), Bun.sleep(opts.stopWaitMs ?? 1000)]); db.close(); }; - return { server, stop, rooms, users, sessions, db, gateway, settings, roles }; + return { server, stop, rooms, users, sessions, db, gateway, settings, roles, purgeIdleGuests }; } async function serveFile(root: string, pathname: string): Promise { diff --git a/apps/server/src/test-utils.ts b/apps/server/src/test-utils.ts index a479476..601a72d 100644 --- a/apps/server/src/test-utils.ts +++ b/apps/server/src/test-utils.ts @@ -15,7 +15,7 @@ export function tempDbPath(): string { export function boot(dbPath = tempDbPath(), extra: Partial = {}) { const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN }); - const app = startServer({ config, log: () => {}, staticDir: null, ...extra }); + const app = startServer({ config, log: () => {}, staticDir: null, stopWaitMs: 50, ...extra }); const base = `http://localhost:${app.server.port}`; return { ...app, base, dbPath, config }; } diff --git a/apps/server/src/ws/gateway.ts b/apps/server/src/ws/gateway.ts index 6faaccd..253340c 100644 --- a/apps/server/src/ws/gateway.ts +++ b/apps/server/src/ws/gateway.ts @@ -19,6 +19,8 @@ export interface GatewayDeps { me(userId: string): PublicUser | null; now?: () => number; log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record) => void; + /** A socket closed (the user may have left the site). */ + onClose?: (userId: string) => void; } export const MAX_CONNS_PER_USER = 5; @@ -118,6 +120,7 @@ export class Gateway { if (this.byUser.get(ws.data.userId)?.size === 0) this.byUser.delete(ws.data.userId); const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined; if (room && ws.data.conn) room.disconnect(ws.data.conn); + this.deps.onClose?.(ws.data.userId); }, }; @@ -143,6 +146,11 @@ export class Gateway { return; } const room: Room | undefined = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined; + if (m.t === 'detach') { + // ws.data.room stays: when this socket closes, the room still learns that the person went offline. + if (room && ws.data.conn) room.detach(ws.data.conn); + return; + } if (!room || room.conns.get(u) !== ws.data.conn) return err('먼저 방에 들어가 주세요.', 'not-in-room'); let e: string | null = null; switch (m.t) { @@ -209,6 +217,18 @@ export class Gateway { } } + isOnline(userId: string): boolean { + return (this.byUser.get(userId)?.size ?? 0) > 0; + } + + /** The account is gone (guest logged out or expired): close its sockets quietly. */ + closeUser(userId: string): void { + for (const ws of [...(this.byUser.get(userId) ?? [])]) { + this.send(ws, { t: 'bye', reason: 'closed' }); + ws.close(4401, 'logged out'); + } + } + /** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */ shutdown(): void { this.closing = true; diff --git a/apps/web/src/components/SiteHeader.tsx b/apps/web/src/components/SiteHeader.tsx index d071be3..871fb8f 100644 --- a/apps/web/src/components/SiteHeader.tsx +++ b/apps/web/src/components/SiteHeader.tsx @@ -104,7 +104,7 @@ function AccountMenu() { {confirm && ( { diff --git a/apps/web/src/net/socket.ts b/apps/web/src/net/socket.ts index 60a32d2..d5f25f3 100644 --- a/apps/web/src/net/socket.ts +++ b/apps/web/src/net/socket.ts @@ -220,6 +220,13 @@ class GameSocket { useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null }); } + /** Left the room page (홈, 규칙…) without leaving: the seat stays, but the room no longer counts us as there. */ + detach(code: string): void { + if (useRoom.getState().code !== code) return; + this.send({ t: 'detach' }); + useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null }); + } + /** Sends a game action; returns false when not connected. */ act(a: unknown): boolean { const cs = ++this.cs; diff --git a/apps/web/src/pages/MePage.tsx b/apps/web/src/pages/MePage.tsx index ba0178c..7d3c334 100644 --- a/apps/web/src/pages/MePage.tsx +++ b/apps/web/src/pages/MePage.tsx @@ -159,7 +159,7 @@ export default function MePage() { {confirm === 'logout' && ( { diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 0ca4b75..917635e 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -32,7 +32,9 @@ export default function RoomPage() { }, [code, me]); useEffect(() => { - if (me && /^\d{6}$/.test(code)) socket.join(code); + if (!me || !/^\d{6}$/.test(code)) return; + socket.join(code); + return () => socket.detach(code); }, [code, me]); if (!me) { diff --git a/docs/03-realtime-protocol.md b/docs/03-realtime-protocol.md index 39079ec..455c2f8 100644 --- a/docs/03-realtime-protocol.md +++ b/docs/03-realtime-protocol.md @@ -21,6 +21,7 @@ |---|---|---| | `join` | `code`, `as: "player"\|"spectator"` | 방 입장. 이미 자리 있는 방이면 재입장 처리 | | `leave` | — | 방 나가기(게임 중이면 확인 후, 기권 처리 규칙은 6절) | +| `detach` | — | 방 화면을 벗어남(자리 유지, 방 메시지 중단, 같은 소켓으로 `join`하면 다시 받음). docs/06 §11 | | `seat` | `seat?: number` | 자리에 앉기/자리 바꾸기(대기실에서만) | | `unseat` | — | 관전자로 일어나기(대기실에서만) | | `ready` | `ready: boolean` | 준비 | diff --git a/docs/05-accounts-auth.md b/docs/05-accounts-auth.md index d05f1f9..f48e09e 100644 --- a/docs/05-accounts-auth.md +++ b/docs/05-accounts-auth.md @@ -39,7 +39,8 @@ ## 5. 세션 - 로그인/게스트 생성 시 32바이트 무작위 토큰을 만들어 쿠키 `sid`로 주고, DB에는 SHA-256 해시를 저장. - 수명: 게스트 365일, 디스코드 90일(사용할 때마다 연장, 하루 1번만 DB 갱신). -- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 그 게스트 계정에 다시 들어올 수 없다는 경고를 띄운다. +- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 게스트 계정을 지운다(방 자리, 전적, 세션 포함, 지난 대국 기록에는 '알 수 없음'으로 남음). 확인 창에서 지워진다고 알린다. +- 게스트가 사이트를 닫고 하루(24시간) 동안 오지 않으면 같은 방식으로 지운다. 서버가 1분마다 `users.last_seen_at`(요청·소켓 종료 때 갱신)을 보고 정리하며, 소켓이 열려 있거나 방 자리를 갖고 있으면 건너뛴다. - 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제. ## 6. HTTP API diff --git a/docs/06-lobby-rooms.md b/docs/06-lobby-rooms.md index 79e4e0d..c62074c 100644 --- a/docs/06-lobby-rooms.md +++ b/docs/06-lobby-rooms.md @@ -95,6 +95,8 @@ interface Room { ``` - 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?"). - 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개. +- 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다. 사람이 [방 나가기]로 모두 나가 컴퓨터만 남으면 바로 닫는다. +- 방 화면을 벗어나면(홈, 규칙 등) 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다. ## 12. 컴퓨터 상대 (혼자 연습) - 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명). diff --git a/docs/10-testing.md b/docs/10-testing.md index c01754a..eb98a70 100644 --- a/docs/10-testing.md +++ b/docs/10-testing.md @@ -9,7 +9,7 @@ | 종류 | 도구 | 대상 | 언제 | |---|---|---|---| | 규칙 단위 | `bun test` | `packages/games/*` | 매 커밋 | -| 무작위 대국/불변식 | `bun test` + `engine/testing.ts` | 모든 게임(시드 1,000개, CI에서는 200개) | 매 커밋 | +| 무작위 대국/불변식 | `bun test` + `engine/testing.ts` | 모든 게임. 평소 `bun run test`는 `playoutSeeds()`로 시드 1/10(예: 1,000 → 100)만, `bun run test:full`(`FULL_TESTS=1`)은 전부 | 매 커밋(전체는 큰 변경·배포 전) | | 정보 유출 | 같은 도구 | 숨김 정보 게임 | 매 커밋 | | 서버 통합 | `bun test` + 실제 WS 클라이언트 | 인증, 방 만들기/입장, 행동, 재접속, 강퇴, 한 판 더 | 매 커밋 | | 복구 | 통합 테스트 | 서버 인스턴스 종료 → 같은 DB로 재시작 → 상태 동일 | 매 커밋 | diff --git a/e2e/bots.e2e.ts b/e2e/bots.e2e.ts index 5e1a8fc..167e70b 100644 --- a/e2e/bots.e2e.ts +++ b/e2e/bots.e2e.ts @@ -160,6 +160,53 @@ try { console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`); }); + // ---------------------------------------------------------------- leaving practice rooms, guest logout + const codeOf = (p: Page) => new URL(p.url()).pathname.split('/').pop()!; + const userId = (nick: string) => app.db.query<{ id: string }, [string]>('SELECT id FROM users WHERE nickname = ?').get(nick)?.id; + + await step('오목(게임 중): ← 홈 → 자리는 남고 접속은 끊김, 아무도 안 보면 30분 뒤 정리', async () => { + const code = codeOf(a); + await a.getByRole('button', { name: '홈으로' }).click(); + await a.waitForURL(`${ORIGIN}/`); + const room = app.rooms.get(code)!; + for (let i = 0; i < 50 && room.conns.size; i++) await Bun.sleep(20); + if (room.conns.size !== 0) throw new Error(`room still counts ${room.conns.size} connection(s)`); + if (room.seatOf(userId('호랑이')!) < 0) throw new Error('seat was not kept'); + app.rooms.sweep(); + if (!app.rooms.get(code)) throw new Error('closed too early'); + room.emptySince = Date.now() - 29 * 60_000; + app.rooms.sweep(); + if (!app.rooms.get(code)) throw new Error('running practice game closed before 30 minutes'); + room.emptySince = Date.now() - 31 * 60_000; + app.rooms.sweep(); + if (app.rooms.get(code)) throw new Error('practice room nobody looks at was not closed'); + }); + + await step('원카드: 메뉴 → 방 나가기 → 방이 바로 닫힘', async () => { + const code = codeOf(b); + if (!app.rooms.get(code)) throw new Error('room missing before leaving'); + const result = b.getByRole('dialog', { name: '게임 결과' }); + if (await result.count()) await result.getByRole('button', { name: '방 나가기' }).click(); + else { + await b.getByRole('button', { name: '메뉴' }).click(); + await b.getByRole('button', { name: '🚪 방 나가기' }).click(); + await b.getByRole('button', { name: '나가기', exact: true }).click(); + } + await b.waitForURL(`${ORIGIN}/`); + for (let i = 0; i < 50 && app.rooms.get(code); i++) await Bun.sleep(20); + if (app.rooms.get(code)) throw new Error('room still open after everyone left'); + }); + + await step('게스트 로그아웃 → 계정 삭제', async () => { + const id = userId('호랑이')!; + await a.getByRole('button', { name: '내 계정: 호랑이' }).click(); + await a.getByRole('menuitem', { name: '🚪 로그아웃' }).click(); + await a.getByText('게스트 계정은 로그아웃하면 이름과 기록이 모두 지워져요.').waitFor(); + await a.getByRole('button', { name: '로그아웃', exact: true }).last().click(); + await a.getByRole('link', { name: /로그인/ }).first().waitFor(); + if (app.db.query('SELECT 1 FROM users WHERE id = ?').get(id)) throw new Error('guest row still there'); + }); + if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`); } catch (e) { failed = true; diff --git a/package.json b/package.json index d0919ad..b32d745 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dev": "bun run --filter '@bg/server' dev & bun run --filter '@bg/web' dev", "build": "bun run --filter '@bg/web' build", "typecheck": "tsc -p packages/engine && tsc -p packages/shared && tsc -p packages/games && tsc -p apps/server && tsc -p apps/web", - "test": "bun test --timeout 60000" + "test": "bun test --parallel=4 --timeout 60000", + "test:full": "FULL_TESTS=1 bun test --timeout 60000" }, "devDependencies": { "@types/bun": "1.4.2", diff --git a/packages/engine/src/testing.ts b/packages/engine/src/testing.ts index 50493b7..fd5752c 100644 --- a/packages/engine/src/testing.ts +++ b/packages/engine/src/testing.ts @@ -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 } }).process?.env; + const fullRun = !!env?.FULL_TESTS; + return fullRun ? full : Math.max(1, Math.ceil(full / 10)); +} diff --git a/packages/games/src/bang/bang.test.ts b/packages/games/src/bang/bang.test.ts index 2dfd609..dadcffc 100644 --- a/packages/games/src/bang/bang.test.ts +++ b/packages/games/src/bang/bang.test.ts @@ -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[] = [ { 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) { diff --git a/packages/games/src/catchmind/catchmind.test.ts b/packages/games/src/catchmind/catchmind.test.ts index c3d1a03..af4ecd6 100644 --- a/packages/games/src/catchmind/catchmind.test.ts +++ b/packages/games/src/catchmind/catchmind.test.ts @@ -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); diff --git a/packages/games/src/cockroach-poker/cockroach-poker.test.ts b/packages/games/src/cockroach-poker/cockroach-poker.test.ts index ccb6841..b02f351 100644 --- a/packages/games/src/cockroach-poker/cockroach-poker.test.ts +++ b/packages/games/src/cockroach-poker/cockroach-poker.test.ts @@ -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)); diff --git a/packages/games/src/dalmuti/dalmuti.test.ts b/packages/games/src/dalmuti/dalmuti.test.ts index 0d64294..aa65b3d 100644 --- a/packages/games/src/dalmuti/dalmuti.test.ts +++ b/packages/games/src/dalmuti/dalmuti.test.ts @@ -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[] = [ { 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); diff --git a/packages/games/src/davinci-code/davinci-code.test.ts b/packages/games/src/davinci-code/davinci-code.test.ts index 5e1c90d..62e68cd 100644 --- a/packages/games/src/davinci-code/davinci-code.test.ts +++ b/packages/games/src/davinci-code/davinci-code.test.ts @@ -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); diff --git a/packages/games/src/dobble/dobble.test.ts b/packages/games/src/dobble/dobble.test.ts index 31ece15..41dd29c 100644 --- a/packages/games/src/dobble/dobble.test.ts +++ b/packages/games/src/dobble/dobble.test.ts @@ -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[] = [ ]; 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 = { 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) { diff --git a/packages/games/src/gostop/gostop.test.ts b/packages/games/src/gostop/gostop.test.ts index 3d79f45..ff72e15 100644 --- a/packages/games/src/gostop/gostop.test.ts +++ b/packages/games/src/gostop/gostop.test.ts @@ -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][] = [ ]; 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 }); diff --git a/packages/games/src/halli-galli/halli-galli.test.ts b/packages/games/src/halli-galli/halli-galli.test.ts index ca5aff4..aea69cf 100644 --- a/packages/games/src/halli-galli/halli-galli.test.ts +++ b/packages/games/src/halli-galli/halli-galli.test.ts @@ -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[] = [ ]; 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([ diff --git a/packages/games/src/janggi/janggi.test.ts b/packages/games/src/janggi/janggi.test.ts index 8b9c55d..2414e6d 100644 --- a/packages/games/src/janggi/janggi.test.ts +++ b/packages/games/src/janggi/janggi.test.ts @@ -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); 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 }); diff --git a/packages/games/src/las-vegas/las-vegas.test.ts b/packages/games/src/las-vegas/las-vegas.test.ts index 60d3715..6eef393 100644 --- a/packages/games/src/las-vegas/las-vegas.test.ts +++ b/packages/games/src/las-vegas/las-vegas.test.ts @@ -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)); } diff --git a/packages/games/src/mafia/mafia.test.ts b/packages/games/src/mafia/mafia.test.ts index 34f30f7..1bbacfd 100644 --- a/packages/games/src/mafia/mafia.test.ts +++ b/packages/games/src/mafia/mafia.test.ts @@ -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 = {}; - 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); diff --git a/packages/games/src/mahjong/playout-3.test.ts b/packages/games/src/mahjong/playout-3.test.ts index 83026e6..cd2b56e 100644 --- a/packages/games/src/mahjong/playout-3.test.ts +++ b/packages/games/src/mahjong/playout-3.test.ts @@ -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); }); }); diff --git a/packages/games/src/mahjong/playout-4h.test.ts b/packages/games/src/mahjong/playout-4h.test.ts index 541f504..f2decb4 100644 --- a/packages/games/src/mahjong/playout-4h.test.ts +++ b/packages/games/src/mahjong/playout-4h.test.ts @@ -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); diff --git a/packages/games/src/mahjong/playout-4t.test.ts b/packages/games/src/mahjong/playout-4t.test.ts index 96b27cc..ac2d3b6 100644 --- a/packages/games/src/mahjong/playout-4t.test.ts +++ b/packages/games/src/mahjong/playout-4t.test.ts @@ -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); }); diff --git a/packages/games/src/marble/marble.test.ts b/packages/games/src/marble/marble.test.ts index d0db0ef..9b10847 100644 --- a/packages/games/src/marble/marble.test.ts +++ b/packages/games/src/marble/marble.test.ts @@ -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 = {}; - 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); diff --git a/packages/games/src/ninety-nine/ninety-nine.test.ts b/packages/games/src/ninety-nine/ninety-nine.test.ts index 9722061..23e310c 100644 --- a/packages/games/src/ninety-nine/ninety-nine.test.ts +++ b/packages/games/src/ninety-nine/ninety-nine.test.ts @@ -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[] = [ ]; 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) { diff --git a/packages/games/src/omok/omok.test.ts b/packages/games/src/omok/omok.test.ts index 8c73fdd..3276a34 100644 --- a/packages/games/src/omok/omok.test.ts +++ b/packages/games/src/omok/omok.test.ts @@ -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 }); diff --git a/packages/games/src/one-card/one-card.test.ts b/packages/games/src/one-card/one-card.test.ts index 4a3e711..3f508be 100644 --- a/packages/games/src/one-card/one-card.test.ts +++ b/packages/games/src/one-card/one-card.test.ts @@ -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[]> = { ], }; -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) { diff --git a/packages/games/src/poker/poker.test.ts b/packages/games/src/poker/poker.test.ts index a3221d8..595d67d 100644 --- a/packages/games/src/poker/poker.test.ts +++ b/packages/games/src/poker/poker.test.ts @@ -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, 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); diff --git a/packages/games/src/quoridor/quoridor.test.ts b/packages/games/src/quoridor/quoridor.test.ts index bc6bc70..23803fc 100644 --- a/packages/games/src/quoridor/quoridor.test.ts +++ b/packages/games/src/quoridor/quoridor.test.ts @@ -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 = {}; - 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) => { diff --git a/packages/games/src/rummikub/rummikub.test.ts b/packages/games/src/rummikub/rummikub.test.ts index 372d8a6..606da0b 100644 --- a/packages/games/src/rummikub/rummikub.test.ts +++ b/packages/games/src/rummikub/rummikub.test.ts @@ -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 = {}; for (let seed = chunk * 100 + 1; seed <= chunk * 100 + 100; seed++) { const { state } = run(seed); diff --git a/packages/games/src/seotda/seotda.test.ts b/packages/games/src/seotda/seotda.test.ts index 3e596fa..9af0b37 100644 --- a/packages/games/src/seotda/seotda.test.ts +++ b/packages/games/src/seotda/seotda.test.ts @@ -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' }); diff --git a/packages/games/src/splendor/splendor.playout.test.ts b/packages/games/src/splendor/splendor.playout.test.ts index f3342e3..6e9bced 100644 --- a/packages/games/src/splendor/splendor.playout.test.ts +++ b/packages/games/src/splendor/splendor.playout.test.ts @@ -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[] = [ @@ -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 = {}; 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); }); diff --git a/packages/games/src/yacht/yacht.test.ts b/packages/games/src/yacht/yacht.test.ts index d008e66..e1af267 100644 --- a/packages/games/src/yacht/yacht.test.ts +++ b/packages/games/src/yacht/yacht.test.ts @@ -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)); diff --git a/packages/games/src/yut/yut.test.ts b/packages/games/src/yut/yut.test.ts index 1f630c4..05a37dc 100644 --- a/packages/games/src/yut/yut.test.ts +++ b/packages/games/src/yut/yut.test.ts @@ -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)', () => { diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index d4e0eef..3d46228 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -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() }),