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

@@ -136,6 +136,8 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
const g = { nameKo: gameName(room.gameId) };
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
// Games with their own chat channels (마피아) close the room chat while playing (the server refuses it too).
const roomChatClosed = !!ui.ownChat && (room.status === 'playing' || room.status === 'paused');
const isHost = room.hostId === me.id;
const doLeave = () => {
socket.leave();
@@ -157,9 +159,11 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
</strong>
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
<span style={{ flex: 1 }} />
<span className="phone-only">
<ChatButton onOpen={() => setChatOpen(true)} />
</span>
{!roomChatClosed && (
<span className="phone-only">
<ChatButton onOpen={() => setChatOpen(true)} />
</span>
)}
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
📖 규칙
</button>
@@ -183,7 +187,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
)}
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
<EmoteBubbles room={room} />
{chatOpen && (
{chatOpen && !roomChatClosed && (
<Sheet onClose={() => setChatOpen(false)} label="채팅">
<ChatPanel room={room} me={me} />
<button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}>
@@ -549,7 +553,7 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui
<aside className="game-side" aria-label="게임 정보">
{wide && status}
<ui.Side {...props} />
{wide && <ChatPanel room={room} me={me} title={false} />}
{wide && !(ui.ownChat && (room.status === 'playing' || room.status === 'paused')) && <ChatPanel room={room} me={me} title={false} />}
</aside>
</div>
);