컴퓨터 방 자동 정리 + 게스트 계정 삭제(로그아웃·하루 미접속) + 테스트 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
|
```bash
|
||||||
bun run typecheck
|
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) # 웹 빌드
|
(cd apps/web && bunx vite build) # 웹 빌드
|
||||||
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
|
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
|
||||||
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Database } from 'bun:sqlite';
|
|||||||
const DAY = 24 * 3600_000;
|
const DAY = 24 * 3600_000;
|
||||||
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
|
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
|
||||||
export const SESSION_COOKIE = 'sid';
|
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 {
|
export function randomToken(bytes = 32): string {
|
||||||
const buf = new Uint8Array(bytes);
|
const buf = new Uint8Array(bytes);
|
||||||
@@ -30,8 +32,8 @@ export class Sessions {
|
|||||||
resolve(token: string, now = Date.now()): string | null {
|
resolve(token: string, now = Date.now()): string | null {
|
||||||
const hash = sha256Hex(token);
|
const hash = sha256Hex(token);
|
||||||
const row = this.db
|
const row = this.db
|
||||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
|
.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 FROM sessions s JOIN users u ON u.id = s.user_id
|
`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`,
|
WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`,
|
||||||
)
|
)
|
||||||
.get(hash);
|
.get(hash);
|
||||||
@@ -45,6 +47,8 @@ export class Sessions {
|
|||||||
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
|
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
|
||||||
.run(now, now + SESSION_TTL[row.kind], 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;
|
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);
|
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 {
|
softDelete(userId: string, now = Date.now()): void {
|
||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
this.db
|
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;
|
roles: Roles;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
|
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 } };
|
type Env = { Variables: { userId: string | null } };
|
||||||
@@ -169,7 +171,10 @@ export function createHttpApp(d: HttpDeps) {
|
|||||||
|
|
||||||
app.post('/api/auth/logout', (c) => {
|
app.post('/api/auth/logout', (c) => {
|
||||||
const token = getCookie(c, SESSION_COOKIE);
|
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: '/' });
|
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||||
return c.json({ ok: true });
|
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', () => {
|
describe('restore after restart', () => {
|
||||||
test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => {
|
test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => {
|
||||||
const { manager, conn, store, clock, users } = harness();
|
const { manager, conn, store, clock, users } = harness();
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const MAX_ROOMS = 2000;
|
|||||||
export const MAX_ROOMS_PER_USER = 3;
|
export const MAX_ROOMS_PER_USER = 3;
|
||||||
const EMPTY_LOBBY_TTL = 10 * 60_000;
|
const EMPTY_LOBBY_TTL = 10 * 60_000;
|
||||||
const FINISHED_TTL = 30 * 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 {
|
export interface ManagerDeps {
|
||||||
store: RoomStore;
|
store: RoomStore;
|
||||||
@@ -42,6 +45,10 @@ export class RoomManager {
|
|||||||
log: this.deps.log,
|
log: this.deps.log,
|
||||||
settings: this.deps.settings,
|
settings: this.deps.settings,
|
||||||
botDelay: this.deps.botDelay,
|
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) => {
|
onSeatChange: (room, userId, seated) => {
|
||||||
if (isBotId(userId)) return;
|
if (isBotId(userId)) return;
|
||||||
const set = this.seatIndex.get(userId) ?? new Set<string>();
|
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 }[] {
|
publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] {
|
||||||
return this.all()
|
return this.all()
|
||||||
// Rooms with computer players are practice rooms: never listed, never filled by quick play.
|
// 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()) {
|
for (const room of this.all()) {
|
||||||
if (room.conns.size > 0) continue;
|
if (room.conns.size > 0) continue;
|
||||||
const idle = now - room.lastActiveAt;
|
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 =
|
const closable =
|
||||||
(room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) ||
|
(room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) ||
|
||||||
(room.status === 'finished' && idle > FINISHED_TTL) ||
|
(room.status === 'finished' && idle > FINISHED_TTL) ||
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ export interface RoomDeps {
|
|||||||
users: { publicUser(id: string): PublicUser | null };
|
users: { publicUser(id: string): PublicUser | null };
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
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;
|
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
|
||||||
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
|
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
|
||||||
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
|
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
|
||||||
@@ -97,6 +98,8 @@ export class Room {
|
|||||||
spectators = new Set<string>();
|
spectators = new Set<string>();
|
||||||
presence = new Map<string, Presence>();
|
presence = new Map<string, Presence>();
|
||||||
conns = new Map<string, Conn>();
|
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>();
|
kicked = new Set<string>();
|
||||||
/** Players who left during a game; their seat is freed when the game ends. */
|
/** Players who left during a game; their seat is freed when the game ends. */
|
||||||
leftDuringGame = new Set<string>();
|
leftDuringGame = new Set<string>();
|
||||||
@@ -108,6 +111,8 @@ export class Room {
|
|||||||
game: GameSlot | null = null;
|
game: GameSlot | null = null;
|
||||||
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
||||||
lastActiveAt: number;
|
lastActiveAt: number;
|
||||||
|
/** When the last connection went away (null while someone is connected). */
|
||||||
|
emptySince: number | null;
|
||||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
|
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
|
||||||
private pendingLogs: LogEntry[] = [];
|
private pendingLogs: LogEntry[] = [];
|
||||||
@@ -130,6 +135,7 @@ export class Room {
|
|||||||
this.seats = new Array(config.maxPlayers).fill(null);
|
this.seats = new Array(config.maxPlayers).fill(null);
|
||||||
this.now = deps.now ?? Date.now;
|
this.now = deps.now ?? Date.now;
|
||||||
this.lastActiveAt = this.now();
|
this.lastActiveAt = this.now();
|
||||||
|
this.emptySince = this.now();
|
||||||
const room = this;
|
const room = this;
|
||||||
this.bots = new BotDriver(
|
this.bots = new BotDriver(
|
||||||
{
|
{
|
||||||
@@ -205,6 +211,11 @@ export class Room {
|
|||||||
return this.seats.some((s) => !!s && isBotId(s.userId));
|
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
|
||||||
|
|
||||||
snapshot(): RoomSnapshot {
|
snapshot(): RoomSnapshot {
|
||||||
@@ -409,6 +420,8 @@ export class Room {
|
|||||||
this.leftDuringGame.delete(u);
|
this.leftDuringGame.delete(u);
|
||||||
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
|
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
|
||||||
this.conns.set(u, conn);
|
this.conns.set(u, conn);
|
||||||
|
this.detached.delete(u);
|
||||||
|
this.emptySince = null;
|
||||||
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
||||||
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
||||||
}
|
}
|
||||||
@@ -422,11 +435,25 @@ export class Room {
|
|||||||
return null;
|
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). */
|
/** Connection dropped (not an intentional leave). */
|
||||||
disconnect(conn: Conn): void {
|
disconnect(conn: Conn): void {
|
||||||
const u = conn.userId;
|
const u = conn.userId;
|
||||||
if (this.conns.get(u) !== conn) return;
|
if (this.detached.get(u) === conn) this.detached.delete(u);
|
||||||
this.conns.delete(u);
|
else if (this.conns.get(u) === conn) this.conns.delete(u);
|
||||||
|
else return;
|
||||||
const p = this.presence.get(u);
|
const p = this.presence.get(u);
|
||||||
if (p) {
|
if (p) {
|
||||||
p.connected = false;
|
p.connected = false;
|
||||||
@@ -440,12 +467,13 @@ export class Room {
|
|||||||
}
|
}
|
||||||
this.broadcastRoom();
|
this.broadcastRoom();
|
||||||
this.scheduleTimer();
|
this.scheduleTimer();
|
||||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
if (this.conns.size === 0) this.becameEmpty('disconnect');
|
||||||
}
|
}
|
||||||
|
|
||||||
leave(userId: string): void {
|
leave(userId: string): void {
|
||||||
const conn = this.conns.get(userId);
|
const conn = this.conns.get(userId);
|
||||||
this.conns.delete(userId);
|
this.conns.delete(userId);
|
||||||
|
this.detached.delete(userId);
|
||||||
this.spectators.delete(userId);
|
this.spectators.delete(userId);
|
||||||
this.presence.delete(userId);
|
this.presence.delete(userId);
|
||||||
const seat = this.seatOf(userId);
|
const seat = this.seatOf(userId);
|
||||||
@@ -471,7 +499,12 @@ export class Room {
|
|||||||
this.persist({ actorId: userId, kind: 'leave', data: null });
|
this.persist({ actorId: userId, kind: 'leave', data: null });
|
||||||
this.broadcastRoom();
|
this.broadcastRoom();
|
||||||
conn?.send({ t: 'bye', reason: 'closed' });
|
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 {
|
private pickNewHost(): void {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { Gateway, type WsData } from './ws/gateway';
|
|||||||
import { Audit, Roles } from './admin/roles';
|
import { Audit, Roles } from './admin/roles';
|
||||||
import { SettingsStore } from './admin/settings';
|
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 type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||||
|
|
||||||
export const jsonLog: LogFn = (level, msg, extra) => {
|
export const jsonLog: LogFn = (level, msg, extra) => {
|
||||||
@@ -31,6 +34,8 @@ export interface StartOptions {
|
|||||||
discordExchange?: HttpDeps['discordExchange'];
|
discordExchange?: HttpDeps['discordExchange'];
|
||||||
/** Computer players' thinking time (tests shorten it). */
|
/** Computer players' thinking time (tests shorten it). */
|
||||||
botDelay?: (choices: number, withPeople: boolean) => number;
|
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) {
|
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 rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings, botDelay: opts.botDelay });
|
||||||
const restored = rooms.restoreAll();
|
const restored = rooms.restoreAll();
|
||||||
log('info', 'rooms restored', restored);
|
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;
|
let ready = true;
|
||||||
|
|
||||||
const ipOf = (req: Request) =>
|
const ipOf = (req: Request) =>
|
||||||
@@ -72,6 +90,7 @@ export function startServer(opts: StartOptions) {
|
|||||||
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
||||||
roles,
|
roles,
|
||||||
settings,
|
settings,
|
||||||
|
removeGuest,
|
||||||
admin: {
|
admin: {
|
||||||
audit,
|
audit,
|
||||||
games: GAMES,
|
games: GAMES,
|
||||||
@@ -128,6 +147,7 @@ export function startServer(opts: StartOptions) {
|
|||||||
try {
|
try {
|
||||||
rooms.sweep();
|
rooms.sweep();
|
||||||
sessions.purgeExpired();
|
sessions.purgeExpired();
|
||||||
|
purgeIdleGuests();
|
||||||
store.purgeOldLogs();
|
store.purgeOldLogs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('error', 'sweep failed', { err: String(err) });
|
log('error', 'sweep failed', { err: String(err) });
|
||||||
@@ -141,11 +161,11 @@ export function startServer(opts: StartOptions) {
|
|||||||
rooms.shutdown();
|
rooms.shutdown();
|
||||||
// Bun's stop() promise can stay pending after server-initiated WS closes even though the
|
// Bun's stop() promise can stay pending after server-initiated WS closes even though the
|
||||||
// listener is already closed, so bound the wait.
|
// 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();
|
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> {
|
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> = {}) {
|
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
|
||||||
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
|
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}`;
|
const base = `http://localhost:${app.server.port}`;
|
||||||
return { ...app, base, dbPath, config };
|
return { ...app, base, dbPath, config };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface GatewayDeps {
|
|||||||
me(userId: string): PublicUser | null;
|
me(userId: string): PublicUser | null;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
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;
|
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);
|
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;
|
const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||||
if (room && ws.data.conn) room.disconnect(ws.data.conn);
|
if (room && ws.data.conn) room.disconnect(ws.data.conn);
|
||||||
|
this.deps.onClose?.(ws.data.userId);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,6 +146,11 @@ export class Gateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const room: Room | undefined = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
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');
|
if (!room || room.conns.get(u) !== ws.data.conn) return err('먼저 방에 들어가 주세요.', 'not-in-room');
|
||||||
let e: string | null = null;
|
let e: string | null = null;
|
||||||
switch (m.t) {
|
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). */
|
/** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
this.closing = true;
|
this.closing = true;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function AccountMenu() {
|
|||||||
{confirm && (
|
{confirm && (
|
||||||
<ConfirmSheet
|
<ConfirmSheet
|
||||||
title="로그아웃할까요?"
|
title="로그아웃할까요?"
|
||||||
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
|
body={me.kind === 'guest' ? '게스트 계정은 로그아웃하면 이름과 기록이 모두 지워져요.' : undefined}
|
||||||
confirm="로그아웃"
|
confirm="로그아웃"
|
||||||
danger={me.kind === 'guest'}
|
danger={me.kind === 'guest'}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
|
|||||||
@@ -220,6 +220,13 @@ class GameSocket {
|
|||||||
useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null });
|
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. */
|
/** Sends a game action; returns false when not connected. */
|
||||||
act(a: unknown): boolean {
|
act(a: unknown): boolean {
|
||||||
const cs = ++this.cs;
|
const cs = ++this.cs;
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export default function MePage() {
|
|||||||
{confirm === 'logout' && (
|
{confirm === 'logout' && (
|
||||||
<ConfirmSheet
|
<ConfirmSheet
|
||||||
title="로그아웃할까요?"
|
title="로그아웃할까요?"
|
||||||
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
|
body={me.kind === 'guest' ? '게스트 계정은 로그아웃하면 이름과 기록이 모두 지워져요.' : undefined}
|
||||||
confirm="로그아웃"
|
confirm="로그아웃"
|
||||||
danger={me.kind === 'guest'}
|
danger={me.kind === 'guest'}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ export default function RoomPage() {
|
|||||||
}, [code, me]);
|
}, [code, me]);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [code, me]);
|
||||||
|
|
||||||
if (!me) {
|
if (!me) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `join` | `code`, `as: "player"\|"spectator"` | 방 입장. 이미 자리 있는 방이면 재입장 처리 |
|
| `join` | `code`, `as: "player"\|"spectator"` | 방 입장. 이미 자리 있는 방이면 재입장 처리 |
|
||||||
| `leave` | — | 방 나가기(게임 중이면 확인 후, 기권 처리 규칙은 6절) |
|
| `leave` | — | 방 나가기(게임 중이면 확인 후, 기권 처리 규칙은 6절) |
|
||||||
|
| `detach` | — | 방 화면을 벗어남(자리 유지, 방 메시지 중단, 같은 소켓으로 `join`하면 다시 받음). docs/06 §11 |
|
||||||
| `seat` | `seat?: number` | 자리에 앉기/자리 바꾸기(대기실에서만) |
|
| `seat` | `seat?: number` | 자리에 앉기/자리 바꾸기(대기실에서만) |
|
||||||
| `unseat` | — | 관전자로 일어나기(대기실에서만) |
|
| `unseat` | — | 관전자로 일어나기(대기실에서만) |
|
||||||
| `ready` | `ready: boolean` | 준비 |
|
| `ready` | `ready: boolean` | 준비 |
|
||||||
|
|||||||
@@ -39,7 +39,8 @@
|
|||||||
## 5. 세션
|
## 5. 세션
|
||||||
- 로그인/게스트 생성 시 32바이트 무작위 토큰을 만들어 쿠키 `sid`로 주고, DB에는 SHA-256 해시를 저장.
|
- 로그인/게스트 생성 시 32바이트 무작위 토큰을 만들어 쿠키 `sid`로 주고, DB에는 SHA-256 해시를 저장.
|
||||||
- 수명: 게스트 365일, 디스코드 90일(사용할 때마다 연장, 하루 1번만 DB 갱신).
|
- 수명: 게스트 365일, 디스코드 90일(사용할 때마다 연장, 하루 1번만 DB 갱신).
|
||||||
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 그 게스트 계정에 다시 들어올 수 없다는 경고를 띄운다.
|
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 게스트 계정을 지운다(방 자리, 전적, 세션 포함, 지난 대국 기록에는 '알 수 없음'으로 남음). 확인 창에서 지워진다고 알린다.
|
||||||
|
- 게스트가 사이트를 닫고 하루(24시간) 동안 오지 않으면 같은 방식으로 지운다. 서버가 1분마다 `users.last_seen_at`(요청·소켓 종료 때 갱신)을 보고 정리하며, 소켓이 열려 있거나 방 자리를 갖고 있으면 건너뛴다.
|
||||||
- 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제.
|
- 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제.
|
||||||
|
|
||||||
## 6. HTTP API
|
## 6. HTTP API
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ interface Room {
|
|||||||
```
|
```
|
||||||
- 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?").
|
- 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?").
|
||||||
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.
|
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.
|
||||||
|
- 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다. 사람이 [방 나가기]로 모두 나가 컴퓨터만 남으면 바로 닫는다.
|
||||||
|
- 방 화면을 벗어나면(홈, 규칙 등) 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다.
|
||||||
|
|
||||||
## 12. 컴퓨터 상대 (혼자 연습)
|
## 12. 컴퓨터 상대 (혼자 연습)
|
||||||
- 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명).
|
- 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| 종류 | 도구 | 대상 | 언제 |
|
| 종류 | 도구 | 대상 | 언제 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 규칙 단위 | `bun test` | `packages/games/*` | 매 커밋 |
|
| 규칙 단위 | `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 클라이언트 | 인증, 방 만들기/입장, 행동, 재접속, 강퇴, 한 판 더 | 매 커밋 |
|
| 서버 통합 | `bun test` + 실제 WS 클라이언트 | 인증, 방 만들기/입장, 행동, 재접속, 강퇴, 한 판 더 | 매 커밋 |
|
||||||
| 복구 | 통합 테스트 | 서버 인스턴스 종료 → 같은 DB로 재시작 → 상태 동일 | 매 커밋 |
|
| 복구 | 통합 테스트 | 서버 인스턴스 종료 → 같은 DB로 재시작 → 상태 동일 | 매 커밋 |
|
||||||
|
|||||||
@@ -160,6 +160,53 @@ try {
|
|||||||
console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`);
|
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(' | ')}`);
|
if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
failed = true;
|
failed = true;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"dev": "bun run --filter '@bg/server' dev & bun run --filter '@bg/web' dev",
|
"dev": "bun run --filter '@bg/server' dev & bun run --filter '@bg/web' dev",
|
||||||
"build": "bun run --filter '@bg/web' build",
|
"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",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/bun": "1.4.2",
|
"@types/bun": "1.4.2",
|
||||||
|
|||||||
@@ -78,3 +78,13 @@ export function checkNoLeak(def: AnyGameDefinition, state: unknown, viewer: Play
|
|||||||
export function seedN(n: number): RngState {
|
export function seedN(n: number): RngState {
|
||||||
return [n >>> 0, (n * 2654435761) >>> 0, (n ^ 0xdeadbeef) >>> 0, (n + 1) >>> 0];
|
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 { 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 {
|
import {
|
||||||
CARDS,
|
CARDS,
|
||||||
CHARACTER_LIFE,
|
CHARACTER_LIFE,
|
||||||
@@ -1263,11 +1263,13 @@ const PRESETS: Partial<BangOptions>[] = [
|
|||||||
{ characterDraft: 'choose2', autoBarrel: false, autoDefend: false, reactionSeconds: 8 },
|
{ characterDraft: 'choose2', autoBarrel: false, autoDefend: false, reactionSeconds: 8 },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('무작위 대국 (시드 1,000개, 4~7명)', () => {
|
const SEEDS = playoutSeeds(1000);
|
||||||
test('1,000 games finish; cards conserved, life in bounds, no leaks, onTimeout always legal, deterministic', () => {
|
|
||||||
|
describe(`무작위 대국 (시드 ${SEEDS}개, 4~7명)`, () => {
|
||||||
|
test(`${SEEDS} games finish; cards conserved, life in bounds, no leaks, onTimeout always legal, deterministic`, () => {
|
||||||
let finished = 0;
|
let finished = 0;
|
||||||
const wins = { law: 0, outlaws: 0, renegade: 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 n = 4 + (i % 4);
|
||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = bang.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
|
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(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(finished).toBe(1000);
|
expect(finished).toBe(SEEDS);
|
||||||
// Every team wins sometimes.
|
// Every team wins sometimes.
|
||||||
expect(wins.law).toBeGreaterThan(0);
|
expect(wins.law).toBeGreaterThan(0);
|
||||||
expect(wins.outlaws).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) => {
|
] 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 players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = bang.optionsSchema.parse(opts);
|
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 });
|
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);
|
expect(a.finished).toBe(true);
|
||||||
if (i === 1) {
|
if (i === 1) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** 그림 맞히기 rules tests (docs/games/catchmind.md §10) + seeded random playouts (docs/09 §7). */
|
/** 그림 맞히기 rules tests (docs/games/catchmind.md §10) + seeded random playouts (docs/09 §7). */
|
||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
BUILTIN_WORDS,
|
BUILTIN_WORDS,
|
||||||
CHOOSE_MS,
|
CHOOSE_MS,
|
||||||
@@ -592,10 +592,12 @@ function invariants(s: CatchmindState, poolSize: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('무작위 대국(§10, docs/09 §7)', () => {
|
describe('무작위 대국(§10, docs/09 §7)', () => {
|
||||||
test('1,000판: 예외 없음, 단어·점수 보존, 종료, 결정성', () => {
|
const GAMES = playoutSeeds(1000);
|
||||||
|
const DEEP_GAMES = playoutSeeds(150);
|
||||||
|
test(`${GAMES}판: 예외 없음, 단어·점수 보존, 종료, 결정성`, () => {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
let finished = 0;
|
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 pk = SeededRng.fromSeed(seedN(9000 + i));
|
||||||
const n = 2 + pk.int(9);
|
const n = 2 + pk.int(9);
|
||||||
const options = opts({
|
const options = opts({
|
||||||
@@ -609,7 +611,7 @@ describe('무작위 대국(§10, docs/09 §7)', () => {
|
|||||||
speedBonus: pk.int(3) === 0,
|
speedBonus: pk.int(3) === 0,
|
||||||
difficulty: (['easy', 'normal', 'mixed'] as const)[pk.int(3)]!,
|
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++;
|
if (run.state.finished) finished++;
|
||||||
else throw new Error(`seed ${i}: not finished after ${run.steps} steps`);
|
else throw new Error(`seed ${i}: not finished after ${run.steps} steps`);
|
||||||
if (i % 100 === 0) {
|
if (i % 100 === 0) {
|
||||||
@@ -617,7 +619,7 @@ describe('무작위 대국(§10, docs/09 §7)', () => {
|
|||||||
expect(JSON.stringify(again.state)).toBe(JSON.stringify(run.state));
|
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);
|
expect(performance.now() - t0).toBeLessThan(55_000);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
KINDS,
|
KINDS,
|
||||||
allCardIds,
|
allCardIds,
|
||||||
@@ -481,7 +481,8 @@ describe('비공개 정보 (§8.3)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('무작위 진행 중 모든 view가 비공개 정보를 숨기고 이벤트에도 미공개 카드가 없음', () => {
|
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 n = 2 + (seed % 5);
|
||||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
||||||
const r = SeededRng.fromSeed(seedN(seed));
|
const r = SeededRng.fromSeed(seedN(seed));
|
||||||
@@ -553,8 +554,10 @@ function invariant(st: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('무작위 대국 (docs/09 §7)', () => {
|
describe('무작위 대국 (docs/09 §7)', () => {
|
||||||
test('engine randomPlayout, seeds 1–200: 끝남, 카드 보존, 유출 없음, onTimeout 합법, 결정성', () => {
|
const ENGINE_SEEDS = playoutSeeds(200);
|
||||||
for (let i = 1; i <= 200; i++) {
|
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 { players, options } = cfg(i);
|
||||||
const r = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, invariant, maxApplyMs: 50 });
|
const r = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, invariant, maxApplyMs: 50 });
|
||||||
expect(r.finished).toBe(true);
|
expect(r.finished).toBe(true);
|
||||||
@@ -569,9 +572,9 @@ describe('무작위 대국 (docs/09 §7)', () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test('fast loop, seeds 201–1000 (+ timeouts): 끝남, 카드 보존, 상태 JSON 왕복, 같은 로그 재생 = 같은 결과', () => {
|
test(`fast loop, seeds 201–${FAST_LAST} (+ timeouts): 끝남, 카드 보존, 상태 JSON 왕복, 같은 로그 재생 = 같은 결과`, () => {
|
||||||
let maxSteps = 0;
|
let maxSteps = 0;
|
||||||
for (let i = 201; i <= 1000; i++) {
|
for (let i = 201; i <= FAST_LAST; i++) {
|
||||||
const { players, options } = cfg(i);
|
const { players, options } = cfg(i);
|
||||||
const r = SeededRng.fromSeed(seedN(i));
|
const r = SeededRng.fromSeed(seedN(i));
|
||||||
const chooser = SeededRng.fromSeed(seedN(i + 5000));
|
const chooser = SeededRng.fromSeed(seedN(i + 5000));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
FULL_DECK,
|
FULL_DECK,
|
||||||
allCardIds,
|
allCardIds,
|
||||||
@@ -630,12 +630,14 @@ const PRESETS: Partial<DalmutiOptions>[] = [
|
|||||||
{ rounds: 5, removeHighCards: true, passLocksOut: true },
|
{ rounds: 5, removeHighCards: true, passLocksOut: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('random playouts (1,000 seeds)', () => {
|
const SEEDS = playoutSeeds(1000);
|
||||||
test('1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic', () => {
|
|
||||||
|
describe(`random playouts (${SEEDS} seeds)`, () => {
|
||||||
|
test(`${SEEDS} seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
|
||||||
let finished = 0;
|
let finished = 0;
|
||||||
let revolutions = 0;
|
let revolutions = 0;
|
||||||
let great = 0;
|
let great = 0;
|
||||||
for (let i = 1; i <= 1000; i++) {
|
for (let i = 1; i <= SEEDS; i++) {
|
||||||
const n = 4 + (i % 5);
|
const n = 4 + (i % 5);
|
||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = dalmuti.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
|
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(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(finished).toBe(1000);
|
expect(finished).toBe(SEEDS);
|
||||||
expect(revolutions).toBeGreaterThan(0);
|
expect(revolutions).toBeGreaterThan(0);
|
||||||
expect(great).toBeGreaterThan(0);
|
expect(great).toBeGreaterThan(0);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
allTileIds,
|
allTileIds,
|
||||||
autoAction,
|
autoAction,
|
||||||
@@ -521,7 +521,8 @@ describe('시간 초과·연결 끊김 (§8.5)', () => {
|
|||||||
|
|
||||||
describe('정보 유출 (§8.3)', () => {
|
describe('정보 유출 (§8.3)', () => {
|
||||||
test('관전자·상대 view와 이벤트에 남의 숨은 숫자·ID·이번 턴 타일 숫자가 없음', () => {
|
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 n = 2 + (seed % 3);
|
||||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
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 });
|
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`, () => {
|
test(`seeds ${from}–${to}: finish, conserve tiles, sorted rows, no leaks, onTimeout legal`, () => {
|
||||||
for (let i = from; i <= to; i++) {
|
for (let i = from; i <= to; i++) {
|
||||||
const { players, options } = cfg(i);
|
const { players, options } = cfg(i);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
AWAY_STEP_MS,
|
AWAY_STEP_MS,
|
||||||
COUNTDOWN_MS,
|
COUNTDOWN_MS,
|
||||||
@@ -791,9 +791,10 @@ const PRESETS: Partial<DobbleOptions>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('random playouts (§10, docs/09 §7)', () => {
|
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 };
|
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 n = 2 + (i % 7);
|
||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = dobble.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
|
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(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(tally.correct).toBeGreaterThan(10_000);
|
expect(tally.correct).toBeGreaterThan(10 * SEEDS);
|
||||||
expect(tally.wrong).toBeGreaterThan(1000);
|
expect(tally.wrong).toBeGreaterThan(SEEDS);
|
||||||
expect(tally.forfeit).toBeGreaterThan(0);
|
expect(tally.forfeit).toBeGreaterThan(0);
|
||||||
expect(tally.left).toBeGreaterThan(0);
|
expect(tally.left).toBeGreaterThan(0);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
@@ -831,7 +832,7 @@ describe('random playouts (§10, docs/09 §7)', () => {
|
|||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = dobble.optionsSchema.parse(opts);
|
const options = dobble.optionsSchema.parse(opts);
|
||||||
const inv = (st: unknown) => invariants(st as DobbleState);
|
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 });
|
const a = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, invariant: inv, maxApplyMs: 20 });
|
||||||
expect(a.finished).toBe(true);
|
expect(a.finished).toBe(true);
|
||||||
if (i === 1) {
|
if (i === 1) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
autoPlay,
|
autoPlay,
|
||||||
bestScore,
|
bestScore,
|
||||||
@@ -854,10 +854,12 @@ const CONFIGS: [string, Partial<GostopOptions>][] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('random playouts', () => {
|
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 rounds = 0;
|
||||||
let stops = 0;
|
let stops = 0;
|
||||||
for (let seed = 1; seed <= 1000; seed++) {
|
for (let seed = 1; seed <= SEEDS; seed++) {
|
||||||
const n = 2 + (seed % 4);
|
const n = 2 + (seed % 4);
|
||||||
const ps = players(n);
|
const ps = players(n);
|
||||||
const [, opts] = CONFIGS[seed % CONFIGS.length]!;
|
const [, opts] = CONFIGS[seed % CONFIGS.length]!;
|
||||||
@@ -894,8 +896,8 @@ describe('random playouts', () => {
|
|||||||
invariants(s);
|
invariants(s);
|
||||||
expect(gostop.result(s)!.ranking.flat().sort()).toEqual([...ps].sort());
|
expect(gostop.result(s)!.ranking.flat().sort()).toEqual([...ps].sort());
|
||||||
}
|
}
|
||||||
expect(rounds).toBeGreaterThan(900);
|
expect(rounds).toBeGreaterThan(SEEDS * 0.9);
|
||||||
expect(stops).toBeGreaterThan(100);
|
expect(stops).toBeGreaterThan(SEEDS / 10);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test('engine randomPlayout (leaks, JSON round-trip, determinism) in every mode', () => {
|
test('engine randomPlayout (leaks, JSON round-trip, determinism) in every mode', () => {
|
||||||
@@ -920,7 +922,7 @@ describe('random playouts', () => {
|
|||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test('leak property: views never contain hidden hands / deck, events only reveal public cards', () => {
|
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 n = 2 + (seed % 4);
|
||||||
const ps = players(n);
|
const ps = players(n);
|
||||||
const options = parse({ ...CONFIGS[seed % CONFIGS.length]![1], rounds: 1 });
|
const options = parse({ ...CONFIGS[seed % CONFIGS.length]![1], rounds: 1 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
AWAY_FLIP_MS,
|
AWAY_FLIP_MS,
|
||||||
CARDS,
|
CARDS,
|
||||||
@@ -660,11 +660,12 @@ const PRESETS: Partial<HalliGalliOptions>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('random playouts (§10, docs/09 §7)', () => {
|
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 finished = 0;
|
||||||
let collects = 0;
|
let collects = 0;
|
||||||
let wrongs = 0;
|
let wrongs = 0;
|
||||||
for (let i = 1; i <= 1000; i++) {
|
for (let i = 1; i <= SEEDS; i++) {
|
||||||
const n = 2 + (i % 5);
|
const n = 2 + (i % 5);
|
||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = hg.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
|
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(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(finished).toBe(1000);
|
expect(finished).toBe(SEEDS);
|
||||||
expect(collects).toBeGreaterThan(1000);
|
expect(collects).toBeGreaterThan(SEEDS);
|
||||||
expect(wrongs).toBeGreaterThan(1000);
|
expect(wrongs).toBeGreaterThan(SEEDS);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 { janggi, type JanggiAction, type JanggiOptions, type JanggiState } from './index';
|
||||||
import { idx, inCheck, initialBoard, kingsFacing, makeMove, positionKey, type Board, type PieceKind, type Side } from './rules';
|
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 }],
|
['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 }],
|
['repetition lose, no pass, limit 250', { repetition: 'lose', allowPass: false, moveLimit: 250 }],
|
||||||
] as const)('%s: finish, deterministic, JSON-safe', (_n, o) => {
|
] 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 options = opts(o as Partial<JanggiOptions>);
|
||||||
const a = randomPlayout(janggi, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50, invariant: kings, maxSteps: 400 });
|
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 });
|
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 { 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 {
|
import {
|
||||||
ALL_NOTES,
|
ALL_NOTES,
|
||||||
AWAY_AFTER,
|
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;
|
let finished = 0;
|
||||||
const play = (i: number, check: boolean): string => {
|
const play = (i: number, check: boolean): string => {
|
||||||
const n = 2 + (i % 4) + (i % 7 === 0 ? 1 : 0);
|
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`);
|
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);
|
const a = play(i, true);
|
||||||
finished++;
|
finished++;
|
||||||
if (i % 50 === 0) expect(play(i, false)).toBe(a);
|
if (i % 50 === 0) expect(play(i, false)).toBe(a);
|
||||||
}
|
}
|
||||||
expect(finished).toBe(1000);
|
expect(finished).toBe(SEEDS);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test('engine randomPlayout: 100 seeds with round-trip, leak checker and determinism', () => {
|
test(`engine randomPlayout: ${ENGINE_SEEDS} seeds with round-trip, leak checker and determinism`, () => {
|
||||||
for (let i = 1; i <= 100; i++) {
|
for (let i = 1; i <= ENGINE_SEEDS; i++) {
|
||||||
const players = ['A', 'B', 'C', 'D', 'E'].slice(0, 2 + (i % 4));
|
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 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 });
|
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;
|
const st = r.finalState as LasVegasState;
|
||||||
expect(st.roundResults.length).toBe(opts.rounds);
|
expect(st.roundResults.length).toBe(opts.rounds);
|
||||||
expect(allBills(st)).toEqual(SORTED_NOTES);
|
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 });
|
const again = randomPlayout(lv, { players, options: opts, seed: seedN(i), maxSteps: 3000, maxApplyMs: 30 });
|
||||||
expect(JSON.stringify(again.finalState)).toBe(JSON.stringify(st));
|
expect(JSON.stringify(again.finalState)).toBe(JSON.stringify(st));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
AWAY_DAYS,
|
AWAY_DAYS,
|
||||||
CHANNELS,
|
CHANNELS,
|
||||||
@@ -801,11 +801,13 @@ function fastPlayout(players: PlayerId[], options: MafiaOptions, seed: RngState,
|
|||||||
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
|
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('무작위 대국 (시드 1,000개, 4~12명)', () => {
|
const SEEDS = playoutSeeds(1000);
|
||||||
test('1,000 games finish; roles conserved, no leaks, onTimeout always legal, deterministic', () => {
|
|
||||||
|
describe(`무작위 대국 (시드 ${SEEDS}개, 4~12명)`, () => {
|
||||||
|
test(`${SEEDS} games finish; roles conserved, no leaks, onTimeout always legal, deterministic`, () => {
|
||||||
const wins = { town: 0, mafia: 0 };
|
const wins = { town: 0, mafia: 0 };
|
||||||
const causes: Record<string, number> = {};
|
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 n = 4 + (i % 9);
|
||||||
const players = ids(n);
|
const players = ids(n);
|
||||||
const options = presetFor(i, 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(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(wins.town).toBeGreaterThan(50);
|
expect(wins.town).toBeGreaterThan(SEEDS / 20);
|
||||||
expect(wins.mafia).toBeGreaterThan(50);
|
expect(wins.mafia).toBeGreaterThan(SEEDS / 20);
|
||||||
expect(Object.keys(causes).sort()).toEqual(['disconnect', 'night', 'vote']);
|
expect(Object.keys(causes).sort()).toEqual(['disconnect', 'night', 'vote']);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* 마작 무작위 대국 ③ 3인(산마) 360판 + 엔진 randomPlayout(직렬화 왕복·정보 누출 검사 내장) + 매 단계 누출 검사
|
* 마작 무작위 대국 ③ 3인(산마) 360판(FULL_TESTS, 기본은 36판) + 엔진 randomPlayout(직렬화 왕복·정보 누출 검사 내장) + 매 단계 누출 검사
|
||||||
* + 재현성 (docs/09-game-engine.md §7).
|
* + 재현성 (docs/09-game-engine.md §7).
|
||||||
*/
|
*/
|
||||||
import { describe, expect, test } from 'bun:test';
|
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 { mahjong } from './index';
|
||||||
import { checkPoints, checkTiles, emptyStats, playGame, variedOptions } from './playout';
|
import { checkPoints, checkTiles, emptyStats, playGame, variedOptions } from './playout';
|
||||||
|
|
||||||
const stats = emptyStats();
|
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인 산마', () => {
|
describe('무작위 대국: 3인 산마', () => {
|
||||||
for (let chunk = 0; chunk < 18; chunk++) {
|
for (let chunk = 0; chunk * 20 < GAMES; chunk++) {
|
||||||
test(`시드 ${2000 + chunk * 20 + 1}~${2000 + chunk * 20 + 20}`, () => {
|
const size = Math.min(20, GAMES - chunk * 20);
|
||||||
for (let i = 1; i <= 20; i++) {
|
test(`시드 ${2000 + chunk * 20 + 1}~${2000 + chunk * 20 + size}`, () => {
|
||||||
|
for (let i = 1; i <= size; i++) {
|
||||||
const seed = 2000 + chunk * 20 + 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 });
|
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('북 뽑기·화료·유국이 모두 나왔다', () => {
|
test('북 뽑기·화료·유국이 모두 나왔다', () => {
|
||||||
expect(stats.kita).toBeGreaterThan(500);
|
expect(stats.kita).toBeGreaterThan(500 * k);
|
||||||
expect(stats.tsumo).toBeGreaterThan(50);
|
expect(stats.tsumo).toBeGreaterThan(50 * k);
|
||||||
expect(stats.ron).toBeGreaterThan(50);
|
expect(stats.ron).toBeGreaterThan(50 * k);
|
||||||
expect(stats.exhaustive).toBeGreaterThan(50);
|
expect(stats.exhaustive).toBeGreaterThan(50 * k);
|
||||||
expect(stats.maxApplyMs).toBeLessThan(25);
|
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 { describe, expect, test } from 'bun:test';
|
||||||
|
import { playoutSeeds } from '@bg/engine';
|
||||||
import { emptyStats, playGame, variedOptions } from './playout';
|
import { emptyStats, playGame, variedOptions } from './playout';
|
||||||
|
|
||||||
const stats = emptyStats();
|
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인 반장전', () => {
|
describe('무작위 대국: 4인 반장전', () => {
|
||||||
for (let chunk = 0; chunk < 12; chunk++) {
|
for (let chunk = 0; chunk * 20 < GAMES; chunk++) {
|
||||||
test(`시드 ${0 + chunk * 20 + 1}~${0 + chunk * 20 + 20}`, () => {
|
const size = Math.min(20, GAMES - chunk * 20);
|
||||||
for (let i = 1; i <= 20; i++) {
|
test(`시드 ${0 + chunk * 20 + 1}~${0 + chunk * 20 + size}`, () => {
|
||||||
|
for (let i = 1; i <= size; i++) {
|
||||||
const seed = 0 + chunk * 20 + 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 });
|
playGame({ seed, players: 4, options: { length: 'hanchan', ...variedOptions(seed, 4) }, prefs: seed % 5 === 0, leaveAt: seed % 17 === 0 ? 400 : undefined, stats });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
test('다양한 결과가 나왔고 apply는 빠르다', () => {
|
test('다양한 결과가 나왔고 apply는 빠르다', () => {
|
||||||
expect(stats.tsumo).toBeGreaterThan(50);
|
expect(stats.tsumo).toBeGreaterThan(50 * k);
|
||||||
expect(stats.ron).toBeGreaterThan(100);
|
expect(stats.ron).toBeGreaterThan(100 * k);
|
||||||
expect(stats.exhaustive).toBeGreaterThan(100);
|
expect(stats.exhaustive).toBeGreaterThan(100 * k);
|
||||||
expect(stats.abortive).toBeGreaterThan(0);
|
expect(stats.abortive).toBeGreaterThan(0);
|
||||||
expect(stats.riichi).toBeGreaterThan(200);
|
expect(stats.riichi).toBeGreaterThan(200 * k);
|
||||||
expect(stats.kans).toBeGreaterThan(50);
|
expect(stats.kans).toBeGreaterThan(50 * k);
|
||||||
expect(Object.keys(stats.endReasons).length).toBeGreaterThanOrEqual(3);
|
expect(Object.keys(stats.endReasons).length).toBeGreaterThanOrEqual(3);
|
||||||
expect(stats.applyMs / stats.applies).toBeLessThan(1);
|
expect(stats.applyMs / stats.applies).toBeLessThan(1);
|
||||||
expect(stats.maxApplyMs).toBeLessThan(25);
|
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 { describe, expect, test } from 'bun:test';
|
||||||
|
import { playoutSeeds } from '@bg/engine';
|
||||||
import { emptyStats, playGame, variedOptions } from './playout';
|
import { emptyStats, playGame, variedOptions } from './playout';
|
||||||
|
|
||||||
const stats = emptyStats();
|
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인 동풍전', () => {
|
describe('무작위 대국: 4인 동풍전', () => {
|
||||||
for (let chunk = 0; chunk < 20; chunk++) {
|
for (let chunk = 0; chunk * 20 < GAMES; chunk++) {
|
||||||
test(`시드 ${1000 + chunk * 20 + 1}~${1000 + chunk * 20 + 20}`, () => {
|
const size = Math.min(20, GAMES - chunk * 20);
|
||||||
for (let i = 1; i <= 20; i++) {
|
test(`시드 ${1000 + chunk * 20 + 1}~${1000 + chunk * 20 + size}`, () => {
|
||||||
|
for (let i = 1; i <= size; i++) {
|
||||||
const seed = 1000 + chunk * 20 + 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 });
|
playGame({ seed, players: 4, options: { length: 'tonpuu', ...variedOptions(seed, 4) }, prefs: seed % 4 === 0, leaveAt: seed % 13 === 0 ? 200 : undefined, stats });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
test('다양한 결과가 나왔고 apply는 빠르다', () => {
|
test('다양한 결과가 나왔고 apply는 빠르다', () => {
|
||||||
expect(stats.tsumo + stats.ron).toBeGreaterThan(150);
|
expect(stats.tsumo + stats.ron).toBeGreaterThan(150 * k);
|
||||||
expect(stats.doubleRon).toBeGreaterThan(0);
|
// 더블 론은 400판에 한 번 정도라 전체 실행에서만 확인한다.
|
||||||
|
if (GAMES === 400) expect(stats.doubleRon).toBeGreaterThan(0);
|
||||||
expect(stats.applyMs / stats.applies).toBeLessThan(1);
|
expect(stats.applyMs / stats.applies).toBeLessThan(1);
|
||||||
expect(stats.maxApplyMs).toBeLessThan(25);
|
expect(stats.maxApplyMs).toBeLessThan(25);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
CARD_IDS,
|
CARD_IDS,
|
||||||
GROUPS,
|
GROUPS,
|
||||||
@@ -1044,10 +1044,11 @@ function invariant(s: MarbleState): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('random playouts', () => {
|
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;
|
let steps = 0;
|
||||||
const reasons: Record<string, number> = {};
|
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 cfg = CONFIGS[i % CONFIGS.length]!;
|
||||||
const options = marble.optionsSchema.parse(cfg.opts);
|
const options = marble.optionsSchema.parse(cfg.opts);
|
||||||
const players = PLAYERS.slice(0, cfg.n);
|
const players = PLAYERS.slice(0, cfg.n);
|
||||||
@@ -1082,13 +1083,15 @@ describe('random playouts', () => {
|
|||||||
const res = marble.result(s)!;
|
const res = marble.result(s)!;
|
||||||
expect(res.ranking.flat().sort()).toEqual([...players].sort());
|
expect(res.ranking.flat().sort()).toEqual([...players].sort());
|
||||||
}
|
}
|
||||||
expect(steps).toBeGreaterThan(1000 * 50);
|
expect(steps).toBeGreaterThan(SEEDS * 50);
|
||||||
// Every way of ending shows up.
|
// Every way of ending shows up (monopoly only once in the full 1,000 seeds, at seed 767).
|
||||||
expect(Object.keys(reasons).sort()).toEqual(['lastSurvivor', 'monopoly', 'roundLimit']);
|
expect(Object.keys(reasons).sort()).toEqual(SEEDS === 1000 ? ['lastSurvivor', 'monopoly', 'roundLimit'] : ['lastSurvivor', 'roundLimit']);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test('engine randomPlayout (round trip, leak check, 5 ms apply), deterministic and replayable', () => {
|
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 cfg = CONFIGS[i % CONFIGS.length]!;
|
||||||
const options = marble.optionsSchema.parse(cfg.opts);
|
const options = marble.optionsSchema.parse(cfg.opts);
|
||||||
const players = PLAYERS.slice(0, cfg.n);
|
const players = PLAYERS.slice(0, cfg.n);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
cardTotal,
|
cardTotal,
|
||||||
classicDeck,
|
classicDeck,
|
||||||
@@ -664,9 +664,10 @@ const PRESETS: Partial<NNOptions>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('random playouts', () => {
|
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;
|
let finished = 0;
|
||||||
for (let i = 1; i <= 1000; i++) {
|
for (let i = 1; i <= SEEDS; i++) {
|
||||||
const n = 2 + (i % 9);
|
const n = 2 + (i % 9);
|
||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = ninetyNine.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
|
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(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(finished).toBe(1000);
|
expect(finished).toBe(SEEDS);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@@ -695,7 +696,7 @@ describe('random playouts', () => {
|
|||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = ninetyNine.optionsSchema.parse(opts);
|
const options = ninetyNine.optionsSchema.parse(opts);
|
||||||
const check = (st: unknown) => invariants(st as NinetyNineState);
|
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 });
|
const a = randomPlayout(ninetyNine, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: check, maxApplyMs: 30 });
|
||||||
expect(a.finished).toBe(true);
|
expect(a.finished).toBe(true);
|
||||||
if (i === 1) {
|
if (i === 1) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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';
|
import { omok, type OmokOptions, type OmokState, type OmokAction } from './index';
|
||||||
|
|
||||||
const COLS = 'ABCDEFGHIJKLMNOPQRS';
|
const COLS = 'ABCDEFGHIJKLMNOPQRS';
|
||||||
@@ -269,8 +269,9 @@ describe('views and playouts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('random playouts finish, are deterministic and fast', () => {
|
test('random playouts finish, are deterministic and fast', () => {
|
||||||
|
const games = playoutSeeds(15);
|
||||||
for (const ruleSet of ['korean', 'renju', 'free'] as const) {
|
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 options = omok.optionsSchema.parse({ ruleSet });
|
||||||
const a = randomPlayout(omok, { players: ['A', 'B'], options, seed: seedN(i), maxApplyMs: 50 });
|
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 });
|
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 { 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 {
|
import {
|
||||||
cardTotal,
|
cardTotal,
|
||||||
classicDeck,
|
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) {
|
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;
|
const max = mode === 'classic' ? 9 : 10;
|
||||||
let finished = 0;
|
let finished = 0;
|
||||||
for (let i = 1; i <= 1000; i++) {
|
for (let i = 1; i <= SEEDS; i++) {
|
||||||
const n = 2 + (i % (max - 1));
|
const n = 2 + (i % (max - 1));
|
||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = oneCard.optionsSchema.parse({ mode, ...PRESETS[mode][i % PRESETS[mode].length]! });
|
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(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(finished).toBe(1000);
|
expect(finished).toBe(SEEDS);
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +858,7 @@ describe('random playouts (1,000 seeds per mode)', () => {
|
|||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = oneCard.optionsSchema.parse({ mode, ...opts });
|
const options = oneCard.optionsSchema.parse({ mode, ...opts });
|
||||||
const conserve = (st: unknown) => invariants(st as OneCardState);
|
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 });
|
const a = randomPlayout(oneCard, { players, options, seed: seedN(1000 + i), maxSteps: 30_000, invariant: conserve, maxApplyMs: 30 });
|
||||||
expect(a.finished).toBe(true);
|
expect(a.finished).toBe(true);
|
||||||
if (i === 1) {
|
if (i === 1) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 { deck52, deckD20, type CardId } from './cards';
|
||||||
import { evaluate } from './showdown';
|
import { evaluate } from './showdown';
|
||||||
import { awardHiLo } 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][] = [
|
const configs: [Partial<PokerOptions>, number][] = [
|
||||||
[{ mode: 'holdem' }, 6],
|
[{ mode: 'holdem' }, 6],
|
||||||
[{ mode: 'holdem', betting: 'potLimit', dealerRule: 'winner' }, 10],
|
[{ mode: 'holdem', betting: 'potLimit', dealerRule: 'winner' }, 10],
|
||||||
@@ -927,7 +928,8 @@ describe('random playouts', () => {
|
|||||||
];
|
];
|
||||||
let hands = 0;
|
let hands = 0;
|
||||||
const times: number[] = [];
|
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 [o, n] = configs[i % configs.length]!;
|
||||||
const r = playout(poker.optionsSchema.parse({ handLimit: 10, ...o }), n, i + 1);
|
const r = playout(poker.optionsSchema.parse({ handLimit: 10, ...o }), n, i + 1);
|
||||||
expect(r.finished).toBe(true);
|
expect(r.finished).toBe(true);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
|
import { SeededRng, playoutSeeds, randomPlayout, seedN } from '@bg/engine';
|
||||||
import {
|
import {
|
||||||
legalWallsFor,
|
legalWallsFor,
|
||||||
pawnMovesFor,
|
pawnMovesFor,
|
||||||
@@ -636,12 +636,13 @@ function fastPlayout(seed: number, n: 2 | 4, options: QuoridorOptions, checkEver
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('random playouts (docs/09 §7)', () => {
|
describe('random playouts (docs/09 §7)', () => {
|
||||||
test('1,000판 무작위 대국: 끝까지, 불변식, 시간 초과 합법, 정보 누출 없음', () => {
|
const GAMES = playoutSeeds(1000);
|
||||||
|
test(`${GAMES}판 무작위 대국: 끝까지, 불변식, 시간 초과 합법, 정보 누출 없음`, () => {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
let finished = 0;
|
let finished = 0;
|
||||||
let steps = 0;
|
let steps = 0;
|
||||||
const reasons: Record<string, number> = {};
|
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 n = i % 3 === 0 ? 4 : 2;
|
||||||
const options = quoridor.optionsSchema.parse({
|
const options = quoridor.optionsSchema.parse({
|
||||||
seatOrder: i % 2 ? 'random' : 'joinOrder',
|
seatOrder: i % 2 ? 'random' : 'joinOrder',
|
||||||
@@ -656,9 +657,9 @@ describe('random playouts (docs/09 §7)', () => {
|
|||||||
steps += trace.length;
|
steps += trace.length;
|
||||||
reasons[state.result?.reason ?? 'none'] = (reasons[state.result?.reason ?? 'none'] ?? 0) + 1;
|
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);
|
console.log(` ${GAMES} playouts: ${finished} finished, avg ${(steps / GAMES).toFixed(1)} actions, ${(performance.now() - t0).toFixed(0)}ms`, reasons);
|
||||||
expect(finished).toBe(1000);
|
expect(finished).toBe(GAMES);
|
||||||
expect(reasons.reachedGoal).toBeGreaterThan(500);
|
expect(reasons.reachedGoal).toBeGreaterThan(GAMES / 2);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test('결정성: 같은 시드·같은 행동이면 같은 결과, 기록 재생도 같은 상태', () => {
|
test('결정성: 같은 시드·같은 행동이면 같은 결과, 기록 재생도 같은 상태', () => {
|
||||||
@@ -680,7 +681,9 @@ describe('random playouts (docs/09 §7)', () => {
|
|||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test('엔진 randomPlayout(완전 무작위 합법 행동)으로도 끝까지', () => {
|
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 players = i % 3 === 0 ? ['A', 'B', 'C', 'D'] : ['A', 'B'];
|
||||||
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 2 === 0, wallsPerPlayer: 6 });
|
const options = quoridor.optionsSchema.parse({ continueForRanking: i % 2 === 0, wallsPerPlayer: 6 });
|
||||||
const inv = (st: unknown) => {
|
const inv = (st: unknown) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
ALL_TILE_IDS,
|
ALL_TILE_IDS,
|
||||||
findMeld,
|
findMeld,
|
||||||
@@ -666,7 +666,9 @@ function conserved(st: unknown): void {
|
|||||||
|
|
||||||
describe('random playouts (docs/09 §7)', () => {
|
describe('random playouts (docs/09 §7)', () => {
|
||||||
test('engine randomPlayout (round trip, checkNoLeak via secretsOf, determinism) across options', () => {
|
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 options = rummikub.optionsSchema.parse(CONFIGS[i % CONFIGS.length]);
|
||||||
const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3));
|
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 });
|
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);
|
}, 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.
|
* 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;
|
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`);
|
throw new Error(`seed ${seed}: not finished within 4000 steps`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let chunk = 0; chunk < 10; chunk++) {
|
const totalSeeds = playoutSeeds(1000);
|
||||||
test(`1,000 seeded playouts: seeds ${chunk * 100 + 1}–${chunk * 100 + 100}`, () => {
|
for (let chunk = 0; chunk * 100 < totalSeeds; chunk++) {
|
||||||
|
test(`seeded playouts: seeds ${chunk * 100 + 1}–${chunk * 100 + 100}`, () => {
|
||||||
const ends: Record<string, number> = {};
|
const ends: Record<string, number> = {};
|
||||||
for (let seed = chunk * 100 + 1; seed <= chunk * 100 + 100; seed++) {
|
for (let seed = chunk * 100 + 1; seed <= chunk * 100 + 100; seed++) {
|
||||||
const { state } = run(seed);
|
const { state } = run(seed);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
bestChoice,
|
bestChoice,
|
||||||
chipsTotal,
|
chipsTotal,
|
||||||
@@ -657,7 +657,7 @@ describe('random playouts', () => {
|
|||||||
test.each(configs)('%s: finishes, chips conserved, no leaks, deterministic', (_n, opts, n, runs) => {
|
test.each(configs)('%s: finishes, chips conserved, no leaks, deterministic', (_n, opts, n, runs) => {
|
||||||
const options = seotda.optionsSchema.parse(opts);
|
const options = seotda.optionsSchema.parse(opts);
|
||||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
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 });
|
const a = randomPlayout(seotda, { players, options, seed: seedN(i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 30 });
|
||||||
expect(a.finished).toBe(true);
|
expect(a.finished).toBe(true);
|
||||||
expect(seotda.result(a.finalState as SeotdaState)!.ranking.flat().sort()).toEqual([...players].sort());
|
expect(seotda.result(a.finalState as SeotdaState)!.ranking.flat().sort()).toEqual([...players].sort());
|
||||||
@@ -668,8 +668,9 @@ describe('random playouts', () => {
|
|||||||
}
|
}
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
test('leak property: 1,000 seeds × first hand, every viewer and every event', () => {
|
const LEAK_SEEDS = playoutSeeds(1000);
|
||||||
for (let seed = 1; seed <= 1000; seed++) {
|
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 n = 2 + (seed % 5);
|
||||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
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' });
|
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 { 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';
|
import { COLORS, NOBLES, TIERS, componentCounts, splendor, type SplendorAction, type SplendorOptions, type SplendorState, type Tokens } from './index';
|
||||||
|
|
||||||
const CONFIGS: Partial<SplendorOptions>[] = [
|
const CONFIGS: Partial<SplendorOptions>[] = [
|
||||||
@@ -109,10 +109,11 @@ describe('random playouts (docs/09 §7)', () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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> = {};
|
const ends: Record<string, number> = {};
|
||||||
let maxSteps = 0;
|
let maxSteps = 0;
|
||||||
for (let seed = 1; seed <= 1000; seed++) {
|
for (let seed = 1; seed <= SEEDS; seed++) {
|
||||||
const { state, steps } = run(seed);
|
const { state, steps } = run(seed);
|
||||||
maxSteps = Math.max(maxSteps, steps);
|
maxSteps = Math.max(maxSteps, steps);
|
||||||
const res = splendor.result(state)!;
|
const res = splendor.result(state)!;
|
||||||
@@ -128,8 +129,9 @@ describe('random playouts (docs/09 §7)', () => {
|
|||||||
if (!state.conceded.length) expect(turns.size).toBe(1);
|
if (!state.conceded.length) expect(turns.size).toBe(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(ends.target ?? 0).toBeGreaterThan(900);
|
expect(ends.target ?? 0).toBeGreaterThan(SEEDS * 0.9);
|
||||||
expect(ends.resign ?? 0).toBeGreaterThan(0);
|
// 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);
|
expect(maxSteps).toBeLessThan(2000);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
CATEGORIES,
|
CATEGORIES,
|
||||||
autoCategory,
|
autoCategory,
|
||||||
@@ -519,8 +519,10 @@ describe('random playouts (docs/09 §7)', () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('1,000 seeds: finish, scores consistent, onTimeout legal, deterministic', () => {
|
const SEEDS = playoutSeeds(1000);
|
||||||
for (let i = 1; i <= 1000; i++) {
|
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 n = i % 10 === 0 ? 8 : 1 + (i % 4);
|
||||||
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
const players = Array.from({ length: n }, (_, k) => `P${k}`);
|
||||||
const options = yacht.optionsSchema.parse({
|
const options = yacht.optionsSchema.parse({
|
||||||
@@ -539,8 +541,8 @@ describe('random playouts (docs/09 §7)', () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test('200 seeds with random concedes and timeouts', () => {
|
test(`${CONCEDE_SEEDS} seeds with random concedes and timeouts`, () => {
|
||||||
for (let i = 1; i <= 200; i++) {
|
for (let i = 1; i <= CONCEDE_SEEDS; i++) {
|
||||||
const players = Array.from({ length: 1 + (i % 5) }, (_, k) => `P${k}`);
|
const players = Array.from({ length: 1 + (i % 5) }, (_, k) => `P${k}`);
|
||||||
const r = SeededRng.fromSeed(seedN(i));
|
const r = SeededRng.fromSeed(seedN(i));
|
||||||
const chooser = SeededRng.fromSeed(seedN(i + 999));
|
const chooser = SeededRng.fromSeed(seedN(i + 999));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
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 {
|
import {
|
||||||
FLAT_P,
|
FLAT_P,
|
||||||
advance,
|
advance,
|
||||||
@@ -661,9 +661,10 @@ function invariant(st: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('random playouts', () => {
|
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;
|
let steps = 0;
|
||||||
for (let i = 1; i <= 1000; i++) {
|
for (let i = 1; i <= SEEDS; i++) {
|
||||||
const cfg = CONFIGS[i % CONFIGS.length]!;
|
const cfg = CONFIGS[i % CONFIGS.length]!;
|
||||||
const options = yut.optionsSchema.parse(cfg.opts);
|
const options = yut.optionsSchema.parse(cfg.opts);
|
||||||
const players = PLAYERS.slice(0, cfg.n);
|
const players = PLAYERS.slice(0, cfg.n);
|
||||||
@@ -687,7 +688,7 @@ describe('random playouts', () => {
|
|||||||
expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState));
|
expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(steps).toBeGreaterThan(1000 * 20);
|
expect(steps).toBeGreaterThan(SEEDS * 20);
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
test('apply is fast: p99 under 5 ms (the per-step cap above only guards against GC/scheduler spikes)', () => {
|
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', [
|
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('join'), code, as: z.enum(['player', 'spectator']).default('player') }),
|
||||||
z.object({ t: z.literal('leave') }),
|
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('seat'), seat: z.number().int().min(0).max(31).optional() }),
|
||||||
z.object({ t: z.literal('unseat') }),
|
z.object({ t: z.literal('unseat') }),
|
||||||
z.object({ t: z.literal('ready'), ready: z.boolean() }),
|
z.object({ t: z.literal('ready'), ready: z.boolean() }),
|
||||||
|
|||||||
Reference in New Issue
Block a user