컴퓨터 방 자동 정리 + 게스트 계정 삭제(로그아웃·하루 미접속) + 테스트 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:
@@ -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 # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
4
apps/server/src/db/migrations/003_guest_cleanup.sql
Normal file
4
apps/server/src/db/migrations/003_guest_cleanup.sql
Normal 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';
|
||||
128
apps/server/src/guests.test.ts
Normal file
128
apps/server/src/guests.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|---|---|---|
|
||||
| `join` | `code`, `as: "player"\|"spectator"` | 방 입장. 이미 자리 있는 방이면 재입장 처리 |
|
||||
| `leave` | — | 방 나가기(게임 중이면 확인 후, 기권 처리 규칙은 6절) |
|
||||
| `detach` | — | 방 화면을 벗어남(자리 유지, 방 메시지 중단, 같은 소켓으로 `join`하면 다시 받음). docs/06 §11 |
|
||||
| `seat` | `seat?: number` | 자리에 앉기/자리 바꾸기(대기실에서만) |
|
||||
| `unseat` | — | 관전자로 일어나기(대기실에서만) |
|
||||
| `ready` | `ready: boolean` | 준비 |
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
## 5. 세션
|
||||
- 로그인/게스트 생성 시 32바이트 무작위 토큰을 만들어 쿠키 `sid`로 주고, DB에는 SHA-256 해시를 저장.
|
||||
- 수명: 게스트 365일, 디스코드 90일(사용할 때마다 연장, 하루 1번만 DB 갱신).
|
||||
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 그 게스트 계정에 다시 들어올 수 없다는 경고를 띄운다.
|
||||
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 게스트 계정을 지운다(방 자리, 전적, 세션 포함, 지난 대국 기록에는 '알 수 없음'으로 남음). 확인 창에서 지워진다고 알린다.
|
||||
- 게스트가 사이트를 닫고 하루(24시간) 동안 오지 않으면 같은 방식으로 지운다. 서버가 1분마다 `users.last_seen_at`(요청·소켓 종료 때 갱신)을 보고 정리하며, 소켓이 열려 있거나 방 자리를 갖고 있으면 건너뛴다.
|
||||
- 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제.
|
||||
|
||||
## 6. HTTP API
|
||||
|
||||
@@ -95,6 +95,8 @@ interface Room {
|
||||
```
|
||||
- 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?").
|
||||
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.
|
||||
- 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다. 사람이 [방 나가기]로 모두 나가 컴퓨터만 남으면 바로 닫는다.
|
||||
- 방 화면을 벗어나면(홈, 규칙 등) 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다.
|
||||
|
||||
## 12. 컴퓨터 상대 (혼자 연습)
|
||||
- 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명).
|
||||
|
||||
@@ -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로 재시작 → 상태 동일 | 매 커밋 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
Reference in New Issue
Block a user