플레이어가 모두 사이트를 닫으면 게임 중·끝난 방을 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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) ?? [])]) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user