Files
joke-app/apps/web/src/games/mafia/index.tsx
EJClaw c682725968 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>
2026-10-06 00:21:32 +09:00

1031 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 마피아 화면 (docs/games/mafia.md §9).
*
* The board carries its own chat with channel tabs (전체 / 마피아 / 죽은 사람 / 연인). The server view
* only ever contains the channels this viewer may read, so the tabs simply mirror `view.channels`.
* While the game runs the room chat panel and quick reactions are closed (`ownChat`), so night talk
* cannot leak through the room chat; after the game the room chat opens again.
*/
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import type { Channel, ChatView, MafiaOptions, MafiaView, PlayerView, PrivateResult, PublicEvent, Role, Team } from '@bg/games/mafia';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
// ---------------------------------------------------------------- names (original names only, docs §12)
const ROLE_INFO: Record<Role, { name: string; icon: string; goal: string; color: string }> = {
mafia: { name: '마피아', icon: '🕶️', goal: '밤마다 시민 1명을 골라 탈락시켜요. 마피아 수가 시민 수와 같아지면 이겨요.', color: '#991b1b' },
police: { name: '경찰', icon: '🔎', goal: '밤마다 1명을 조사해 마피아인지 알아내요. 마피아를 모두 찾으면 시민 팀이 이겨요.', color: '#1d4ed8' },
doctor: { name: '의사', icon: '🩺', goal: '밤마다 1명을 지켜요. 마피아가 노린 사람이면 살아나요.', color: '#047857' },
citizen: { name: '시민', icon: '🙂', goal: '토론과 투표로 숨은 마피아를 찾아요.', color: '#4b5563' },
soldier: { name: '군인', icon: '🪖', goal: '마피아의 공격을 한 번 버텨요. 토론과 투표로 마피아를 찾아요.', color: '#4d7c0f' },
politician: { name: '정치인', icon: '🎤', goal: '내 표는 2표예요. 투표로는 탈락하지 않아요.', color: '#7c3aed' },
medium: { name: '영매', icon: '🔮', goal: '밤에 죽은 사람과 이야기하고, 탈락한 사람 1명의 직업을 알 수 있어요.', color: '#6d28d9' },
lover: { name: '연인', icon: '💞', goal: '연인 2명은 서로를 알아요. 밤에 둘만 이야기할 수 있어요.', color: '#be185d' },
};
const ROLE_ORDER: Role[] = ['mafia', 'police', 'doctor', 'soldier', 'politician', 'medium', 'lover', 'citizen'];
const TEAM_KO: Record<Team, string> = { town: '시민 팀', mafia: '마피아 팀' };
const CHANNEL_KO: Record<Channel, string> = { day: '전체', mafia: '마피아', dead: '죽은 사람', lovers: '연인' };
const CHANNEL_ORDER: Channel[] = ['day', 'mafia', 'dead', 'lovers'];
const PHASE_KO: Record<MafiaView['phase']['kind'], string> = {
roleReveal: '직업 확인',
greeting: '인사 시간',
discussion: '낮 토론',
vote: '지목 투표',
defense: '최후의 반론',
confirm: '찬반 투표',
night: '밤',
ended: '게임 끝',
};
const PHASE_EVENT: Record<Exclude<MafiaView['phase']['kind'], 'ended'>, string> = {
roleReveal: '직업 확인 시간이에요',
greeting: '인사 시간이에요',
discussion: '낮 토론 시간이에요',
vote: '지목 투표 시간이에요',
defense: '최후의 반론 시간이에요',
confirm: '찬반 투표 시간이에요',
night: '밤이 됐어요',
};
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const isNight = (v: MafiaView) => v.phase.kind === 'night' || v.phase.kind === 'roleReveal';
// ---------------------------------------------------------------- styles
const CSS = `
.mf { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
.mf-banner { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; border-radius: 16px; padding: 10px 14px; box-shadow: var(--shadow);
background: linear-gradient(135deg, #fff7d6, #fde68a); color: #422006; border: 2px solid #f59e0b; transition: background .6s ease, color .6s ease; }
.mf-banner[data-time='night'] { background: linear-gradient(135deg, #111827, #312e81); color: #eef2ff; border-color: #4338ca; }
.mf-banner[data-time='end'] { background: linear-gradient(135deg, #ecfdf5, #bbf7d0); color: #052e16; border-color: #16a34a; }
.mf-ph { display: flex; flex-direction: column; gap: 0; min-width: 0; flex: 1 1 200px; }
.mf-ph .d { font-size: .85rem; font-weight: 700; opacity: .85; }
.mf-ph strong { font-size: 1.45rem; line-height: 1.2; }
.mf-ph .s { font-size: .9rem; font-weight: 600; }
.mf-clock { font-size: 2.4rem; font-weight: 900; font-variant-numeric: tabular-nums; line-height: 1; min-width: 3.2ch; text-align: center; }
.mf-clock[data-warn='true'] { color: #dc2626; }
.mf-banner[data-time='night'] .mf-clock[data-warn='true'] { color: #fca5a5; }
.mf-rolecard { min-height: 52px; min-width: 128px; border-radius: 12px; border: 2px dashed currentColor; background: rgba(255,255,255,.18); color: inherit;
font: inherit; font-weight: 800; padding: 4px 12px; cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
.mf-rolecard[data-show='true'] { color: #fff; border-style: solid; }
.mf-rolecard:focus-visible, .mf-tile:focus-visible, .mf-tab:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.mf-rolehint { font-size: .82rem; max-width: 300px; font-weight: 600; }
.mf-news { border-radius: 16px; padding: 10px 14px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); border: 3px solid var(--focus);
display: flex; flex-direction: column; gap: 6px; animation: mf-pop .35s ease-out; }
.mf-news h2 { margin: 0; font-size: 1.05rem; }
.mf-news .big { font-size: 1.15rem; font-weight: 800; }
.mf-bars { display: grid; gap: 4px; }
.mf-bar { display: grid; grid-template-columns: minmax(70px, 140px) 1fr 2.2em; gap: 6px; align-items: center; font-size: .88rem; font-weight: 700; }
.mf-bar .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mf-bar .tr { height: 14px; border-radius: 7px; background: var(--surface-2); overflow: hidden; }
.mf-bar .tr i { display: block; height: 100%; background: #f97316; border-radius: 7px; animation: mf-grow .7s ease-out; transform-origin: left; }
.mf-bar[data-top='true'] .tr i { background: #dc2626; }
.mf-grid { flex: 1; min-height: 0; display: grid; gap: 10px; grid-template-columns: 236px minmax(0, 1fr) 280px; grid-template-areas: 'players chat actions'; }
.mf-col { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 8px; }
.mf-players { grid-area: players; }
.mf-chatwrap { grid-area: chat; }
.mf-actions { grid-area: actions; }
.mf-box { background: var(--surface); color: var(--text); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
.mf-box h2 { margin: 0; font-size: 1rem; }
.mf-tiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
.mf-tile { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 6px 4px; border-radius: 12px; min-height: 76px;
background: var(--surface-2); color: var(--text); border: 3px solid transparent; font: inherit; text-align: center; }
button.mf-tile { cursor: pointer; }
.mf-tile .nm { font-weight: 800; font-size: .86rem; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mf-tile.me { border-color: var(--primary); }
.mf-tile.dead { filter: grayscale(1); opacity: .6; }
.mf-tile.accused { border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,.35); }
.mf-tile[data-target='ok'] { border-color: var(--success); box-shadow: 0 0 0 3px rgba(21,128,61,.35); }
.mf-tile[data-target='ok'][aria-pressed='true'] { background: #15803d; color: #fff; border-color: #15803d; box-shadow: 0 0 0 4px rgba(21,128,61,.55); }
.mf-tile[data-target='no'] { opacity: .5; }
.mf-tag { display: inline-block; border-radius: 999px; padding: 0 7px; font-size: .72rem; font-weight: 800; line-height: 1.6; color: #fff; background: #4b5563; white-space: nowrap; }
.mf-tag.unk { background: transparent; color: var(--text-sub); border: 1px dashed var(--border); }
.mf-tag.mark { background: #dc2626; }
.mf-chat { flex: 1; min-height: 0; padding: 0; overflow: hidden; gap: 0; }
.mf-tabs { display: flex; gap: 4px; padding: 8px 8px 0; flex-wrap: wrap; border-bottom: 1px solid var(--border); }
.mf-tab { position: relative; min-height: 44px; padding: 6px 12px; border: 0; border-radius: 10px 10px 0 0; background: transparent; color: var(--text-sub); font: inherit; font-weight: 700; cursor: pointer; }
.mf-tab[aria-selected='true'] { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 -3px 0 var(--primary); }
.mf-tab[data-ch='mafia'][aria-selected='true'] { background: #fee2e2; color: #7f1d1d; box-shadow: inset 0 -3px 0 #dc2626; }
.mf-tab[data-ch='dead'][aria-selected='true'] { background: #e5e7eb; color: #111827; box-shadow: inset 0 -3px 0 #4b5563; }
.mf-tab[data-ch='lovers'][aria-selected='true'] { background: #fce7f3; color: #831843; box-shadow: inset 0 -3px 0 #db2777; }
.mf-tab .n { display: inline-block; margin-left: 4px; min-width: 1.4em; border-radius: 999px; background: #dc2626; color: #fff; font-size: .72rem; padding: 0 5px; }
.mf-msgs { flex: 1; min-height: 160px; overflow-y: auto; padding: 8px 10px; display: flex; flex-direction: column; gap: 5px; }
.mf-msg { line-height: 1.4; overflow-wrap: anywhere; }
.mf-msg strong { margin-right: 6px; }
.mf-msg.mine strong { color: var(--primary); }
.mf-msg.sys { font-size: .86rem; font-weight: 700; color: var(--text-sub); background: var(--surface-2); border-radius: 8px; padding: 3px 8px; }
.mf-msg.sys.hot { color: var(--danger); }
.mf-form { display: flex; gap: 6px; padding: 8px; border-top: 1px solid var(--border); flex-wrap: nowrap; }
.mf-form .input { flex: 1; min-height: 44px; min-width: 0; }
.mf-why { padding: 8px 10px; border-top: 1px solid var(--border); font-size: .88rem; color: var(--text-sub); font-weight: 600; }
.mf-big { display: flex; flex-wrap: wrap; gap: 8px; }
.mf-big .btn { flex: 1 1 auto; min-height: 52px; }
.mf-prompt { margin: 0; font-weight: 800; font-size: 1.02rem; }
.mf-results { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; font-size: .9rem; }
.mf-results li { background: var(--surface-2); border-radius: 8px; padding: 4px 8px; font-weight: 700; }
.mf-final { border: 3px solid #16a34a; }
.mf-final table { border-collapse: collapse; width: 100%; font-size: .9rem; }
.mf-final td, .mf-final th { padding: 4px 6px; text-align: left; border-bottom: 1px solid var(--border); }
.mf-log { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 3px; max-height: 320px; overflow: auto; }
.mf-rules td, .mf-rules th { padding: 4px 6px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
@keyframes mf-pop { from { transform: scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes mf-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@media (max-width: 1099px) {
.mf { height: auto; }
.mf-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-areas: 'actions players' 'chat chat'; }
.mf-col { overflow: visible; }
.mf-msgs { max-height: 420px; }
}
@media (max-width: 719px) {
.mf-grid { grid-template-columns: minmax(0, 1fr); grid-template-areas: 'actions' 'players' 'chat'; }
.mf-banner { padding: 8px 10px; gap: 8px; }
.mf-ph strong { font-size: 1.2rem; }
.mf-clock { font-size: 2rem; }
.mf-rolecard { min-width: 104px; min-height: 48px; font-size: .9rem; }
.mf-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.mf-tile { min-height: 70px; }
.mf-msgs { min-height: 200px; max-height: 340px; }
}
@media (prefers-reduced-motion: reduce) {
.mf-banner { transition: none; }
.mf-news, .mf-bar .tr i { animation: none; }
}
`;
// ---------------------------------------------------------------- small parts
function Clock({ endsAt }: { endsAt: number | null }) {
const now = useServerNow(500);
if (endsAt === null) return null;
const sec = Math.max(0, Math.ceil((endsAt - now) / 1000));
const text = sec >= 60 ? `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}` : `${sec}`;
return (
<span className="mf-clock" data-warn={sec <= 5} role="timer" aria-label={`남은 시간 ${sec}초`}>
{text}
</span>
);
}
function useHold(): [boolean, Record<string, unknown>] {
const [show, setShow] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []);
return [
show,
{
onPointerDown: () => setShow(true),
onPointerUp: () => setShow(false),
onPointerLeave: () => setShow(false),
onPointerCancel: () => setShow(false),
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
onKeyDown: (e: KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
setShow(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setShow(false), 2500);
},
},
];
}
/** "내 직업" — only visible while held (screen sharing / people next to you, docs §9). */
function RoleCard({ role, mates, players, always }: { role: Role; mates: string[]; players: Record<string, PublicUser>; always: boolean }) {
const [held, handlers] = useHold();
const show = held || always;
const r = ROLE_INFO[role];
const mateText = mates.length ? ` ${role === 'mafia' ? '동료 마피아' : '내 연인'}: ${mates.map((m) => `${nick(players, m)}님`).join(', ')}.` : '';
return (
<span className="stack" style={{ gap: 2, alignItems: 'flex-end' }}>
<button
type="button"
className="mf-rolecard"
data-show={show}
style={show ? { background: r.color, borderColor: r.color } : undefined}
aria-label={show ? `내 직업: ${r.name}. ${r.goal}${mateText}` : '내 직업 보기. 누르고 있는 동안만 보여요'}
{...handlers}
>
{show ? `${r.icon} ${r.name}` : '🂠 내 직업 (꾹 누르기)'}
</button>
{show && (
<span className="mf-rolehint">
{r.goal}
{mateText}
</span>
)}
</span>
);
}
function RoleTag({ p, meId }: { p: PlayerView; meId: string | null }) {
// My own role stays on the hold-to-see card while I am alive (docs §9).
if (p.role && !(p.id === meId && p.alive)) {
const r = ROLE_INFO[p.role];
return (
<span className="mf-tag" style={{ background: r.color }}>
{r.icon} {r.name}
</span>
);
}
if (p.team && !p.alive) return <span className="mf-tag" style={{ background: p.team === 'mafia' ? '#991b1b' : '#1d4ed8' }}>{p.team === 'mafia' ? '마피아였어요' : '시민 팀'}</span>;
return null;
}
function eventText(e: PublicEvent, players: Record<string, PublicUser>): string {
const n = (id: string) => `${nick(players, id)}님`;
switch (e.t) {
case 'phase':
return `${e.day}일차 ${PHASE_EVENT[e.kind]}`;
case 'adjust':
return `${n(e.p)}이 토론 시간을 15초 ${e.delta > 0 ? '늘렸어요' : '줄였어요'}`;
case 'vote':
if (e.accused) return `지목 투표: ${n(e.accused)}이 ${e.tally[e.accused]}표로 지목됐어요`;
return e.tie ? '지목 투표가 동률이라 아무도 지목되지 않았어요' : '모두 기권해서 아무도 지목되지 않았어요';
case 'confirm': {
const head = `찬성 ${e.yes} · 반대 ${e.no} — `;
if (e.outcome === 'executed') return `${head}${n(e.accused)}이 투표로 탈락했어요`;
if (e.outcome === 'politician') return `${head}${n(e.accused)}은 정치인이라 탈락하지 않아요`;
return `${head}${n(e.accused)}이 살아남았어요`;
}
case 'death': {
const reveal = e.role ? ` (직업: ${ROLE_INFO[e.role].name})` : e.team ? ` (${e.team === 'mafia' ? '마피아였어요' : '마피아가 아니었어요'})` : '';
if (e.by === 'mafia') return `밤사이 ${n(e.p)}이 마피아에게 당해 탈락했어요${reveal}`;
if (e.by === 'disconnect') return `${n(e.p)}이 자리를 비워 탈락했어요${reveal}`;
return `${n(e.p)}이 탈락했어요${reveal}`;
}
case 'healed':
return '의사의 치료로 누군가 살아났어요';
case 'soldier':
return `군인 ${n(e.p)}이 마피아의 공격을 버텼어요`;
case 'quiet':
return '조용한 밤이었어요. 아무도 탈락하지 않았어요';
case 'end':
return `${TEAM_KO[e.winner]} 승리!`;
}
}
/** Announcements since the previous phase change (morning news, vote result, execution). */
function latestNews(events: PublicEvent[]): PublicEvent[] {
let i = events.length - 1;
while (i >= 0 && (events[i]!.t === 'phase' || events[i]!.t === 'adjust')) i--;
const out: PublicEvent[] = [];
for (; i >= 0 && events[i]!.t !== 'phase'; i--) if (events[i]!.t !== 'adjust') out.unshift(events[i]!);
return out;
}
function News({ v, players }: { v: MafiaView; players: Record<string, PublicUser> }) {
const news = latestNews(v.events);
if (!news.length || v.final) return null;
const night = news.some((e) => e.t === 'healed' || e.t === 'quiet' || e.t === 'soldier' || (e.t === 'death' && e.by === 'mafia'));
const title = night ? '아침 발표' : news.some((e) => e.t === 'confirm') ? '찬반 투표 결과' : news.some((e) => e.t === 'vote') ? '지목 투표 결과' : '알림';
return (
<section className="mf-news" key={news[0]!.n} aria-label={title} role="status">
<h2>📢 {title}</h2>
{news.map((e) =>
e.t === 'vote' ? (
<VoteBars key={e.n} e={e} players={players} />
) : (
<span key={e.n} className="big">
{eventText(e, players)}
</span>
),
)}
</section>
);
}
function VoteBars({ e, players }: { e: Extract<PublicEvent, { t: 'vote' }>; players: Record<string, PublicUser> }) {
const rows = Object.entries(e.tally).sort((a, b) => b[1] - a[1]);
const max = Math.max(1, ...rows.map((r) => r[1]));
return (
<>
<span className="big">{eventText(e, players)}</span>
{rows.length > 0 && (
<div className="mf-bars" role="list" aria-label="득표">
{rows.map(([id, n]) => (
<div key={id} className="mf-bar" data-top={id === e.accused} role="listitem" aria-label={`${nick(players, id)} ${n}표`}>
<span className="nm">{nick(players, id)}</span>
<span className="tr" aria-hidden>
<i style={{ width: `${(n / max) * 100}%` }} />
</span>
<span>{n}표</span>
</div>
))}
</div>
)}
{e.abstain > 0 && <span className="small">기권 {e.abstain}표</span>}
{e.votes && (
<span className="small muted">
{Object.entries(e.votes)
.map(([from, to]) => `${nick(players, from)} → ${to === 'abstain' ? '기권' : nick(players, to)}`)
.join(' · ')}
</span>
)}
</>
);
}
// ---------------------------------------------------------------- players
type Pick = { ids: string[]; chosen: string | null; onPick: (id: string) => void } | null;
function Players({ v, players, me, pick, pending }: { v: MafiaView; players: Record<string, PublicUser>; me: string | null; pick: Pick; pending: boolean }) {
const mafiaChoice = v.my?.night?.action === 'mafiaTarget' ? v.my.night.choice : null;
return (
<section className="mf-box" aria-label={`사람들. 살아 있는 사람 ${v.aliveCount}명`}>
<h2>
사람들 <span className="small muted">살아 있는 사람 {v.aliveCount}명</span>
</h2>
<div className="mf-tiles">
{v.players.map((p) => {
const u = userOf(players, p.id);
const cls = `mf-tile${p.id === me ? ' me' : ''}${!p.alive ? ' dead' : ''}${p.id === v.phase.accused ? ' accused' : ''}`;
const can = pick?.ids.includes(p.id) ?? false;
const body = (
<>
<Avatar user={u} size={30} />
<span className="nm">
{u.nickname}
{p.id === me ? ' (나)' : ''}
</span>
<RoleTag p={p} meId={me} />
{!p.alive && <span className="mf-tag">탈락 · {p.diedDay}일차</span>}
{p.id === v.phase.accused && <span className="mf-tag mark">지목됨</span>}
{mafiaChoice === p.id && !pick && <span className="mf-tag mark">🎯 오늘 밤 대상</span>}
</>
);
const label = [
`${u.nickname}${p.id === me ? '(나)' : ''}`,
p.alive ? '살아 있음' : `${p.diedDay}일차에 탈락`,
p.role && p.id !== me ? ROLE_INFO[p.role].name : '',
p.id === v.phase.accused ? '지목됨' : '',
]
.filter(Boolean)
.join(', ');
if (pick && can) {
const chosen = pick.chosen === p.id;
return (
<button key={p.id} type="button" className={cls} data-target="ok" data-player={p.id} aria-pressed={chosen} disabled={pending} aria-label={`${label}. ${chosen ? '고른 사람' : '고르기'}`} onClick={() => pick.onPick(p.id)}>
{body}
</button>
);
}
return (
<div key={p.id} className={cls} data-target={pick ? 'no' : undefined} data-player={p.id} role="group" aria-label={label}>
{body}
</div>
);
})}
</div>
</section>
);
}
// ---------------------------------------------------------------- chat
type Line = ({ k: 'chat'; m: ChatView } | { k: 'sys'; e: PublicEvent }) & { at: number };
function ChatBox({ v, players, me, act }: { v: MafiaView; players: Record<string, PublicUser>; me: string | null; act: (a: unknown) => void }) {
const tabs = useMemo(() => {
const set = new Set<Channel>(['day', ...v.channels.map((c) => c.channel), ...v.chat.map((m) => m.channel)]);
return CHANNEL_ORDER.filter((c) => set.has(c));
}, [v.channels, v.chat]);
const [tab, setTab] = useState<Channel>('day');
const [text, setText] = useState('');
const [seen, setSeen] = useState<Record<string, number>>({});
const listRef = useRef<HTMLDivElement>(null);
const cur: Channel = tabs.includes(tab) ? tab : 'day';
// Day → 전체; night → the first private channel I may write in (only on my own screen).
const phaseKey = `${v.day}-${v.phase.kind}`;
useEffect(() => {
if (v.phase.kind === 'night') {
const priv = v.channels.find((c) => c.channel !== 'day' && c.canWrite);
if (priv) setTab(priv.channel);
} else if (v.phase.kind !== 'ended') setTab('day');
}, [phaseKey]); // eslint-disable-line react-hooks/exhaustive-deps
const count = (c: Channel) => v.chat.filter((m) => m.channel === c).length;
const lines: Line[] = useMemo(() => {
const msgs: Line[] = v.chat.filter((m) => m.channel === cur).map((m) => ({ k: 'chat' as const, m, at: m.at }));
if (cur !== 'day') return msgs;
// Public announcements (the system channel) are shown inside 전체.
const sys: Line[] = v.events.map((e) => ({ k: 'sys' as const, e, at: e.at }));
return [...sys, ...msgs].sort((a, b) => a.at - b.at || (a.k === 'sys' ? -1 : 1));
}, [v.chat, v.events, cur]);
useEffect(() => {
setSeen((s) => ({ ...s, [cur]: count(cur) }));
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [cur, v.chat.length, v.events.length]); // eslint-disable-line react-hooks/exhaustive-deps
const ch = v.channels.find((c) => c.channel === cur);
const canWrite = !!ch?.canWrite;
const why = ch ? ch.why : me ? '이 채팅은 읽기만 할 수 있어요.' : '관전 중에는 채팅을 쓸 수 없어요.';
const onTabKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const i = tabs.indexOf(cur);
const next = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length]!;
setTab(next);
e.currentTarget.querySelector<HTMLButtonElement>(`[data-ch='${next}']`)?.focus();
};
return (
<section className="mf-box mf-chat" aria-label="게임 채팅">
<div className="mf-tabs" role="tablist" aria-label="채팅방" onKeyDown={onTabKey}>
{tabs.map((c) => {
const unread = c === cur ? 0 : Math.max(0, count(c) - (seen[c] ?? 0));
return (
<button
key={c}
type="button"
role="tab"
className="mf-tab"
data-ch={c}
aria-selected={c === cur}
tabIndex={c === cur ? 0 : -1}
aria-label={`${CHANNEL_KO[c]} 채팅${unread ? `, 새 메시지 ${unread}개` : ''}`}
onClick={() => setTab(c)}
>
{CHANNEL_KO[c]}
{unread > 0 && <span className="n">{unread}</span>}
</button>
);
})}
</div>
<div ref={listRef} className="mf-msgs" role="tabpanel" aria-label={`${CHANNEL_KO[cur]} 채팅 내용`} aria-live="polite" data-channel={cur}>
{lines.length === 0 && <p className="muted small" style={{ margin: 0 }}>아직 메시지가 없어요.</p>}
{lines.map((l) =>
l.k === 'sys' ? (
<div key={`e${l.e.n}`} className={`mf-msg sys${l.e.t === 'death' || l.e.t === 'end' ? ' hot' : ''}`}>
📢 {eventText(l.e, players)}
</div>
) : (
<div key={l.m.key} className={`mf-msg${l.m.from === me ? ' mine' : ''}`}>
<strong>{nick(players, l.m.from)}</strong>
{l.m.text}
</div>
),
)}
</div>
{canWrite ? (
<form
className="mf-form"
onSubmit={(e) => {
e.preventDefault();
const t = text.trim();
if (!t) return;
act({ type: 'chat', channel: cur, text: t.slice(0, 200) });
setText('');
}}
>
<input
className="input"
value={text}
onChange={(e) => setText(e.target.value)}
maxLength={200}
placeholder={`${CHANNEL_KO[cur]} 채팅에 쓰기`}
aria-label={`${CHANNEL_KO[cur]} 채팅 메시지`}
enterKeyHint="send"
/>
<button className="btn btn-primary btn-sm">보내기</button>
</form>
) : (
<p className="mf-why">{why}</p>
)}
</section>
);
}
// ---------------------------------------------------------------- actions
function resultText(r: PrivateResult, players: Record<string, PublicUser>): string {
const who = `${nick(players, r.target)}님`;
if (r.kind === 'peek') return `${r.day}일차 밤 · ${who}의 직업은 ${ROLE_INFO[r.result as Role].name}이에요`;
if (r.result === 'notMafia') return `${r.day}일차 밤 조사 · ${who}은 마피아가 아니에요`;
if (r.result === 'mafia') return `${r.day}일차 밤 조사 · ${who}은 마피아예요!`;
return `${r.day}일차 밤 조사 · ${who}의 직업은 ${ROLE_INFO[r.result].name}이에요`;
}
function Actions({ v, players, me, act, pending }: { v: MafiaView; players: Record<string, PublicUser>; me: string | null; act: (a: unknown) => void; pending: boolean }) {
const my = v.my;
const ph = v.phase;
const n = (id: string) => `${nick(players, id)}님`;
let body: ReactNode = null;
if (!my) {
body = <p className="mf-prompt">관전 중이에요. 낮 채팅과 발표만 볼 수 있어요.</p>;
} else if (ph.kind === 'ended') {
body = <p className="mf-prompt">게임이 끝났어요. 모든 직업이 공개됐어요.</p>;
} else if (!my.alive) {
body = (
<>
<p className="mf-prompt">탈락했어요.</p>
<span className="small">죽은 사람 채팅에서만 이야기할 수 있어요. 살아 있는 사람에게 정보를 알려 주지 말아 주세요.</span>
</>
);
} else if (ph.kind === 'roleReveal') {
body = (
<>
<p className="mf-prompt">위의 "내 직업" 카드를 꾹 눌러 직업을 확인해 주세요.</p>
<span className="small">곧 {v.options.firstPhase === 'day' ? '인사 시간' : '첫 밤'}이 시작돼요.</span>
</>
);
} else if (ph.kind === 'greeting' || ph.kind === 'discussion') {
body = (
<>
<p className="mf-prompt">{ph.kind === 'greeting' ? '인사 시간이에요. 오늘은 투표가 없어요.' : '토론 시간이에요. 마피아가 누구일까요?'}</p>
{ph.kind === 'discussion' && v.options.timeAdjust && (
<div className="mf-big" role="group" aria-label="토론 시간 바꾸기">
<button type="button" className="btn btn-secondary" disabled={pending || !my.canAdjust} onClick={() => act({ type: 'adjustTime', delta: 15 })} aria-label="토론 시간 15초 늘리기">
+15초
</button>
<button type="button" className="btn btn-secondary" disabled={pending || !my.canAdjust} onClick={() => act({ type: 'adjustTime', delta: -15 })} aria-label="토론 시간 15초 줄이기">
−15초
</button>
</div>
)}
{ph.kind === 'discussion' && !my.canAdjust && v.options.timeAdjust && <span className="small muted">시간 바꾸기는 하루에 한 번만 할 수 있어요.</span>}
<div className="mf-big">
<button type="button" className="btn btn-primary" disabled={pending || !my.canReady} onClick={() => act({ type: 'readyEarly' })}>
{my.ready ? `기다리는 중 (${ph.readyCount}/${ph.readyNeeded})` : `${ph.kind === 'greeting' ? '인사 끝내기' : '토론 끝내기'} (${ph.readyCount}/${ph.readyNeeded})`}
</button>
</div>
<span className="small muted">살아 있는 사람이 모두 누르면 바로 다음으로 넘어가요.</span>
</>
);
} else if (ph.kind === 'vote') {
body = (
<>
<p className="mf-prompt">마피아로 의심되는 사람을 골라 주세요</p>
<span className="small">초록 테두리 사람을 누르면 지목해요. 시간 안에는 바꿀 수 있어요.</span>
<strong>{my.vote === null ? '아직 고르지 않았어요 (안 고르면 기권)' : my.vote === 'abstain' ? '기권했어요' : `${n(my.vote)}을 지목했어요`}</strong>
<div className="mf-big">
<button type="button" className="btn btn-secondary" disabled={pending || my.vote === 'abstain'} onClick={() => act({ type: 'vote', target: 'abstain' })}>
기권
</button>
</div>
</>
);
} else if (ph.kind === 'defense') {
body = <p className="mf-prompt">{ph.accused === me ? '지목됐어요. 전체 채팅으로 최후의 반론을 해 주세요!' : `${n(ph.accused!)}의 최후의 반론을 들어 주세요.`}</p>;
} else if (ph.kind === 'confirm') {
body = my.canConfirm ? (
<>
<p className="mf-prompt">{n(ph.accused!)}을 탈락시킬까요?</p>
<div className="mf-big" role="group" aria-label="찬반 투표">
<button type="button" className={`btn ${my.ballot === 'yes' ? 'btn-danger' : 'btn-secondary'}`} aria-pressed={my.ballot === 'yes'} disabled={pending} onClick={() => act({ type: 'confirm', choice: 'yes' })}>
찬성 (탈락)
</button>
<button type="button" className={`btn ${my.ballot === 'no' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={my.ballot === 'no'} disabled={pending} onClick={() => act({ type: 'confirm', choice: 'no' })}>
반대 (살리기)
</button>
</div>
<span className="small muted">{my.ballot ? `${my.ballot === 'yes' ? '찬성' : '반대'}했어요. 시간 안에는 바꿀 수 있어요.` : '고르지 않으면 집계에서 빠져요.'}</span>
</>
) : (
<p className="mf-prompt">다른 사람들이 찬반 투표를 하고 있어요.</p>
);
} else if (ph.kind === 'night' && my.night) {
const nv = my.night;
body = (
<>
<p className="mf-prompt">{nv.prompt}</p>
{nv.action && nv.targets.length > 0 && (
<>
<span className="small">초록 테두리 사람을 누르면 골라요. 밤이 끝날 때 고른 사람으로 정해져요.</span>
<strong>{nv.choice ? `고른 사람: ${n(nv.choice)}${nv.pickedBy && nv.pickedBy !== me ? ` (${n(nv.pickedBy)}이 골랐어요)` : ''}` : '아직 고르지 않았어요'}</strong>
{nv.action === 'mafiaTarget' && nv.choice && (
<div className="mf-big">
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'mafiaTarget', target: null })}>
선택 취소
</button>
</div>
)}
</>
)}
{!nv.action && <span className="small muted">밤이 지나면 아침 발표가 있어요.</span>}
</>
);
}
return (
<section className="mf-box" aria-label="할 일">
<h2>{ph.kind === 'night' ? '🌙 오늘 밤' : ph.kind === 'ended' ? '🏁 결과' : '☀️ 할 일'}</h2>
{body}
{my?.idleWarning && ph.kind !== 'ended' && <strong style={{ color: 'var(--danger)' }}>오늘도 아무것도 하지 않으면 자리 비움으로 탈락해요.</strong>}
{my && my.results.length > 0 && (
<>
<strong className="small">{my.role === 'medium' ? '영매 결과 (나만 봐요)' : '조사 결과 (나만 봐요)'}</strong>
<ul className="mf-results">
{my.results.map((r) => (
<li key={r.id}>{resultText(r, players)}</li>
))}
</ul>
</>
)}
</section>
);
}
function FinalReveal({ v, players }: { v: MafiaView; players: Record<string, PublicUser> }) {
const f = v.final!;
return (
<section className="mf-box mf-final" aria-label="최종 공개">
<h2>
🏆 {TEAM_KO[f.winner]} 승리 · {f.days}일차
</h2>
<span>{f.summary}</span>
<table>
<thead>
<tr>
<th scope="col">이름</th>
<th scope="col">직업</th>
<th scope="col">결과</th>
</tr>
</thead>
<tbody>
{f.ranking.map((id) => {
const p = f.players.find((x) => x.id === id)!;
return (
<tr key={id}>
<th scope="row">
{p.won ? '🏆 ' : ''}
{nick(players, id)}
{f.mvp === id ? ' ⭐MVP' : ''}
</th>
<td>
{ROLE_INFO[p.role].icon} {ROLE_INFO[p.role].name}
</td>
<td>{p.alive ? '생존' : `${p.diedDay}일차 ${p.diedBy === 'vote' ? '투표로' : p.diedBy === 'mafia' ? '밤에' : '자리 비움으로'} 탈락`}</td>
</tr>
);
})}
</tbody>
</table>
</section>
);
}
// ---------------------------------------------------------------- board
function MafiaBoard({ view: v, me, players, act, pending }: BoardProps<MafiaView>) {
const ph = v.phase;
const my = v.my;
const [announce, setAnnounce] = useState('');
const phaseKey = `${v.day}-${ph.kind}`;
const first = useRef(true);
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
setAnnounce(`${v.day}일차 ${PHASE_KO[ph.kind]}`);
if (my?.alive && (ph.kind === 'vote' || (ph.kind === 'confirm' && my.canConfirm) || ph.kind === 'night' || ph.kind === 'discussion')) {
haptic(40);
sounds.myTurn();
}
}, [phaseKey]); // eslint-disable-line react-hooks/exhaustive-deps
let pick: Pick = null;
if (my?.alive && ph.kind === 'vote') pick = { ids: my.voteTargets, chosen: my.vote === 'abstain' ? null : my.vote, onPick: (id) => act({ type: 'vote', target: id }) };
else if (my?.alive && ph.kind === 'night' && my.night?.action && my.night.targets.length) {
const action = my.night.action;
pick = { ids: my.night.targets, chosen: my.night.choice, onPick: (id) => act({ type: action, target: id }) };
}
const time = ph.kind === 'ended' ? 'end' : isNight(v) ? 'night' : 'day';
const sub =
ph.kind === 'defense' || ph.kind === 'confirm'
? `${nick(players, ph.accused!)}님이 지목됐어요`
: ph.kind === 'discussion' && ph.adjustedBy.length
? `시간 바꾼 사람: ${ph.adjustedBy.map((id) => nick(players, id)).join(', ')}`
: ph.kind === 'ended'
? v.final?.summary
: `살아 있는 사람 ${v.aliveCount}명`;
return (
<div className="mf">
<style>{CSS}</style>
<section className="mf-banner" data-time={time} aria-label="진행 상황">
<span className="mf-ph">
<span className="d">
{time === 'night' ? '🌙' : time === 'end' ? '🏁' : '☀️'} {v.day}일차
</span>
<strong>{PHASE_KO[ph.kind]}</strong>
<span className="s">{sub}</span>
</span>
<Clock endsAt={ph.endsAt} />
{my && <RoleCard role={my.role} mates={my.mates} players={players} always={!!v.winner || !my.alive} />}
</section>
<News v={v} players={players} />
{v.final && <FinalReveal v={v} players={players} />}
<div className="mf-grid">
<div className="mf-col mf-players">
<Players v={v} players={players} me={me} pick={pick} pending={pending} />
</div>
<div className="mf-col mf-chatwrap">
<ChatBox v={v} players={players} me={me} act={act} />
</div>
<div className="mf-col mf-actions">
<Actions v={v} players={players} me={me} act={act} pending={pending} />
</div>
</div>
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function composition(setup: MafiaView['setup']): string {
return ROLE_ORDER.filter((r) => setup[r])
.map((r) => `${ROLE_INFO[r].name} ${setup[r]}`)
.join(' · ');
}
function MafiaSide({ view: v, players }: BoardProps<MafiaView>) {
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>
마피아 · {v.day}일차 {PHASE_KO[v.phase.kind]}
</strong>
<span className="small">이번 판 직업: {composition(v.setup)}</span>
<span className="small muted">{optionsSummary(v.options)}</span>
</div>
{v.events.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>발표 기록</summary>
<ol className="mf-log">
{[...v.events]
.reverse()
.filter((e) => e.t !== 'phase')
.map((e) => (
<li key={e.n} className="small">
{e.day}일차 · {eventText(e, players)}
</li>
))}
</ol>
</details>
)}
<p className="small muted" style={{ margin: 0 }}>
탈락한 사람은 음성 채팅에서도 말하지 않기로 해요. 게임 중에는 방 채팅 대신 게임 화면의 채팅을 써요.
</p>
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: MafiaView, me: string | null): boolean {
const my = v.my;
if (!me || !my || !my.alive || v.winner) return false;
switch (v.phase.kind) {
case 'vote':
return my.vote === null;
case 'defense':
return v.phase.accused === me;
case 'confirm':
return my.canConfirm && my.ballot === null;
default:
// Night is the same for every role, so the status bar never gives a role away (docs §9).
return false;
}
}
function statusText(v: MafiaView, me: string | null, players: Record<string, PublicUser>): string {
if (v.final) return v.final.summary;
const ph = v.phase;
const my = v.my;
const accused = ph.accused ? `${nick(players, ph.accused)}님` : '';
const prefix = my && !my.alive ? '탈락했어요 · ' : '';
switch (ph.kind) {
case 'roleReveal':
return '내 직업을 확인해 주세요';
case 'greeting':
return `${prefix}인사 시간이에요 · 오늘은 투표가 없어요`;
case 'discussion':
return `${prefix}${v.day}일차 낮 토론 시간이에요`;
case 'vote':
return isMyTurn(v, me) ? '내 차례예요 · 마피아로 의심되는 사람을 골라 주세요' : `${prefix}지목 투표 중이에요`;
case 'defense':
return ph.accused === me ? '내 차례예요 · 최후의 반론을 해 주세요' : `${prefix}${accused}의 최후의 반론이에요`;
case 'confirm':
return isMyTurn(v, me) ? `내 차례예요 · ${accused}을 탈락시킬지 골라 주세요` : `${prefix}${accused} 찬반 투표 중이에요`;
case 'night':
return my?.alive ? '밤이에요 · 오늘 밤 할 일을 확인해 주세요' : `${prefix}밤이에요`;
case 'ended':
return '게임이 끝났어요';
}
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
{help && <span className="small muted">{help}</span>}
</div>
);
}
const onOff: [boolean, string][] = [
[true, '켜기'],
[false, '끄기'],
];
const secs = <T extends number>(...v: T[]): [T, string][] => v.map((x) => [x, `${x}초`]);
const EXTRA_KO: Record<keyof MafiaOptions['extraRoles'], string> = { soldier: '군인', politician: '정치인', medium: '영매', lovers: '연인(2명)' };
function Options({ value: o, onChange }: OptionsProps<MafiaOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<MafiaOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="마피아 수" help="자동: 4~5명 1명, 6~8명 2명, 9명 이상 3명">
<Seg
label="마피아 수"
value={o.mafiaCount}
options={[
['auto', '자동'],
[1, '1명'],
[2, '2명'],
[3, '3명'],
[4, '4명'],
]}
onChange={(mafiaCount) => set({ mafiaCount })}
/>
</Field>
<Field label="추가 직업" help="시민 자리를 대신해요. 인원이 모자라면 시작할 때 알려 줘요.">
<div className="segmented" role="group" aria-label="추가 직업">
{(Object.keys(EXTRA_KO) as (keyof MafiaOptions['extraRoles'])[]).map((k) => (
<button key={k} type="button" aria-pressed={o.extraRoles[k]} onClick={() => set({ extraRoles: { ...o.extraRoles, [k]: !o.extraRoles[k] } })}>
{EXTRA_KO[k]}
</button>
))}
</div>
</Field>
<Field label="시작 페이즈">
<Seg
label="시작 페이즈"
value={o.firstPhase}
options={[
['day', '인사하는 낮부터'],
['night', '밤부터'],
]}
onChange={(firstPhase) => set({ firstPhase })}
/>
</Field>
<Field label="밤 시간">
<Seg label="밤 시간" value={o.nightSeconds} options={secs(15, 20, 30, 45, 60, 90)} onChange={(nightSeconds) => set({ nightSeconds })} />
</Field>
<Field label="탈락한 사람 공개">
<Seg
label="탈락한 사람 공개"
value={o.deathReveal}
options={[
['none', '공개 안 함'],
['team', '마피아인지만'],
['role', '직업까지'],
]}
onChange={(deathReveal) => set({ deathReveal })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="토론 시간(살아 있는 사람 1명당)">
<Seg label="토론 시간(1명당)" value={o.discussionPerAlive} options={secs(10, 15, 20)} onChange={(discussionPerAlive) => set({ discussionPerAlive })} />
</Field>
<Field label="토론 시간 최소">
<Seg label="토론 시간 최소" value={o.discussionMin} options={secs(30, 45, 60, 90, 120)} onChange={(discussionMin) => set({ discussionMin })} />
</Field>
<Field label="토론 시간 최대">
<Seg label="토론 시간 최대" value={o.discussionMax} options={secs(60, 120, 180, 240, 300)} onChange={(discussionMax) => set({ discussionMax })} />
</Field>
<Field label="지목 투표 시간">
<Seg label="지목 투표 시간" value={o.voteSeconds} options={secs(10, 15, 20, 30, 45, 60)} onChange={(voteSeconds) => set({ voteSeconds })} />
</Field>
<Field label="최후의 반론 시간">
<Seg label="최후의 반론 시간" value={o.defenseSeconds} options={secs(10, 15, 20, 30, 45, 60)} onChange={(defenseSeconds) => set({ defenseSeconds })} />
</Field>
<Field label="찬반 투표 시간">
<Seg label="찬반 투표 시간" value={o.confirmSeconds} options={secs(5, 10, 15, 20, 30)} onChange={(confirmSeconds) => set({ confirmSeconds })} />
</Field>
<Field label="하루 한 번 토론 시간 ±15초">
<Seg label="토론 시간 바꾸기" value={o.timeAdjust} options={onOff} onChange={(timeAdjust) => set({ timeAdjust })} />
</Field>
<Field label="지목 투표에서 누가 누구를 골랐는지 공개">
<Seg label="투표 공개" value={o.openVote} options={onOff} onChange={(openVote) => set({ openVote })} />
</Field>
<Field label="의사가 자기 자신 지키기">
<Seg label="의사 자기 치료" value={o.doctorSelfHeal} options={onOff} onChange={(doctorSelfHeal) => set({ doctorSelfHeal })} />
</Field>
<Field label="의사가 같은 사람을 이틀 연속 지키기">
<Seg label="의사 연속 치료" value={o.doctorRepeat} options={onOff} onChange={(doctorRepeat) => set({ doctorRepeat })} />
</Field>
<Field label="마피아가 아무도 안 골랐을 때">
<Seg
label="마피아 미선택"
value={o.mafiaMustKill}
options={[
['noKill', '아무도 탈락 안 함'],
['random', '무작위로 1명'],
]}
onChange={(mafiaMustKill) => set({ mafiaMustKill })}
/>
</Field>
<Field label="(하우스 룰) 경찰이 마피아 여부 대신 직업을 알아요">
<Seg label="경찰 직업 확인" value={o.policeRevealRole} options={onOff} onChange={(policeRevealRole) => set({ policeRevealRole })} />
</Field>
<Field label="탈락한 사람이 모든 직업과 마피아 채팅 보기">
<Seg label="탈락자 모두 보기" value={o.deadSeeAll} options={onOff} onChange={(deadSeeAll) => set({ deadSeeAll })} />
</Field>
<Field label="고운 말 필터(욕설·링크·전화번호 막기)">
<Seg label="고운 말 필터" value={o.kidSafeChat} options={onOff} onChange={(kidSafeChat) => set({ kidSafeChat })} />
</Field>
<Field label="게임이 끝나면 모든 채팅 공개">
<Seg label="끝나면 채팅 공개" value={o.revealChatsAtEnd} options={onOff} onChange={(revealChatsAtEnd) => set({ revealChatsAtEnd })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: MafiaOptions): string {
const extras = (Object.keys(EXTRA_KO) as (keyof MafiaOptions['extraRoles'])[]).filter((k) => o.extraRoles[k]).map((k) => EXTRA_KO[k]);
const parts = [o.mafiaCount === 'auto' ? '마피아 자동' : `마피아 ${o.mafiaCount}명`];
if (extras.length) parts.push(`추가: ${extras.join('·')}`);
parts.push(o.firstPhase === 'day' ? '낮부터' : '밤부터', `밤 ${o.nightSeconds}초`, `투표 ${o.voteSeconds}초`);
parts.push(o.deathReveal === 'none' ? '탈락자 비공개' : o.deathReveal === 'team' ? '탈락 시 마피아 여부 공개' : '탈락 시 직업 공개');
return parts.join(' · ');
}
// ---------------------------------------------------------------- rules
function Rules() {
return (
<div className="stack">
<p>
4~12명이 비밀 직업을 받아 <strong>시민 팀</strong>과 <strong>마피아 팀</strong>으로 나뉘어요. 진행은 서버가 맡아요. 시민 팀은 마피아를 모두 찾으면, 마피아 팀은 살아 있는 마피아 수가 다른 사람
수와 같아지면 이겨요.
</p>
<p>
<strong>하루 흐름</strong>: ☀️ 낮 토론 → 지목 투표(가장 많이 지목된 1명) → 최후의 반론 → 찬반 투표(찬성이 많으면 탈락) → 🌙 밤(직업별 비밀 행동) → 아침 발표. 첫날은 인사만 하고 투표 없이
밤이 돼요.
</p>
<p>
밤에는 낮 채팅이 잠겨요. 마피아는 마피아 채팅으로, 연인은 연인 채팅으로 몰래 이야기해요. 탈락한 사람은 죽은 사람 채팅에서만 이야기할 수 있어요(영매는 밤에 함께해요).
</p>
<table className="mf-rules small" style={{ borderCollapse: 'collapse', width: '100%' }}>
<tbody>
{ROLE_ORDER.map((r) => (
<tr key={r}>
<td style={{ width: 28, fontSize: '1.2rem' }} aria-hidden>
{ROLE_INFO[r].icon}
</td>
<th scope="row" style={{ whiteSpace: 'nowrap' }}>
{ROLE_INFO[r].name}
</th>
<td>{ROLE_INFO[r].goal}</td>
</tr>
))}
</tbody>
</table>
<p className="small muted">
내 직업은 "내 직업" 카드를 꾹 누르고 있을 때만 보여요. 탈락한 사람은 음성 채팅에서도 말하지 않기로 해요. 게임 중에는 방 채팅 대신 게임 화면의 채팅을 써요.
</p>
</div>
);
}
const ui: GameUI<MafiaView, MafiaOptions> = {
Board: MafiaBoard,
Side: MafiaSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
ownChat: true,
};
export default ui;