/** 마피아 through a real Room: room chat closed while playing, filtered game chat per connection, server-run phases. */ import { describe, expect, test } from 'bun:test'; import type { ServerMessage } from '@bg/shared'; import { GAMES } from '@bg/games'; import type { MafiaState, MafiaView } from '@bg/games/mafia'; import { openDb } from '../db'; import { Room, type Conn, type RoomConfig } from './room'; import { RoomStore } from './store'; function harness(players: string[]) { const db = openDb(':memory:'); const store = new RoomStore(db); let now = 1_000_000; const clock = { now: () => now, advance: (ms: number) => (now += ms) }; const games = { mafia: GAMES.mafia! }; const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now }; const config: RoomConfig = { gameId: 'mafia', options: { ...games.mafia.defaultOptions, firstPhase: 'night' }, maxPlayers: players.length, visibility: 'private', allowSpectators: true, chatEnabled: true, chatFilter: true, graceSec: 60, }; const room = new Room('room1', '482913', config, players[0]!, deps); store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'mafia', status: 'lobby', visibility: 'private', config, stateJson: '{}', now }); const conns: Record = {}; for (const p of players) { const msgs: ServerMessage[] = []; conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} }; room.join(conns[p], 'player'); } const state = () => room.game!.state as MafiaState; const lastView = (p: string) => (conns[p]!.msgs.filter((m) => m.t === 'state').at(-1) as Extract).view as MafiaView; return { room, clock, conns, state, lastView }; } describe('마피아 room flow', () => { test('게임 중 방 채팅·반응은 닫히고, 게임 채팅은 연결마다 채널 권한대로만 간다', () => { const { room, clock, conns, state, lastView } = harness(['A', 'B', 'C', 'D']); expect(room.chat('A', '대기실 인사')).toBeNull(); expect(room.start('A')).toBeNull(); expect(room.chat('A', '몰래 말하기')).toBe('게임 중에는 게임 화면의 채팅을 써 주세요.'); // Role reveal → night on the server clock. clock.advance(8_000); room.onTimer(); expect(state().phase.kind).toBe('night'); const mafia = state().players.find((p) => p.role === 'mafia')!.id; const town = state().players.filter((p) => p.role !== 'mafia').map((p) => p.id); room.act(mafia, 1, { type: 'chat', channel: 'mafia', text: 'ROOM-MAFIA-SECRET' }); expect(lastView(mafia).chat.map((c) => c.text)).toContain('ROOM-MAFIA-SECRET'); for (const t of town) { const all = JSON.stringify(conns[t]!.msgs); expect(all).not.toContain('ROOM-MAFIA-SECRET'); expect(all).not.toContain(`"role":"mafia"`); } // Rate limit (validate gets the server clock), and the system-only phase timer cannot be sent by clients. room.act(mafia, 2, { type: 'chat', channel: 'mafia', text: '또' }); expect(conns[mafia]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 2, reason: '조금 천천히 보내 주세요.' }); room.act(town[0]!, 3, { type: 'phaseTimeout' }); expect(conns[town[0]!]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 3, reason: '잘못된 요청이에요.' }); expect(state().phase.kind).toBe('night'); clock.advance(30_000); room.onTimer(); expect(state().phase.kind).toBe('discussion'); expect(state().day).toBe(2); }); });