플레이어가 모두 사이트를 닫으면 게임 중·끝난 방을 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 {
|
||||
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' });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`로 안내 → 재시작(수 초) → 자동 재연결·복구.
|
||||
|
||||
@@ -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. 컴퓨터 상대 (혼자 연습)
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user