컴퓨터 방 자동 정리 + 게스트 계정 삭제(로그아웃·하루 미접속) + 테스트 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

@@ -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 # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영

View File

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

View File

@@ -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

View File

@@ -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';

View File

@@ -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<typeof boot>;
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<Client> {
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<string> {
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);
});
});

View File

@@ -27,6 +27,8 @@ export interface HttpDeps {
roles: Roles;
settings: SettingsStore;
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
/** 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 });
});

View File

@@ -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();

View File

@@ -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<string>();
@@ -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) ||

View File

@@ -76,7 +76,8 @@ export interface RoomDeps {
users: { publicUser(id: string): PublicUser | null };
now?: () => number;
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => 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<string>();
presence = new Map<string, Presence>();
conns = new Map<string, Conn>();
/** Seated people still on the site but away from the room page (no room messages; still count as connected). */
detached = new Map<string, Conn>();
kicked = new Set<string>();
/** Players who left during a game; their seat is freed when the game ends. */
leftDuringGame = new Set<string>();
@@ -108,6 +111,8 @@ export class Room {
game: GameSlot | null = null;
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
lastActiveAt: number;
/** When the last connection went away (null while someone is connected). */
emptySince: number | null;
private timer: ReturnType<typeof setTimeout> | 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 {

View File

@@ -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<string, unknown>) => 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<Response> {

View File

@@ -15,7 +15,7 @@ export function tempDbPath(): string {
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
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 };
}

View File

@@ -19,6 +19,8 @@ export interface GatewayDeps {
me(userId: string): PublicUser | null;
now?: () => number;
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => 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;

View File

@@ -104,7 +104,7 @@ function AccountMenu() {
{confirm && (
<ConfirmSheet
title="로그아웃할까요?"
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
body={me.kind === 'guest' ? '게스트 계정은 로그아웃하면 이름과 기록이 모두 지워져요.' : undefined}
confirm="로그아웃"
danger={me.kind === 'guest'}
onConfirm={async () => {

View File

@@ -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;

View File

@@ -159,7 +159,7 @@ export default function MePage() {
{confirm === 'logout' && (
<ConfirmSheet
title="로그아웃할까요?"
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
body={me.kind === 'guest' ? '게스트 계정은 로그아웃하면 이름과 기록이 모두 지워져요.' : undefined}
confirm="로그아웃"
danger={me.kind === 'guest'}
onConfirm={async () => {

View File

@@ -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) {

View File

@@ -21,6 +21,7 @@
|---|---|---|
| `join` | `code`, `as: "player"\|"spectator"` | 방 입장. 이미 자리 있는 방이면 재입장 처리 |
| `leave` | — | 방 나가기(게임 중이면 확인 후, 기권 처리 규칙은 6절) |
| `detach` | — | 방 화면을 벗어남(자리 유지, 방 메시지 중단, 같은 소켓으로 `join`하면 다시 받음). docs/06 §11 |
| `seat` | `seat?: number` | 자리에 앉기/자리 바꾸기(대기실에서만) |
| `unseat` | — | 관전자로 일어나기(대기실에서만) |
| `ready` | `ready: boolean` | 준비 |

View File

@@ -39,7 +39,8 @@
## 5. 세션
- 로그인/게스트 생성 시 32바이트 무작위 토큰을 만들어 쿠키 `sid`로 주고, DB에는 SHA-256 해시를 저장.
- 수명: 게스트 365일, 디스코드 90일(사용할 때마다 연장, 하루 1번만 DB 갱신).
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 그 게스트 계정에 다시 들어올 수 없다는 경고를 띄운다.
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 게스트 계정을 지운다(방 자리, 전적, 세션 포함, 지난 대국 기록에는 '알 수 없음'으로 남음). 확인 창에서 지워진다고 알린다.
- 게스트가 사이트를 닫고 하루(24시간) 동안 오지 않으면 같은 방식으로 지운다. 서버가 1분마다 `users.last_seen_at`(요청·소켓 종료 때 갱신)을 보고 정리하며, 소켓이 열려 있거나 방 자리를 갖고 있으면 건너뛴다.
- 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제.
## 6. HTTP API

View File

@@ -95,6 +95,8 @@ interface Room {
```
- 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?").
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.
- 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다. 사람이 [방 나가기]로 모두 나가 컴퓨터만 남으면 바로 닫는다.
- 방 화면을 벗어나면(홈, 규칙 등) 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다.
## 12. 컴퓨터 상대 (혼자 연습)
- 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명).

View File

@@ -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로 재시작 → 상태 동일 | 매 커밋 |

View File

@@ -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;

View File

@@ -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",

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() }),