플레이어가 모두 사이트를 닫으면 게임 중·끝난 방을 1분 뒤 닫기(관전자는 셈하지 않음)

- 게이트웨이가 사용자별 '마지막 소켓이 닫힌 시각'을 기록(offlineSince). 소켓이 하나라도 열려 있으면 사이트에 있는 것
- sweep: 대기실이 아닌 방에서 자리에 앉은 사람(컴퓨터·게임 중 나간 사람 제외)이 모두 사이트를 떠난 지 1분이면
  게임을 무효로 끝내고 방을 닫는다. 관전자가 접속해 있어도 닫고 내보낸다. 대기실은 초대 입장 때문에 기존 규칙 유지
- 홈 등 다른 화면에서 새로 연 창도 자리가 있으면(/api/me activeRoom) 소켓을 연결해 '사이트 열림'으로 남게
- Room.closeByAdmin을 closeWith(summary, notice)로 일반화
- e2e(bots): 오목은 컴퓨터가 3번만 받게(이후 단계 전에 게임이 끝나지 않게), 홈 단계를 원카드 앞으로(턴 시간 초과 방지)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-08 09:47:22 +09:00
parent 0a587a8b98
commit f5671cda70
10 changed files with 229 additions and 34 deletions

View File

@@ -126,3 +126,24 @@ describe('leaving the room page', () => {
expect(room.detached.size).toBe(0);
});
});
describe('on the site or not', () => {
test('offlineSince: null while any socket is open, the close time once the last one closes', async () => {
const s = start();
const g = await guest(s.base, '사이트');
expect(s.gateway.offlineSince(g.id)).toBe(0); // never connected since the server started
const c1 = await connect(s, g);
const c2 = await connect(s, g);
expect(s.gateway.offlineSince(g.id)).toBeNull();
c1.close();
for (let i = 0; i < 50 && !c1.closed; i++) await sleep(10);
await sleep(20);
expect(s.gateway.offlineSince(g.id)).toBeNull(); // another tab is still open
const before = Date.now();
c2.close();
for (let i = 0; i < 50 && s.gateway.offlineSince(g.id) === null; i++) await sleep(10);
expect(s.gateway.offlineSince(g.id)).toBeGreaterThanOrEqual(before);
await connect(s, g);
expect(s.gateway.offlineSince(g.id)).toBeNull();
});
});

View File

