플레이어가 모두 사이트를 닫으면 게임 중·끝난 방을 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 {
this.closeWith('관리자가 방을 닫아 무효 처리됐어요.', '관리자가 방을 닫았어요.');
}
/** 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.finishGame({ ranking: [this.game.players], summary, reason: 'abandoned' }, true);
}
this.notice('closed', '관리자가 방을 닫았어요.');
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) ?? [])]) {