M8: 마피아 규칙·화면·테스트

- 규칙 모듈 packages/games/src/mafia: 4~12명, 자동/수동 마피아 수, 추가 직업(군인·정치인·영매·연인),
  서버 타이머가 phaseTimeout(시스템 전용)으로 페이즈 진행, 인사 낮/토론(±15초, 토론 끝내기)/지목 투표/
  최후의 반론/찬반 투표/밤(마피아 공용 대상, 경찰·영매 결과는 밤 마감 시 전달, 의사·군인 순서),
  채널(전체/마피아/죽은 사람/연인) 권한을 view에서 필터, 레이트 리밋·고운 말 필터, 자리 비움(2일) 탈락,
  나가기 즉시 탈락, 승리 판정·결과(MVP 포함)
- 테스트: 문서 §10 항목 전부, 시드 1,000판 무작위 대국(불변식·누출·onTimeout 합법·결정성),
  엔진 randomPlayout, 기록 재생, 실제 Room 통과 테스트(방 채팅 닫힘, 연결별 채팅 필터)
- 화면 apps/web/src/games/mafia: 낮/밤 배너와 큰 남은 시간, 채널 탭 채팅, 꾹 눌러 보는 내 직업,
  그리드에서 투표·밤 대상 고르기, 아침 발표·득표 막대, 최종 공개
- 공용 코드(하위 호환): GameDefinition.ownChat, validate의 선택 ctx {now}; Room이 ownChat 게임 진행 중
  방 채팅·반응을 거절하고 validate에 서버 시각 전달; 웹 GameUI.ownChat이면 진행 중 방 채팅 패널 숨김
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 방장 + 휴대폰 3명)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 00:21:32 +09:00
parent bc199de10b
commit c682725968
14 changed files with 3231 additions and 9 deletions

View File

@@ -0,0 +1,70 @@
/** 마피아 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<string, Conn & { msgs: ServerMessage[] }> = {};
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<ServerMessage, { t: 'state' }>).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);
});
});

View File

@@ -536,6 +536,7 @@ export class Room {
chat(userId: string, text: string): string | null {
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
if (this.gameChatOnly()) return '게임 중에는 게임 화면의 채팅을 써 주세요.';
const now = this.now();
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
this.lastChatAt.set(userId, now);
@@ -553,8 +554,13 @@ export class Room {
return null;
}
/** Games with their own chat channels (마피아) close the room chat while playing, so nothing leaks around them. */
private gameChatOnly(): boolean {
return (this.status === 'playing' || this.status === 'paused') && !!this.def.ownChat;
}
emote(userId: string, id: string): void {
if (!this.isParticipant(userId)) return;
if (!this.isParticipant(userId) || this.gameChatOnly()) return;
const now = this.now();
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
this.lastChatAt.set(`e:${userId}`, now);
@@ -671,7 +677,7 @@ export class Room {
let next: { state: unknown; events: unknown[] };
const rng = new SeededRng(g.rng);
try {
const v = def.validate(g.state, userId, action);
const v = def.validate(g.state, userId, action, { now: this.now() });
if (!v.ok) return v.reason;
next = def.apply(g.state, userId, action, { rng, now: this.now() });
} catch (err) {