diff --git a/apps/server/src/rooms/mafia.room.test.ts b/apps/server/src/rooms/mafia.room.test.ts new file mode 100644 index 0000000..d77bc74 --- /dev/null +++ b/apps/server/src/rooms/mafia.room.test.ts @@ -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 = {}; + 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).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); + }); +}); diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index 3616f09..e69f234 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -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) { diff --git a/apps/web/src/games/mafia/index.tsx b/apps/web/src/games/mafia/index.tsx new file mode 100644 index 0000000..c2ff267 --- /dev/null +++ b/apps/web/src/games/mafia/index.tsx @@ -0,0 +1,1030 @@ +/** + * 마피아 화면 (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; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 6103b01..a59c637 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -25,6 +25,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { 'las-vegas': () => import('./las-vegas'), 'cockroach-poker': () => import('./cockroach-poker'), 'ninety-nine': () => import('./ninety-nine'), + mafia: () => import('./mafia'), }; const cache = new Map(); diff --git a/apps/web/src/games/types.ts b/apps/web/src/games/types.ts index 342bc10..18382c4 100644 --- a/apps/web/src/games/types.ts +++ b/apps/web/src/games/types.ts @@ -27,4 +27,6 @@ export interface GameUI { Options: ComponentType>; optionsSummary(options: O): string; Rules: ComponentType<{ options?: O }>; + /** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */ + ownChat?: boolean; } diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 6190cf9..0f3f2ca 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -22,6 +22,7 @@ export const GAME_ICON: Record = { 'las-vegas': '⚅', 'cockroach-poker': '🐞', 'ninety-nine': '💯', + mafia: '🌙', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index a4f7f91..5f4ac00 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -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 }) { 방 🔗} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" /> - - setChatOpen(true)} /> - + {!roomChatClosed && ( + + setChatOpen(true)} /> + + )} @@ -183,7 +187,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) { )} {room.status === 'finished' && result && game && } - {chatOpen && ( + {chatOpen && !roomChatClosed && ( setChatOpen(false)} label="채팅">