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

@@ -31,6 +31,11 @@ export interface SetupContext<O> {
gameNo?: number;
}
/** Optional server context for `validate` (time-based checks such as chat rate limits). Absent in pure tests. */
export interface ValidateContext {
now: number;
}
export interface ApplyContext {
rng: Rng;
now: number;
@@ -47,10 +52,12 @@ export interface GameDefinition<S, A, V, O> {
optionsSchema: ZodType<O>;
actionSchema: ZodType<A>;
playersFor?(options: O): { min: number; max: number };
/** The game runs its own (filtered) chat channels: the room chat and quick reactions are closed while it is played. */
ownChat?: boolean;
/** Extra player-count rule `playersFor` cannot express (e.g. even counts only); Korean reason or null. */
playerCountError?(options: O, count: number): string | null;
setup(ctx: SetupContext<O>): S;
validate(state: S, actor: PlayerId, action: A): Validation;
validate(state: S, actor: PlayerId, action: A, ctx?: ValidateContext): Validation;
apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] };
view(state: S, viewer: PlayerId | null): V;
activePlayers(state: S): PlayerId[];