@@ -14,13 +14,13 @@ afterEach(() => {
for (const r of rooms.splice(0)) r.clearTimer();
});
function harness(log?: (level: 'info' | 'warn' | 'error', msg: string) => void) {
function harness(log?: (level: 'info' | 'warn' | 'error', msg: string) => void, offlineSince?: (userId: string) => number | null) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const users = { publicUser: (id: string) => botProfile(id) ?? { id, nickname: id, avatar: null, kind: 'guest' as const } };
const manager = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0, log });
const manager = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0, log, offlineSince });
const conn = (userId: string) => {
const msgs: ServerMessage[] = [];
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
@@ -295,7 +295,7 @@ describe('practice rooms close once nobody is there', () => {
expect(manager.get(room.code)).toBeUndefined();
});
test('a running practice game with nobody connected waits 30 minutes', () => {
test('a running practice game left for another page (site still open) waits 30 minutes', () => {
const h = harness();
const room = h.manager.createPractice('A', 'omok') as Room;
rooms.push(room);
@@ -303,7 +303,7 @@ describe('practice rooms close once nobody is there', () => {
room.join(a, 'player');
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
room.start('A');
room.disconnect(a);
room.detach(a);
h.clock.advance(29 * 60_000);
h.manager.sweep();
expect(h.manager.get(room.code)).toBe(room);
@@ -333,6 +333,97 @@ describe('practice rooms close once nobody is there', () => {
});
});
describe('every player closed the site', () => {
/** A running human omok game (A black) with spectator S watching; `online` = who still has the site open. */
function humanGame() {
const online = new Set<string>(['A', 'B', 'S']);
const offAt = new Map<string, number>();
const h = harness(undefined, (u) => (online.has(u) ? null : (offAt.get(u) ?? 0)));
const room = h.create('A');
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
const a = h.conn('A');
const b = h.conn('B');
const sp = h.conn('S');
room.join(a, 'player');
room.join(b, 'player');
expect(room.start('A')).toBeNull();
room.join(sp, 'spectator');
/** The socket closed and the user has no other page of the site open. */
const closeSite = (u: string, c: Conn) => {
room.disconnect(c);
online.delete(u);
offAt.set(u, h.clock.now());
};
return { ...h, room, a, b, sp, online, offAt, closeSite };
}
test('a running game closes a minute after the last player left the site, spectators or not', () => {
const { room, a, b, sp, manager, clock, store, closeSite } = humanGame();
closeSite('A', a);
clock.advance(30_000);
closeSite('B', b);
clock.advance(59_000);
manager.sweep();
expect(manager.get(room.code)).toBe(room); // a refresh comes back within seconds
clock.advance(1_000);
manager.sweep();
expect(manager.get(room.code)).toBeUndefined();
expect(store.openRooms()).toHaveLength(0);
expect(sp.msgs.some((m) => m.t === 'bye' && m.reason === 'closed')).toBe(true);
expect(manager.activeRoomFor('A')).toBeNull();
expect(manager.activeRoomFor('B')).toBeNull();
});
test('a player still on the site (other page, or reconnected after a drop) keeps the game', () => {
const { room, a, b, manager, clock, online, closeSite } = humanGame();
closeSite('B', b);
room.detach(a); // A went to the home page
clock.advance(60 * 60_000);
manager.sweep();
expect(manager.get(room.code)).toBe(room);
// A's socket dropped and came back on the home page: not in the room any more, but still on the site.
room.disconnect(a);
online.add('A');
clock.advance(60 * 60_000);
manager.sweep();
expect(manager.get(room.code)).toBe(room);
});
test('a finished game closes too; a lobby waits for invited people (10 minutes)', () => {
const { room, a, b, manager, clock, closeSite } = humanGame();
room.act('A', 1, { type: 'resign' });
expect(room.status).toBe('finished');
closeSite('A', a);
closeSite('B', b);
clock.advance(60_000);
manager.sweep();
expect(manager.get(room.code)).toBeUndefined();
const h = harness(undefined, () => 0);
const lobby = h.create('C');
const c = h.conn('C');
lobby.join(c, 'player');
lobby.disconnect(c);
h.clock.advance(5 * 60_000);
h.manager.sweep();
expect(h.manager.get(lobby.code)).toBe(lobby);
});
test('a practice game closes a minute after the site is closed', () => {
const h = harness(undefined, () => 0);
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(60_000);
h.manager.sweep();
expect(h.manager.get(room.code)).toBeUndefined();
});
});
describe('restore after restart', () => {
test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => {
const { manager, conn, store, clock, users } = harness();

View File

@@ -13,6 +13,8 @@ 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;
/** Every player closed the site (spectators aside): the room closes this long after the last one went offline. */
const PLAYERS_GONE_TTL = 60_000;
export interface ManagerDeps {
store: RoomStore;
@@ -23,6 +25,8 @@ export interface ManagerDeps {
random?: () => number;
settings?: SettingsStore;
botDelay?: RoomDeps['botDelay'];
/** When the user's last socket to the site closed; null while the site is open somewhere (the gateway knows). */
offlineSince?: (userId: string) => number | null;
}
export class RoomManager {
@@ -207,7 +211,16 @@ export class RoomManager {
/** Periodic cleanup (call every minute). */
sweep(): void {
const now = this.now();
const offlineSince = this.deps.offlineSince ?? (() => 0);
for (const room of this.all()) {
// A game (running or over) whose players all closed the site: nobody is coming back to it. Lobbies wait for
// people arriving by invite link, so they keep the timeouts below.
const gone = room.status === 'lobby' ? null : room.playersOfflineSince(offlineSince);
if (gone !== null && now - gone >= PLAYERS_GONE_TTL) {
room.closeWith('플레이어가 모두 나가서 무효 처리됐어요.', '플레이어가 모두 나가서 방을 닫았어요.');
this.close(room);
continue;
}
if (room.conns.size > 0) continue;
const idle = now - room.lastActiveAt;
if (room.hasBots()) {

View File

@@ -216,6 +216,23 @@ export class Room {
return this.seats.some((s) => !!s && !isBotId(s.userId) && !this.leftDuringGame.has(s.userId));
}
/**
* Since when every seated person has been off the site, or null while one of them is still on it (in this room or
* on any other page) or nobody is seated. `offlineSince(u)` is null while u has the site open. Computer players,
* people who already left a running game, and spectators don't count.
*/
playersOfflineSince(offlineSince: (userId: string) => number | null): number | null {
let since: number | null = null;
for (const s of this.seats) {
if (!s || isBotId(s.userId) || this.leftDuringGame.has(s.userId)) continue;
const p = this.presence.get(s.userId);
const off = offlineSince(s.userId);
if (p?.connected || off === null) return null;
since = Math.max(since ?? 0, p?.since ?? 0, off);
}
return since;
}
// ---------------------------------------------------------------- snapshot
snapshot(): RoomSnapshot {
@@ -1007,10 +1024,15 @@ export class Room {
/** Admin closes the room: void any running game, tell everyone, disconnect. */
closeByAdmin(): void {
if (this.game && !this.game.finished) {
this.finishGame({ ranking: [this.game.players], summary: '관리자가 방을 닫아 무효 처리됐어요.', reason: 'abandoned' }, true);
this.closeWith('관리자가 방을 닫아 무효 처리됐어요.', '관리자가 방을 닫았어요.');
}
this.notice('closed', '관리자가 방을 닫았어요.');
/** Ends a running game uncounted and sends everyone still in the room away (the manager then closes it). */
closeWith(summary: string, notice: string): void {
if (this.game && !this.game.finished) {
this.finishGame({ ranking: [this.game.players], summary, reason: 'abandoned' }, true);
}
this.notice('closed', notice);
this.clearTimer();
for (const c of this.conns.values()) {
c.send({ t: 'bye', reason: 'closed' });

View File

@@ -55,7 +55,7 @@ export function startServer(opts: StartOptions) {
const roles = new Roles(db, config.superadminDiscordIds);
const audit = new Audit(db);
const startedAt = Date.now();
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, offlineSince: (id): number | null => gateway.offlineSince(id) });
const restored = rooms.restoreAll();
log('info', 'rooms restored', restored);
const gateway = new Gateway({ rooms, me: publicUser, log, onClose: (id) => users.touch(id) });

View File

@@ -29,6 +29,8 @@ const MAX_BUFFERED = 1024 * 1024;
export class Gateway {
private nextId = 1;
private byUser = new Map<string, Set<ServerWebSocket<WsData>>>();
/** userId → when their last socket closed (removed again when one opens). */
private offlineAt = new Map<string, number>();
private closing = false;
constructor(private deps: GatewayDeps) {}
@@ -70,6 +72,7 @@ export class Gateway {
const set = this.byUser.get(ws.data.userId) ?? new Set();
set.add(ws);
this.byUser.set(ws.data.userId, set);
this.offlineAt.delete(ws.data.userId);
const me = this.deps.me(ws.data.userId);
if (!me) {
ws.close(4401, 'unauthorized');
@@ -117,7 +120,10 @@ export class Gateway {
close: (ws: ServerWebSocket<WsData>) => {
this.byUser.get(ws.data.userId)?.delete(ws);
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);
this.offlineAt.set(ws.data.userId, (this.deps.now ?? Date.now)());
}
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);
@@ -221,6 +227,11 @@ export class Gateway {
return (this.byUser.get(userId)?.size ?? 0) > 0;
}
/** When the user's last socket closed; null while one is open, 0 if not seen since the server started. */
offlineSince(userId: string): number | null {
return this.isOnline(userId) ? null : (this.offlineAt.get(userId) ?? 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) ?? [])]) {

View File

@@ -2,6 +2,7 @@
import { create } from 'zustand';
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
import { api, type Settings } from '../net/api';
import { socket } from '../net/socket';
const LS_KEY = 'bg.settings';
@@ -94,6 +95,9 @@ export const useSession = create<SessionState>((set, get) => ({
useAvatar: me.useAvatar ?? true,
hasDiscordAvatar: me.hasDiscordAvatar ?? false,
});
// Holding a seat: stay connected on every page so the server knows the site is still open (a game whose
// players all closed the site gets closed, docs/06 §11).
if (me.activeRoom) socket.start();
},
setMe(me) {
set({ me });

View File

@@ -64,5 +64,5 @@
- 로그: JSON 한 줄 로그(시간, 레벨, 방, 사용자, 이벤트). 게임 행동 내용은 `room_log`에만.
- 지표: `/internal/metrics`(외부 차단) — 연결 수, 방 수, 행동 처리 시간 히스토그램, 저장 시간, 오류 수, 메모리.
- 백업: 매일 SQLite `VACUUM INTO`로 백업 파일 생성, 14일 보관. 복구 절차는 `11-deployment-ops.md`.
- 정리: 아무도 없는 대기실 10분 후 삭제, 끝난 방 30분 후 메모리에서 내림(기록은 DB에 남음), 오래된 `room_log`는 90일 후 삭제.
- 정리: 아무도 없는 대기실 10분 후 삭제, 끝난 방 30분 후 메모리에서 내림(기록은 DB에 남음), 게임 중·끝난 방은 플레이어가 모두 사이트를 닫으면 1분 후 닫음(06 §11), 오래된 `room_log`는 90일 후 삭제.
- 무중단에 가까운 배포: 새 버전 시작 전 옛 버전이 `bye: restart`로 안내 → 재시작(수 초) → 자동 재연결·복구.

View File

@@ -95,7 +95,8 @@ interface Room {
```
- 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?").
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.
- 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다. 사람이 [방 나가기]로 모두 나가 컴퓨터만 남으면 바로 닫는다.
- 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다(사이트를 닫았으면 아래 규칙대로 1분). 사람이 [방 나가기]로 모두 나가 컴퓨터만 남으면 바로 닫는다.
- 게임 중이거나 끝난 방(대기실 제외)에서 자리에 앉은 사람이 모두 사이트를 닫으면(서버와 연결된 창이 하나도 없으면) 마지막 사람이 닫은 때부터 1분 뒤 정리 때 닫는다. 진행 중인 게임은 무효 처리되고, 관전자는 셈하지 않고, 남아 있던 관전자도 방에서 내보낸다. 대기실은 초대받은 사람이 들어올 수 있으므로 위 규칙을 따른다. 자리가 있는 사람은 다른 화면에 있어도 서버 연결을 유지한다(새로 연 창에서도 `/api/me`의 `activeRoom`이 있으면 연결).
- 방 화면을 벗어나면(홈, 규칙 등) 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다.
## 12. 컴퓨터 상대 (혼자 연습)

View File

@@ -84,13 +84,15 @@ try {
await a.screenshot({ path: join(SHOTS, 'bots-1-lobby-pc.png') });
});
await step('오목: 시작 → 컴퓨터가 5번 받아 둔다', async () => {
await step('오목: 시작 → 컴퓨터가 3번 받아 둔다', async () => {
await a.getByRole('button', { name: '▶ 게임 시작' }).click();
await a.locator('svg[role="grid"]').waitFor();
let answers = 0;
// Cells around the centre, nearest first.
const order = Array.from({ length: 225 }, (_, i) => i).sort((i, j) => Math.hypot((i % 15) - 7, Math.floor(i / 15) - 7) - Math.hypot((j % 15) - 7, Math.floor(j / 15) - 7));
for (let turn = 0; turn < 8 && answers < 5; turn++) {
// Three answers at most: even moving first the computer has only four stones, so the game is still running for
// the steps below.
for (let turn = 0; turn < 8 && answers < 3; turn++) {
await a.waitForFunction(() => /내 차례예요|게임이 끝|이겼|졌/.test(document.querySelector('.turn-status')?.textContent ?? ''), null, { timeout: 8000 });
if (await resultShown(a)) break;
const before = await stones(a);
@@ -106,6 +108,57 @@ try {
console.log(` omok: computer answered ${answers} times`);
});
// ---------------------------------------------------------------- leaving practice rooms (right after omok: its turn timer must not run out first)
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');
// A fresh page load on the home page reconnects (the seat is there), so the room still sees the site as open.
const id = userId('호랑이')!;
await a.reload();
await a.getByRole('link', { name: /하던 게임으로 돌아가기/ }).waitFor();
for (let i = 0; i < 100 && !app.gateway.isOnline(id); i++) await Bun.sleep(20);
if (!app.gateway.isOnline(id)) throw new Error('home page did not reconnect');
if (room.playersOfflineSince((u) => app.gateway.offlineSince(u)) !== null) throw new Error('site counted as closed');
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('오목(게임 중): 창을 닫으면 1분 뒤 정리 대상', async () => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' });
await ctx.addInitScript(() => localStorage.setItem('bg:qs:omok', '1'));
const c = await ctx.newPage();
c.on('pageerror', (e) => errors.push(e.message));
await startPractice(c, 'omok', '사자');
await c.getByRole('button', { name: '▶ 게임 시작' }).click();
await c.locator('svg[role="grid"]').waitFor();
const room = app.rooms.get(codeOf(c))!;
const id = userId('사자')!;
const off = () => room.playersOfflineSince((u) => app.gateway.offlineSince(u));
if (off() !== null) throw new Error('counted as gone while playing');
const before = Date.now();
await ctx.close();
for (let i = 0; i < 100 && off() === null; i++) await Bun.sleep(20);
const since = off();
if (since === null || since < before) throw new Error(`closing the window was not seen (${since})`);
if (app.gateway.isOnline(id)) throw new Error('still online after closing');
app.rooms.sweep();
if (!app.rooms.get(room.code)) throw new Error('closed before the minute was up');
});
// ---------------------------------------------------------------- one-card (phone)
await step('원카드: 연습 방 → 컴퓨터 넣기·빼기 → 3명', async () => {
await startPractice(b, 'one-card', '고양이');
@@ -160,28 +213,7 @@ try {
console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`);
});
// ---------------------------------------------------------------- leaving practice rooms, guest logout
const codeOf = (p: Page) => new URL(p.url()).pathname.split('/').pop()!;
const userId = (nick: string) => app.db.query<{ id: string }, [string]>('SELECT id FROM users WHERE nickname = ?').get(nick)?.id;
await step('오목(게임 중): ← 홈 → 자리는 남고 접속은 끊김, 아무도 안 보면 30분 뒤 정리', async () => {
const code = codeOf(a);
await a.getByRole('button', { name: '홈으로' }).click();
await a.waitForURL(`${ORIGIN}/`);
const room = app.rooms.get(code)!;
for (let i = 0; i < 50 && room.conns.size; i++) await Bun.sleep(20);
if (room.conns.size !== 0) throw new Error(`room still counts ${room.conns.size} connection(s)`);
if (room.seatOf(userId('호랑이')!) < 0) throw new Error('seat was not kept');
app.rooms.sweep();
if (!app.rooms.get(code)) throw new Error('closed too early');
room.emptySince = Date.now() - 29 * 60_000;
app.rooms.sweep();
if (!app.rooms.get(code)) throw new Error('running practice game closed before 30 minutes');
room.emptySince = Date.now() - 31 * 60_000;
app.rooms.sweep();
if (app.rooms.get(code)) throw new Error('practice room nobody looks at was not closed');
});
// ---------------------------------------------------------------- leaving with the menu, guest logout
await step('원카드: 메뉴 → 방 나가기 → 방이 바로 닫힘', async () => {
const code = codeOf(b);
if (!app.rooms.get(code)) throw new Error('room missing before leaving');