/** * 마피아 화면 (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 = { 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 = { town: '시민 팀', mafia: '마피아 팀' }; const CHANNEL_KO: Record = { day: '전체', mafia: '마피아', dead: '죽은 사람', lovers: '연인' }; const CHANNEL_ORDER: Channel[] = ['day', 'mafia', 'dead', 'lovers']; const PHASE_KO: Record = { roleReveal: '직업 확인', greeting: '인사 시간', discussion: '낮 토론', vote: '지목 투표', defense: '최후의 반론', confirm: '찬반 투표', night: '밤', ended: '게임 끝', }; const PHASE_EVENT: Record, string> = { roleReveal: '직업 확인 시간이에요', greeting: '인사 시간이에요', discussion: '낮 토론 시간이에요', vote: '지목 투표 시간이에요', defense: '최후의 반론 시간이에요', confirm: '찬반 투표 시간이에요', night: '밤이 됐어요', }; const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; const userOf = (players: Record, 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 ( {text} ); } function useHold(): [boolean, Record] { const [show, setShow] = useState(false); const timer = useRef | 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; 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 ( {show && ( {r.goal} {mateText} )} ); } 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 ( {r.icon} {r.name} ); } if (p.team && !p.alive) return {p.team === 'mafia' ? '마피아였어요' : '시민 팀'}; return null; } function eventText(e: PublicEvent, players: Record): 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 }) { 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 (

📢 {title}

{news.map((e) => e.t === 'vote' ? ( ) : ( {eventText(e, players)} ), )}
); } function VoteBars({ e, players }: { e: Extract; players: Record }) { const rows = Object.entries(e.tally).sort((a, b) => b[1] - a[1]); const max = Math.max(1, ...rows.map((r) => r[1])); return ( <> {eventText(e, players)} {rows.length > 0 && (
{rows.map(([id, n]) => (
{nick(players, id)} {n}표
))}
)} {e.abstain > 0 && 기권 {e.abstain}표} {e.votes && ( {Object.entries(e.votes) .map(([from, to]) => `${nick(players, from)} → ${to === 'abstain' ? '기권' : nick(players, to)}`) .join(' · ')} )} ); } // ---------------------------------------------------------------- players type Pick = { ids: string[]; chosen: string | null; onPick: (id: string) => void } | null; function Players({ v, players, me, pick, pending }: { v: MafiaView; players: Record; me: string | null; pick: Pick; pending: boolean }) { const mafiaChoice = v.my?.night?.action === 'mafiaTarget' ? v.my.night.choice : null; return (

사람들 살아 있는 사람 {v.aliveCount}명

{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 = ( <> {u.nickname} {p.id === me ? ' (나)' : ''} {!p.alive && 탈락 · {p.diedDay}일차} {p.id === v.phase.accused && 지목됨} {mafiaChoice === p.id && !pick && 🎯 오늘 밤 대상} ); 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 ( ); } return (
{body}
); })}
); } // ---------------------------------------------------------------- chat type Line = ({ k: 'chat'; m: ChatView } | { k: 'sys'; e: PublicEvent }) & { at: number }; function ChatBox({ v, players, me, act }: { v: MafiaView; players: Record; me: string | null; act: (a: unknown) => void }) { const tabs = useMemo(() => { const set = new Set(['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('day'); const [text, setText] = useState(''); const [seen, setSeen] = useState>({}); const listRef = useRef(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) => { 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(`[data-ch='${next}']`)?.focus(); }; return (
{tabs.map((c) => { const unread = c === cur ? 0 : Math.max(0, count(c) - (seen[c] ?? 0)); return ( ); })}
{lines.length === 0 &&

아직 메시지가 없어요.

} {lines.map((l) => l.k === 'sys' ? (
📢 {eventText(l.e, players)}
) : (
{nick(players, l.m.from)} {l.m.text}
), )}
{canWrite ? (
{ e.preventDefault(); const t = text.trim(); if (!t) return; act({ type: 'chat', channel: cur, text: t.slice(0, 200) }); setText(''); }} > setText(e.target.value)} maxLength={200} placeholder={`${CHANNEL_KO[cur]} 채팅에 쓰기`} aria-label={`${CHANNEL_KO[cur]} 채팅 메시지`} enterKeyHint="send" />
) : (

{why}

)}
); } // ---------------------------------------------------------------- actions function resultText(r: PrivateResult, players: Record): 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; 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 =

관전 중이에요. 낮 채팅과 발표만 볼 수 있어요.

; } else if (ph.kind === 'ended') { body =

게임이 끝났어요. 모든 직업이 공개됐어요.

; } else if (!my.alive) { body = ( <>

탈락했어요.

죽은 사람 채팅에서만 이야기할 수 있어요. 살아 있는 사람에게 정보를 알려 주지 말아 주세요. ); } else if (ph.kind === 'roleReveal') { body = ( <>

위의 "내 직업" 카드를 꾹 눌러 직업을 확인해 주세요.

곧 {v.options.firstPhase === 'day' ? '인사 시간' : '첫 밤'}이 시작돼요. ); } else if (ph.kind === 'greeting' || ph.kind === 'discussion') { body = ( <>

{ph.kind === 'greeting' ? '인사 시간이에요. 오늘은 투표가 없어요.' : '토론 시간이에요. 마피아가 누구일까요?'}

{ph.kind === 'discussion' && v.options.timeAdjust && (
)} {ph.kind === 'discussion' && !my.canAdjust && v.options.timeAdjust && 시간 바꾸기는 하루에 한 번만 할 수 있어요.}
살아 있는 사람이 모두 누르면 바로 다음으로 넘어가요. ); } else if (ph.kind === 'vote') { body = ( <>

마피아로 의심되는 사람을 골라 주세요

초록 테두리 사람을 누르면 지목해요. 시간 안에는 바꿀 수 있어요. {my.vote === null ? '아직 고르지 않았어요 (안 고르면 기권)' : my.vote === 'abstain' ? '기권했어요' : `${n(my.vote)}을 지목했어요`}
); } else if (ph.kind === 'defense') { body =

{ph.accused === me ? '지목됐어요. 전체 채팅으로 최후의 반론을 해 주세요!' : `${n(ph.accused!)}의 최후의 반론을 들어 주세요.`}

; } else if (ph.kind === 'confirm') { body = my.canConfirm ? ( <>

{n(ph.accused!)}을 탈락시킬까요?

{my.ballot ? `${my.ballot === 'yes' ? '찬성' : '반대'}했어요. 시간 안에는 바꿀 수 있어요.` : '고르지 않으면 집계에서 빠져요.'} ) : (

다른 사람들이 찬반 투표를 하고 있어요.

); } else if (ph.kind === 'night' && my.night) { const nv = my.night; body = ( <>

{nv.prompt}

{nv.action && nv.targets.length > 0 && ( <> 초록 테두리 사람을 누르면 골라요. 밤이 끝날 때 고른 사람으로 정해져요. {nv.choice ? `고른 사람: ${n(nv.choice)}${nv.pickedBy && nv.pickedBy !== me ? ` (${n(nv.pickedBy)}이 골랐어요)` : ''}` : '아직 고르지 않았어요'} {nv.action === 'mafiaTarget' && nv.choice && (
)} )} {!nv.action && 밤이 지나면 아침 발표가 있어요.} ); } return (

{ph.kind === 'night' ? '🌙 오늘 밤' : ph.kind === 'ended' ? '🏁 결과' : '☀️ 할 일'}

{body} {my?.idleWarning && ph.kind !== 'ended' && 오늘도 아무것도 하지 않으면 자리 비움으로 탈락해요.} {my && my.results.length > 0 && ( <> {my.role === 'medium' ? '영매 결과 (나만 봐요)' : '조사 결과 (나만 봐요)'}
    {my.results.map((r) => (
  • {resultText(r, players)}
  • ))}
)}
); } function FinalReveal({ v, players }: { v: MafiaView; players: Record }) { const f = v.final!; return (

🏆 {TEAM_KO[f.winner]} 승리 · {f.days}일차

{f.summary} {f.ranking.map((id) => { const p = f.players.find((x) => x.id === id)!; return ( ); })}
이름 직업 결과
{p.won ? '🏆 ' : ''} {nick(players, id)} {f.mvp === id ? ' ⭐MVP' : ''} {ROLE_INFO[p.role].icon} {ROLE_INFO[p.role].name} {p.alive ? '생존' : `${p.diedDay}일차 ${p.diedBy === 'vote' ? '투표로' : p.diedBy === 'mafia' ? '밤에' : '자리 비움으로'} 탈락`}
); } // ---------------------------------------------------------------- board function MafiaBoard({ view: v, me, players, act, pending }: BoardProps) { 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 (
{time === 'night' ? '🌙' : time === 'end' ? '🏁' : '☀️'} {v.day}일차 {PHASE_KO[ph.kind]} {sub} {my && }
{v.final && }

{announce}

); } // ---------------------------------------------------------------- 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) { return ( <>
마피아 · {v.day}일차 {PHASE_KO[v.phase.kind]} 이번 판 직업: {composition(v.setup)} {optionsSummary(v.options)}
{v.events.length > 0 && (
발표 기록
    {[...v.events] .reverse() .filter((e) => e.t !== 'phase') .map((e) => (
  1. {e.day}일차 · {eventText(e, players)}
  2. ))}
)}

탈락한 사람은 음성 채팅에서도 말하지 않기로 해요. 게임 중에는 방 채팅 대신 게임 화면의 채팅을 써요.

); } // ---------------------------------------------------------------- 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 { 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({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { return (
{options.map(([k, l]) => ( ))}
); } function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) { return (
{label} {children} {help && {help}}
); } const onOff: [boolean, string][] = [ [true, '켜기'], [false, '끄기'], ]; const secs = (...v: T[]): [T, string][] => v.map((x) => [x, `${x}초`]); const EXTRA_KO: Record = { soldier: '군인', politician: '정치인', medium: '영매', lovers: '연인(2명)' }; function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); return (
set({ mafiaCount })} />
{(Object.keys(EXTRA_KO) as (keyof MafiaOptions['extraRoles'])[]).map((k) => ( ))}
set({ firstPhase })} /> set({ nightSeconds })} /> set({ deathReveal })} /> {more && ( <> set({ discussionPerAlive })} /> set({ discussionMin })} /> set({ discussionMax })} /> set({ voteSeconds })} /> set({ defenseSeconds })} /> set({ confirmSeconds })} /> set({ timeAdjust })} /> set({ openVote })} /> set({ doctorSelfHeal })} /> set({ doctorRepeat })} /> set({ mafiaMustKill })} /> set({ policeRevealRole })} /> set({ deadSeeAll })} /> set({ kidSafeChat })} /> set({ revealChatsAtEnd })} /> )}
); } 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 (

4~12명이 비밀 직업을 받아 시민 팀과 마피아 팀으로 나뉘어요. 진행은 서버가 맡아요. 시민 팀은 마피아를 모두 찾으면, 마피아 팀은 살아 있는 마피아 수가 다른 사람 수와 같아지면 이겨요.

하루 흐름: ☀️ 낮 토론 → 지목 투표(가장 많이 지목된 1명) → 최후의 반론 → 찬반 투표(찬성이 많으면 탈락) → 🌙 밤(직업별 비밀 행동) → 아침 발표. 첫날은 인사만 하고 투표 없이 밤이 돼요.

밤에는 낮 채팅이 잠겨요. 마피아는 마피아 채팅으로, 연인은 연인 채팅으로 몰래 이야기해요. 탈락한 사람은 죽은 사람 채팅에서만 이야기할 수 있어요(영매는 밤에 함께해요).

{ROLE_ORDER.map((r) => ( ))}
{ROLE_INFO[r].icon} {ROLE_INFO[r].name} {ROLE_INFO[r].goal}

내 직업은 "내 직업" 카드를 꾹 누르고 있을 때만 보여요. 탈락한 사람은 음성 채팅에서도 말하지 않기로 해요. 게임 중에는 방 채팅 대신 게임 화면의 채팅을 써요.

); } const ui: GameUI = { Board: MafiaBoard, Side: MafiaSide, statusText, isMyTurn, Options, optionsSummary, Rules, ownChat: true, }; export default ui;