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

@@ -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) ?? [])]) {