플레이어가 모두 사이트를 닫으면 게임 중·끝난 방을 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

@@ -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');