M1 서버: 통합 테스트 + 방 단위 테스트, 종료 대기·중도 이탈 자리 처리 수정
- 실제 서버 + WS 클라이언트로 테스트 문서 3절 시나리오 검증(방 만들기·링크 입장·재접속· 서버 강제 종료 후 복구·차례 위반·중복 cs·탭 교체·방장 이전·도배 제한·잘못된 메시지· Origin 검사·디스코드 콜백 state 검증·게스트 합치기) - 시계를 조작하는 방 단위 테스트: 시간 초과 자동 착수, 연결 끊김 유예, 재시작 후 마감 30초 유예, 게임 오류 격리(3회 → 일시 정지), 한 판 더 흑백 교대, 게임 중 나가기 - Bun server.stop()이 서버 쪽 WS 종료 뒤 끝나지 않는 문제: 대기 시간 1초로 제한 - 게임 중 나간 사람의 자리가 게임 종료 후 비워지지 않던 버그 수정 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
182
apps/server/src/rooms/room.test.ts
Normal file
182
apps/server/src/rooms/room.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/** Room-level tests with a controllable clock: timers, grace period, restore, error isolation. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { z } from 'zod';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig, type RoomSnapshot } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
function harness(games: Record<string, AnyGameDefinition> = GAMES) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const deps = {
|
||||
store,
|
||||
games,
|
||||
users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) },
|
||||
now: clock.now,
|
||||
};
|
||||
const config: RoomConfig = {
|
||||
gameId: Object.keys(games)[0]!,
|
||||
options: Object.values(games)[0]!.defaultOptions,
|
||||
maxPlayers: 2,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, 'A', deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: 'A', gameId: config.gameId, status: 'lobby', visibility: 'private', config, stateJson: '{}', now: now });
|
||||
const conn = (userId: string) => {
|
||||
const msgs: ServerMessage[] = [];
|
||||
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
return c;
|
||||
};
|
||||
return { room, store, clock, deps, conn, db };
|
||||
}
|
||||
|
||||
describe('Room timers', () => {
|
||||
test('8. turn timeout applies onTimeout (omok perMove autoMove)', () => {
|
||||
const { room, clock, conn } = harness();
|
||||
const a = conn('A');
|
||||
const b = conn('B');
|
||||
room.join(a, 'player');
|
||||
room.join(b, 'player');
|
||||
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
|
||||
clock.advance(9_999);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect((room.game!.state as any).moves).toHaveLength(0);
|
||||
clock.advance(1);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect((room.game!.state as any).moves).toHaveLength(1);
|
||||
expect((room.game!.state as any).moves[0].auto).toBe(true);
|
||||
});
|
||||
|
||||
test('disconnected player is auto-acted after the grace period, not before the turn deadline rules', () => {
|
||||
const { room, clock, conn } = harness();
|
||||
const a = conn('A');
|
||||
const b = conn('B');
|
||||
room.join(a, 'player');
|
||||
room.join(b, 'player');
|
||||
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } });
|
||||
room.start('A');
|
||||
room.clearTimer();
|
||||
// No time control: idle limit 300s. Black disconnects → grace 60s wins.
|
||||
room.disconnect(a);
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
|
||||
expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true);
|
||||
// Reconnect before the grace period ends → deadline goes back to the idle limit.
|
||||
clock.advance(30_000);
|
||||
room.join(conn('A'), 'player');
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(1_000_000 + 300_000);
|
||||
});
|
||||
|
||||
test('restore postpones overdue deadlines by 30s and keeps game state', () => {
|
||||
const { room, clock, conn, deps, store } = harness();
|
||||
room.join(conn('A'), 'player');
|
||||
room.join(conn('B'), 'player');
|
||||
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
|
||||
room.start('A');
|
||||
room.clearTimer();
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
room.clearTimer();
|
||||
const { seq, stateJson } = store.openRooms()[0]!;
|
||||
clock.advance(600_000); // server was down for 10 minutes
|
||||
const restored = Room.restore('room1', '482913', seq, JSON.parse(stateJson) as RoomSnapshot, deps);
|
||||
restored.clearTimer();
|
||||
expect((restored.game!.state as any).moves).toHaveLength(1);
|
||||
expect(restored.effectiveDeadline()).toBe(clock.now() + 30_000);
|
||||
expect(restored.roomView().seats.every((s) => s && !s.connected)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Room isolation and lobby', () => {
|
||||
const boom: AnyGameDefinition = {
|
||||
id: 'boom',
|
||||
nameKo: '폭탄',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
stateVersion: 1,
|
||||
defaultOptions: {},
|
||||
optionsSchema: z.object({}),
|
||||
actionSchema: z.object({ type: z.literal('go') }),
|
||||
setup: ({ players }) => ({ players, n: 0 }),
|
||||
validate: () => ({ ok: true }),
|
||||
apply: () => {
|
||||
throw new Error('bug');
|
||||
},
|
||||
view: (s) => s,
|
||||
activePlayers: (s: any) => s.players,
|
||||
onTimeout: () => ({ type: 'go' }),
|
||||
result: () => null,
|
||||
};
|
||||
|
||||
test('a throwing game rejects the action, keeps state, pauses after 3 errors', () => {
|
||||
const { room, conn } = harness({ boom });
|
||||
const a = conn('A');
|
||||
room.join(a, 'player');
|
||||
room.join(conn('B'), 'player');
|
||||
room.start('A');
|
||||
for (let i = 1; i <= 3; i++) room.act('A', i, { type: 'go' });
|
||||
expect(a.msgs.filter((m) => m.t === 'reject')).toHaveLength(3);
|
||||
expect(room.status).toBe('paused');
|
||||
expect((room.game!.state as any).n).toBe(0);
|
||||
expect(room.voidGame('A')).toBeNull();
|
||||
expect(room.status).toBe('finished');
|
||||
});
|
||||
|
||||
test('seat rules: start needs min players, only host starts, kicked users cannot rejoin', () => {
|
||||
const { room, conn } = harness();
|
||||
room.join(conn('A'), 'player');
|
||||
expect(room.start('A')).toBe('1명 더 필요해요.');
|
||||
room.join(conn('B'), 'player');
|
||||
expect(room.start('B')).toBe('방장만 시작할 수 있어요.');
|
||||
room.join(conn('C'), 'player');
|
||||
expect(room.spectators.has('C')).toBe(true);
|
||||
expect(room.kick('A', 'C')).toBeNull();
|
||||
expect(room.join(conn('C'), 'player')).toBe('방장이 내보낸 방에는 다시 들어갈 수 없어요.');
|
||||
});
|
||||
|
||||
test('rematch starts when all seated players vote; colours alternate with gameNo', () => {
|
||||
const { room, conn } = harness();
|
||||
room.join(conn('A'), 'player');
|
||||
room.join(conn('B'), 'player');
|
||||
room.configure('A', { options: { colorAssignment: 'alternate' } });
|
||||
room.start('A');
|
||||
room.clearTimer();
|
||||
expect((room.game!.state as any).players.black).toBe('A');
|
||||
room.act('B', 1, { type: 'resign' });
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.sessionStats).toEqual({ A: { played: 1, wins: 1 }, B: { played: 1, wins: 0 } });
|
||||
room.rematch('A');
|
||||
expect(room.status).toBe('finished');
|
||||
room.rematch('B');
|
||||
room.clearTimer();
|
||||
expect(room.status).toBe('playing');
|
||||
expect(room.gameNo).toBe(2);
|
||||
expect((room.game!.state as any).players.black).toBe('B');
|
||||
});
|
||||
|
||||
test('leaving mid-game resigns via onLeave', () => {
|
||||
const { room, conn } = harness();
|
||||
room.join(conn('A'), 'player');
|
||||
room.join(conn('B'), 'player');
|
||||
room.start('A');
|
||||
room.clearTimer();
|
||||
room.leave('B');
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.lastResult?.ranking).toEqual([['A'], ['B']]);
|
||||
expect(room.seatOf('B')).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,8 @@ export class Room {
|
||||
presence = new Map<string, Presence>();
|
||||
conns = new Map<string, Conn>();
|
||||
kicked = new Set<string>();
|
||||
/** Players who left during a game; their seat is freed when the game ends. */
|
||||
leftDuringGame = new Set<string>();
|
||||
gameNo = 0;
|
||||
sessionStats: Record<string, { played: number; wins: number }> = {};
|
||||
rematchVotes = new Set<string>();
|
||||
@@ -314,7 +316,8 @@ export class Room {
|
||||
}
|
||||
}
|
||||
const prev = this.presence.get(u);
|
||||
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt ?? now });
|
||||
this.leftDuringGame.delete(u);
|
||||
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
|
||||
this.conns.set(u, conn);
|
||||
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
||||
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
||||
@@ -358,10 +361,11 @@ export class Room {
|
||||
const seat = this.seatOf(userId);
|
||||
if (seat >= 0) {
|
||||
if (this.inGame(userId)) {
|
||||
// Stay in the seat as "away" until the game ends so the game state stays consistent.
|
||||
this.leftDuringGame.add(userId);
|
||||
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
|
||||
const action = this.def.onLeave?.(this.game!.state, userId);
|
||||
if (action) this.runAction(userId, action, undefined, true);
|
||||
// Stay in the seat as "away" until the game ends so the game state stays consistent.
|
||||
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
|
||||
} else {
|
||||
this.seats[seat] = null;
|
||||
this.rematchVotes.delete(userId);
|
||||
@@ -708,12 +712,13 @@ export class Room {
|
||||
// Players who left during the game lose their seat now.
|
||||
for (let i = 0; i < this.seats.length; i++) {
|
||||
const s = this.seats[i];
|
||||
if (s && !this.presence.get(s.userId)?.connected && this.presence.get(s.userId)?.joinedAt === 0) {
|
||||
if (s && this.leftDuringGame.has(s.userId)) {
|
||||
this.seats[i] = null;
|
||||
this.presence.delete(s.userId);
|
||||
this.deps.onSeatChange?.(this, s.userId, false);
|
||||
}
|
||||
}
|
||||
this.leftDuringGame.clear();
|
||||
this.persist({ actorId: null, kind: 'finish', data: { result, voided } });
|
||||
this.broadcastState([{ type: 'gameOver' }]);
|
||||
for (const c of this.conns.values()) c.send({ t: 'result', seq: this.seq, result });
|
||||
|
||||
Reference in New Issue
Block a user