컴퓨터 방 자동 정리 + 게스트 계정 삭제(로그아웃·하루 미접속) + 테스트 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:
@@ -3,6 +3,8 @@ import type { Database } from 'bun:sqlite';
|
||||
const DAY = 24 * 3600_000;
|
||||
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
|
||||
export const SESSION_COOKIE = 'sid';
|
||||
/** users.last_seen_at is written at most this often per user. */
|
||||
const SEEN_EVERY = 60_000;
|
||||
|
||||
export function randomToken(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
@@ -30,8 +32,8 @@ export class Sessions {
|
||||
resolve(token: string, now = Date.now()): string | null {
|
||||
const hash = sha256Hex(token);
|
||||
const row = this.db
|
||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
|
||||
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member'; last_seen_at: number }, [string]>(
|
||||
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind, u.last_seen_at FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`,
|
||||
)
|
||||
.get(hash);
|
||||
@@ -45,6 +47,8 @@ export class Sessions {
|
||||
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
|
||||
.run(now, now + SESSION_TTL[row.kind], hash);
|
||||
}
|
||||
// Idle guests are deleted after a day (docs/05 §4), so remember when the user was last here.
|
||||
if (now - row.last_seen_at > SEEN_EVERY) this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, row.user_id);
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,32 @@ export class Users {
|
||||
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
|
||||
}
|
||||
|
||||
/** Guests not seen since `before`, oldest first. */
|
||||
idleGuests(before: number, limit = 500): string[] {
|
||||
return this.db
|
||||
.query<{ id: string }, [number, number]>(
|
||||
"SELECT id FROM users WHERE kind = 'guest' AND deleted_at IS NULL AND last_seen_at < ? ORDER BY last_seen_at LIMIT ?",
|
||||
)
|
||||
.all(before, limit)
|
||||
.map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a guest account for good (logout, or idle for a day): the user row, its stats and sessions.
|
||||
* Past games keep the seat (shown as an unknown player). Returns false for non-guests.
|
||||
*/
|
||||
deleteGuest(userId: string): boolean {
|
||||
return this.db.transaction(() => {
|
||||
const row = this.db.query<{ kind: string }, [string]>('SELECT kind FROM users WHERE id = ?').get(userId);
|
||||
if (row?.kind !== 'guest') return false;
|
||||
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM users WHERE id = ?').run(userId);
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
softDelete(userId: string, now = Date.now()): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
|
||||
4
apps/server/src/db/migrations/003_guest_cleanup.sql
Normal file
4
apps/server/src/db/migrations/003_guest_cleanup.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Idle guests are deleted a day after their last visit (docs/05 §4).
|
||||
CREATE INDEX users_guest_seen ON users(kind, last_seen_at);
|
||||
-- last_seen_at was not kept up to date before this migration: start every guest's idle clock now.
|
||||
UPDATE users SET last_seen_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000 WHERE kind = 'guest';
|
||||
128
apps/server/src/guests.test.ts
Normal file
128
apps/server/src/guests.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/** Guest accounts are deleted on logout and after a day away (docs/05 §5); leaving a room page detaches. */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||
import { GUEST_IDLE_MS } from './server';
|
||||
|
||||
type Booted = ReturnType<typeof boot>;
|
||||
const running: Booted[] = [];
|
||||
const clients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const c of clients.splice(0)) c.close();
|
||||
for (const s of running.splice(0)) await s.stop();
|
||||
});
|
||||
|
||||
function start(): Booted {
|
||||
const s = boot();
|
||||
running.push(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async function connect(s: Booted, g: { cookie: string; id: string }): Promise<Client> {
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
async function practiceRoom(s: Booted, c: Client): Promise<string> {
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: c.cookie, body: JSON.stringify({ gameId: 'omok', practice: true }) });
|
||||
expect(r.res.status).toBe(200);
|
||||
c.send({ t: 'join', code: r.body.code, as: 'player' });
|
||||
await c.waitFor((m) => m.t === 'room');
|
||||
return r.body.code;
|
||||
}
|
||||
|
||||
const userRow = (s: Booted, id: string) => s.db.query('SELECT id FROM users WHERE id = ?').get(id);
|
||||
const setSeen = (s: Booted, id: string, at: number) => s.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(at, id);
|
||||
|
||||
describe('guest logout', () => {
|
||||
test('deletes the guest: account, sessions, seat (the practice room closes) and open sockets', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '지울게스트');
|
||||
const c = await connect(s, g);
|
||||
const code = await practiceRoom(s, c);
|
||||
expect(s.rooms.get(code)).toBeDefined();
|
||||
const out = await api(s.base, '/api/auth/logout', { method: 'POST', cookie: g.cookie });
|
||||
expect(out.res.status).toBe(200);
|
||||
expect(userRow(s, g.id)).toBeNull();
|
||||
expect(s.db.query('SELECT 1 FROM sessions WHERE user_id = ?').get(g.id)).toBeNull();
|
||||
expect(s.rooms.get(code)).toBeUndefined();
|
||||
for (let i = 0; i < 50 && !c.closed; i++) await sleep(10);
|
||||
expect(c.closed).not.toBeNull();
|
||||
expect((await api(s.base, '/api/me', { cookie: g.cookie })).body.me).toBeNull();
|
||||
});
|
||||
|
||||
test('a member logging out keeps the account', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '회원');
|
||||
s.users.promoteToMember(g.id, null);
|
||||
await api(s.base, '/api/auth/logout', { method: 'POST', cookie: g.cookie });
|
||||
expect(userRow(s, g.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('idle guests', () => {
|
||||
test('a guest away for a day is deleted; one still on the site, holding a seat, or seen recently is kept', async () => {
|
||||
const s = start();
|
||||
const old = Date.now() - GUEST_IDLE_MS - 60_000;
|
||||
const gone = await guest(s.base, '떠난게스트');
|
||||
const online = await guest(s.base, '접속중');
|
||||
const seated = await guest(s.base, '자리있음');
|
||||
const recent = await guest(s.base, '최근');
|
||||
await connect(s, online);
|
||||
const sc = await connect(s, seated);
|
||||
// A human lobby (no computers): the seat survives the socket closing.
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: seated.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
sc.send({ t: 'join', code: r.body.code, as: 'player' });
|
||||
await sc.waitFor((m) => m.t === 'room');
|
||||
sc.close();
|
||||
for (let i = 0; i < 50 && !sc.closed; i++) await sleep(10);
|
||||
await sleep(20);
|
||||
for (const g of [gone, online, seated]) setSeen(s, g.id, old);
|
||||
s.purgeIdleGuests();
|
||||
expect(userRow(s, gone.id)).toBeNull();
|
||||
expect(userRow(s, online.id)).not.toBeNull();
|
||||
expect(userRow(s, seated.id)).not.toBeNull();
|
||||
expect(userRow(s, recent.id)).not.toBeNull();
|
||||
// Coming back counts as activity.
|
||||
setSeen(s, recent.id, old);
|
||||
expect((await api(s.base, '/api/me', { cookie: recent.cookie })).body.me.id).toBe(recent.id);
|
||||
s.purgeIdleGuests();
|
||||
expect(userRow(s, recent.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaving the room page', () => {
|
||||
test('detach keeps the seat but the room no longer counts the connection', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '홈으로');
|
||||
const c = await connect(s, g);
|
||||
const code = await practiceRoom(s, c);
|
||||
const room = s.rooms.get(code)!;
|
||||
expect(room.conns.size).toBe(1);
|
||||
c.send({ t: 'detach' });
|
||||
for (let i = 0; i < 50 && room.conns.size; i++) await sleep(10);
|
||||
expect(room.conns.size).toBe(0);
|
||||
expect(room.seatOf(g.id)).toBeGreaterThanOrEqual(0);
|
||||
expect(room.emptySince).not.toBeNull();
|
||||
// Still on the site: counts as connected (no "연결 끊김", no auto moves).
|
||||
expect(room.presence.get(g.id)?.connected).toBe(true);
|
||||
// Detaching twice is harmless.
|
||||
c.send({ t: 'detach' });
|
||||
c.send({ t: 'ping', ts: 1 });
|
||||
await c.waitFor((m) => m.t === 'pong');
|
||||
expect(c.messages.some((m) => m.t === 'error')).toBe(false);
|
||||
// Coming back to the page re-attaches the same socket.
|
||||
c.send({ t: 'join', code, as: 'player' });
|
||||
for (let i = 0; i < 50 && !room.conns.size; i++) await sleep(10);
|
||||
expect(room.conns.size).toBe(1);
|
||||
expect(room.emptySince).toBeNull();
|
||||
// Closing the site while detached: the room learns the person went offline.
|
||||
c.send({ t: 'detach' });
|
||||
for (let i = 0; i < 50 && room.conns.size; i++) await sleep(10);
|
||||
c.close();
|
||||
for (let i = 0; i < 50 && room.presence.get(g.id)?.connected; i++) await sleep(10);
|
||||
expect(room.presence.get(g.id)?.connected).toBe(false);
|
||||
expect(room.detached.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ export interface HttpDeps {
|
||||
roles: Roles;
|
||||
settings: SettingsStore;
|
||||
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
|
||||
/** Deletes a guest account everywhere (rooms, sockets, DB). */
|
||||
removeGuest: (userId: string) => void;
|
||||
}
|
||||
|
||||
type Env = { Variables: { userId: string | null } };
|
||||
@@ -169,7 +171,10 @@ export function createHttpApp(d: HttpDeps) {
|
||||
|
||||
app.post('/api/auth/logout', (c) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
if (token) d.sessions.revoke(token);
|
||||
const u = requireUser(c);
|
||||
// A guest has no way to log back in, so logging out deletes the account (docs/05 §4).
|
||||
if (u && d.users.get(u)?.kind === 'guest') d.removeGuest(u);
|
||||
else if (token) d.sessions.revoke(token);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -248,6 +248,91 @@ describe('playing with bots', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('practice rooms close once nobody is there', () => {
|
||||
function finishedPractice() {
|
||||
const h = harness();
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.act('A', 1, { type: 'resign' });
|
||||
expect(room.status).toBe('finished');
|
||||
return { ...h, room, a };
|
||||
}
|
||||
|
||||
test('leaving on purpose (only computers left) closes the room right away', () => {
|
||||
const { room, manager, store } = finishedPractice();
|
||||
room.leave('A');
|
||||
expect(manager.get(room.code)).toBeUndefined();
|
||||
expect(store.openRooms()).toHaveLength(0);
|
||||
expect(manager.activeRoomFor('A')).toBeNull();
|
||||
});
|
||||
|
||||
test('leaving mid-game also closes it', () => {
|
||||
const h = harness();
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
room.join(h.conn('A'), 'player');
|
||||
room.start('A');
|
||||
room.leave('A');
|
||||
expect(h.manager.get(room.code)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('going to another page (seat kept) or closing the site: closed a minute later by the sweep', () => {
|
||||
const { room, a, manager, clock, conn } = finishedPractice();
|
||||
room.disconnect(a);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBe(room); // a refresh reconnects within seconds
|
||||
clock.advance(30_000);
|
||||
room.join(conn('A'), 'player');
|
||||
room.disconnect(room.conns.get('A')!);
|
||||
clock.advance(59_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBe(room); // the minute counts from the last disconnect
|
||||
clock.advance(1_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a running practice game with nobody connected waits 30 minutes', () => {
|
||||
const h = harness();
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||
room.start('A');
|
||||
room.disconnect(a);
|
||||
h.clock.advance(29 * 60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(room.code)).toBe(room);
|
||||
h.clock.advance(60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(room.code)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rooms without computers keep the old timeouts; someone still connected keeps any room', () => {
|
||||
const h = harness();
|
||||
const room = h.create('A');
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
room.join(h.conn('B'), 'player');
|
||||
room.leave('B');
|
||||
expect(h.manager.get(room.code)).toBe(room);
|
||||
const practice = h.manager.createPractice('C', 'omok') as Room;
|
||||
rooms.push(practice);
|
||||
practice.join(h.conn('C'), 'player');
|
||||
h.clock.advance(5 * 60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(practice.code)).toBe(practice);
|
||||
room.disconnect(a);
|
||||
h.clock.advance(5 * 60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(room.code)).toBe(room); // empty lobby: 10 minutes
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore after restart', () => {
|
||||
test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => {
|
||||
const { manager, conn, store, clock, users } = harness();
|
||||
|
||||
@@ -10,6 +10,9 @@ export const MAX_ROOMS = 2000;
|
||||
export const MAX_ROOMS_PER_USER = 3;
|
||||
const EMPTY_LOBBY_TTL = 10 * 60_000;
|
||||
const FINISHED_TTL = 30 * 60_000;
|
||||
/** Rooms with computer players nobody is looking at: closed this long after the last person went away. */
|
||||
const PRACTICE_IDLE_TTL = 60_000;
|
||||
const PRACTICE_GAME_TTL = 30 * 60_000;
|
||||
|
||||
export interface ManagerDeps {
|
||||
store: RoomStore;
|
||||
@@ -42,6 +45,10 @@ export class RoomManager {
|
||||
log: this.deps.log,
|
||||
settings: this.deps.settings,
|
||||
botDelay: this.deps.botDelay,
|
||||
onEmpty: (room, why) => {
|
||||
// Everyone left a room with computer players on purpose: nobody can come back to it, close it now.
|
||||
if (why === 'leave' && this.byCode.get(room.code) === room && room.hasBots() && !room.hasPeopleSeated()) this.close(room);
|
||||
},
|
||||
onSeatChange: (room, userId, seated) => {
|
||||
if (isBotId(userId)) return;
|
||||
const set = this.seatIndex.get(userId) ?? new Set<string>();
|
||||
@@ -150,6 +157,11 @@ export class RoomManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Frees every seat the user holds (account removed). */
|
||||
leaveAll(userId: string): void {
|
||||
for (const code of [...(this.seatIndex.get(userId) ?? [])]) this.byCode.get(code)?.leave(userId);
|
||||
}
|
||||
|
||||
publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] {
|
||||
return this.all()
|
||||
// Rooms with computer players are practice rooms: never listed, never filled by quick play.
|
||||
@@ -198,6 +210,13 @@ export class RoomManager {
|
||||
for (const room of this.all()) {
|
||||
if (room.conns.size > 0) continue;
|
||||
const idle = now - room.lastActiveAt;
|
||||
if (room.hasBots()) {
|
||||
// Practice rooms are private and only for the people who made them: once nobody is looking, close soon.
|
||||
const empty = now - (room.emptySince ?? now);
|
||||
const ttl = room.status === 'playing' || room.status === 'paused' ? PRACTICE_GAME_TTL : PRACTICE_IDLE_TTL;
|
||||
if (empty >= ttl) this.close(room);
|
||||
continue;
|
||||
}
|
||||
const closable =
|
||||
(room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) ||
|
||||
(room.status === 'finished' && idle > FINISHED_TTL) ||
|
||||
|
||||
@@ -76,7 +76,8 @@ export interface RoomDeps {
|
||||
users: { publicUser(id: string): PublicUser | null };
|
||||
now?: () => number;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
onEmpty?: (room: Room) => void;
|
||||
/** The last connection went away: `leave` = someone left on purpose, `disconnect` = a socket dropped or the page was left. */
|
||||
onEmpty?: (room: Room, why: 'leave' | 'disconnect') => void;
|
||||
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
|
||||
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
|
||||
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
|
||||
@@ -97,6 +98,8 @@ export class Room {
|
||||
spectators = new Set<string>();
|
||||
presence = new Map<string, Presence>();
|
||||
conns = new Map<string, Conn>();
|
||||
/** Seated people still on the site but away from the room page (no room messages; still count as connected). */
|
||||
detached = new Map<string, Conn>();
|
||||
kicked = new Set<string>();
|
||||
/** Players who left during a game; their seat is freed when the game ends. */
|
||||
leftDuringGame = new Set<string>();
|
||||
@@ -108,6 +111,8 @@ export class Room {
|
||||
game: GameSlot | null = null;
|
||||
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
||||
lastActiveAt: number;
|
||||
/** When the last connection went away (null while someone is connected). */
|
||||
emptySince: number | null;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
|
||||
private pendingLogs: LogEntry[] = [];
|
||||
@@ -130,6 +135,7 @@ export class Room {
|
||||
this.seats = new Array(config.maxPlayers).fill(null);
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.lastActiveAt = this.now();
|
||||
this.emptySince = this.now();
|
||||
const room = this;
|
||||
this.bots = new BotDriver(
|
||||
{
|
||||
@@ -205,6 +211,11 @@ export class Room {
|
||||
return this.seats.some((s) => !!s && isBotId(s.userId));
|
||||
}
|
||||
|
||||
/** A person still holds a seat (not counting people who already left a running game). */
|
||||
hasPeopleSeated(): boolean {
|
||||
return this.seats.some((s) => !!s && !isBotId(s.userId) && !this.leftDuringGame.has(s.userId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshot
|
||||
|
||||
snapshot(): RoomSnapshot {
|
||||
@@ -409,6 +420,8 @@ export class Room {
|
||||
this.leftDuringGame.delete(u);
|
||||
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
|
||||
this.conns.set(u, conn);
|
||||
this.detached.delete(u);
|
||||
this.emptySince = null;
|
||||
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
||||
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
||||
}
|
||||
@@ -422,11 +435,25 @@ export class Room {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left the room page but still on the site (홈, 규칙…): no more room messages and the room no longer counts the
|
||||
* connection, but the seat stays and the person still counts as connected (no "연결 끊김", no auto moves).
|
||||
*/
|
||||
detach(conn: Conn): void {
|
||||
const u = conn.userId;
|
||||
if (this.conns.get(u) !== conn) return;
|
||||
if (this.spectators.has(u)) return this.disconnect(conn);
|
||||
this.conns.delete(u);
|
||||
this.detached.set(u, conn);
|
||||
if (this.conns.size === 0) this.becameEmpty('disconnect');
|
||||
}
|
||||
|
||||
/** Connection dropped (not an intentional leave). */
|
||||
disconnect(conn: Conn): void {
|
||||
const u = conn.userId;
|
||||
if (this.conns.get(u) !== conn) return;
|
||||
this.conns.delete(u);
|
||||
if (this.detached.get(u) === conn) this.detached.delete(u);
|
||||
else if (this.conns.get(u) === conn) this.conns.delete(u);
|
||||
else return;
|
||||
const p = this.presence.get(u);
|
||||
if (p) {
|
||||
p.connected = false;
|
||||
@@ -440,12 +467,13 @@ export class Room {
|
||||
}
|
||||
this.broadcastRoom();
|
||||
this.scheduleTimer();
|
||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
||||
if (this.conns.size === 0) this.becameEmpty('disconnect');
|
||||
}
|
||||
|
||||
leave(userId: string): void {
|
||||
const conn = this.conns.get(userId);
|
||||
this.conns.delete(userId);
|
||||
this.detached.delete(userId);
|
||||
this.spectators.delete(userId);
|
||||
this.presence.delete(userId);
|
||||
const seat = this.seatOf(userId);
|
||||
@@ -471,7 +499,12 @@ export class Room {
|
||||
this.persist({ actorId: userId, kind: 'leave', data: null });
|
||||
this.broadcastRoom();
|
||||
conn?.send({ t: 'bye', reason: 'closed' });
|
||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
||||
if (this.conns.size === 0) this.becameEmpty('leave');
|
||||
}
|
||||
|
||||
private becameEmpty(why: 'leave' | 'disconnect'): void {
|
||||
this.emptySince ??= this.now();
|
||||
this.deps.onEmpty?.(this, why);
|
||||
}
|
||||
|
||||
private pickNewHost(): void {
|
||||
|
||||
@@ -16,6 +16,9 @@ import { Gateway, type WsData } from './ws/gateway';
|
||||
import { Audit, Roles } from './admin/roles';
|
||||
import { SettingsStore } from './admin/settings';
|
||||
|
||||
/** A guest who closed the site and stayed away this long is deleted. */
|
||||
export const GUEST_IDLE_MS = 24 * 3600_000;
|
||||
|
||||
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
|
||||
export const jsonLog: LogFn = (level, msg, extra) => {
|
||||
@@ -31,6 +34,8 @@ export interface StartOptions {
|
||||
discordExchange?: HttpDeps['discordExchange'];
|
||||
/** Computer players' thinking time (tests shorten it). */
|
||||
botDelay?: (choices: number, withPeople: boolean) => number;
|
||||
/** Upper bound for waiting on Bun's server.stop() (tests shorten it). */
|
||||
stopWaitMs?: number;
|
||||
}
|
||||
|
||||
export function startServer(opts: StartOptions) {
|
||||
@@ -53,7 +58,20 @@ export function startServer(opts: StartOptions) {
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings, botDelay: opts.botDelay });
|
||||
const restored = rooms.restoreAll();
|
||||
log('info', 'rooms restored', restored);
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log });
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log, onClose: (id) => users.touch(id) });
|
||||
const removeGuest = (id: string) => {
|
||||
rooms.leaveAll(id);
|
||||
gateway.closeUser(id);
|
||||
if (users.deleteGuest(id)) log('info', 'guest removed', { user: id });
|
||||
};
|
||||
/** Guests who closed the site and stayed away a day are deleted (docs/05 §4). */
|
||||
const purgeIdleGuests = (now = Date.now()) => {
|
||||
for (const id of users.idleGuests(now - GUEST_IDLE_MS)) {
|
||||
// Still on the site (socket open) or still holding a seat: not idle.
|
||||
if (gateway.isOnline(id) || rooms.activeRoomFor(id)) continue;
|
||||
removeGuest(id);
|
||||
}
|
||||
};
|
||||
let ready = true;
|
||||
|
||||
const ipOf = (req: Request) =>
|
||||
@@ -72,6 +90,7 @@ export function startServer(opts: StartOptions) {
|
||||
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
||||
roles,
|
||||
settings,
|
||||
removeGuest,
|
||||
admin: {
|
||||
audit,
|
||||
games: GAMES,
|
||||
@@ -128,6 +147,7 @@ export function startServer(opts: StartOptions) {
|
||||
try {
|
||||
rooms.sweep();
|
||||
sessions.purgeExpired();
|
||||
purgeIdleGuests();
|
||||
store.purgeOldLogs();
|
||||
} catch (err) {
|
||||
log('error', 'sweep failed', { err: String(err) });
|
||||
@@ -141,11 +161,11 @@ export function startServer(opts: StartOptions) {
|
||||
rooms.shutdown();
|
||||
// Bun's stop() promise can stay pending after server-initiated WS closes even though the
|
||||
// listener is already closed, so bound the wait.
|
||||
await Promise.race([server.stop(true), Bun.sleep(1000)]);
|
||||
await Promise.race([server.stop(true), Bun.sleep(opts.stopWaitMs ?? 1000)]);
|
||||
db.close();
|
||||
};
|
||||
|
||||
return { server, stop, rooms, users, sessions, db, gateway, settings, roles };
|
||||
return { server, stop, rooms, users, sessions, db, gateway, settings, roles, purgeIdleGuests };
|
||||
}
|
||||
|
||||
async function serveFile(root: string, pathname: string): Promise<Response> {
|
||||
|
||||
@@ -15,7 +15,7 @@ export function tempDbPath(): string {
|
||||
|
||||
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
|
||||
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
|
||||
const app = startServer({ config, log: () => {}, staticDir: null, ...extra });
|
||||
const app = startServer({ config, log: () => {}, staticDir: null, stopWaitMs: 50, ...extra });
|
||||
const base = `http://localhost:${app.server.port}`;
|
||||
return { ...app, base, dbPath, config };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface GatewayDeps {
|
||||
me(userId: string): PublicUser | null;
|
||||
now?: () => number;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
/** A socket closed (the user may have left the site). */
|
||||
onClose?: (userId: string) => void;
|
||||
}
|
||||
|
||||
export const MAX_CONNS_PER_USER = 5;
|
||||
@@ -118,6 +120,7 @@ export class Gateway {
|
||||
if (this.byUser.get(ws.data.userId)?.size === 0) this.byUser.delete(ws.data.userId);
|
||||
const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||
if (room && ws.data.conn) room.disconnect(ws.data.conn);
|
||||
this.deps.onClose?.(ws.data.userId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,6 +146,11 @@ export class Gateway {
|
||||
return;
|
||||
}
|
||||
const room: Room | undefined = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||
if (m.t === 'detach') {
|
||||
// ws.data.room stays: when this socket closes, the room still learns that the person went offline.
|
||||
if (room && ws.data.conn) room.detach(ws.data.conn);
|
||||
return;
|
||||
}
|
||||
if (!room || room.conns.get(u) !== ws.data.conn) return err('먼저 방에 들어가 주세요.', 'not-in-room');
|
||||
let e: string | null = null;
|
||||
switch (m.t) {
|
||||
@@ -209,6 +217,18 @@ export class Gateway {
|
||||
}
|
||||
}
|
||||
|
||||
isOnline(userId: string): boolean {
|
||||
return (this.byUser.get(userId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** The account is gone (guest logged out or expired): close its sockets quietly. */
|
||||
closeUser(userId: string): void {
|
||||
for (const ws of [...(this.byUser.get(userId) ?? [])]) {
|
||||
this.send(ws, { t: 'bye', reason: 'closed' });
|
||||
ws.close(4401, 'logged out');
|
||||
}
|
||||
}
|
||||
|
||||
/** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
|
||||
shutdown(): void {
|
||||
this.closing = true;
|
||||
|
||||
Reference in New Issue
Block a user