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:
EJClaw
2026-10-04 04:24:49 +09:00
parent 0f4d22f413
commit b1f3d7c500
5 changed files with 622 additions and 5 deletions

View File

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