/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router'; import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite'; import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; import { GameNotice, gameName } from '../components/GameNotice'; import { QrModal } from '../components/QrModal'; import { api, type RoomPreview } from '../net/api'; import { socket, useRoom } from '../net/socket'; import { useSession } from '../store/session'; import { loadGameUI } from '../games/registry'; import type { GameUI } from '../games/types'; import { sounds } from '../lib/feedback'; import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel'; import { useWide } from '../lib/media'; import { GameHelp } from '../components/GameHelp'; export default function RoomPage() { const { code = '' } = useParams(); const me = useSession((s) => s.me); const nav = useNavigate(); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(null); useEffect(() => { if (!/^\d{6}$/.test(code)) { setPreviewError('방 코드가 올바르지 않아요.'); return; } if (!me) api.room(code).then(setPreview, (e) => setPreviewError((e as Error).message)); }, [code, me]); useEffect(() => { if (me && /^\d{6}$/.test(code)) socket.join(code); }, [code, me]); if (!me) { return ( <> nav('/')} />
{previewError ? ( ) : (
{preview ? ( <>

{gameName(preview.gameId)} 방에 초대받았어요

방장 {preview.host} · {preview.seated}/{preview.maxPlayers}명 · 코드 {preview.codeLabel}

) : (

방 정보를 불러오는 중…

)}
)}
); } return ; } function RoomGone({ message }: { message: string }) { const nav = useNavigate(); return (

{message}

); } function Room({ code, me }: { code: string; me: PublicUser }) { const nav = useNavigate(); const { room, game, result, status, joinError, pending, slow, needsRefresh } = useRoom(); const [ui, setUi] = useState(null); const [menu, setMenu] = useState(false); const [leaveAsk, setLeaveAsk] = useState(false); const [chatOpen, setChatOpen] = useState(false); const gameId = room?.gameId; useEffect(() => { if (gameId) loadGameUI(gameId).then(setUi); }, [gameId]); const players = useMemo(() => { const map: Record = {}; for (const s of room?.seats ?? []) if (s) map[s.user.id] = s.user; for (const u of room?.spectators ?? []) map[u.id] = u; return map; }, [room]); useTurnTitle(ui, game?.view, me.id); if (status === 'replaced') { return (

다른 창에서 이 방을 열었어요

한 번에 한 창에서만 할 수 있어요.

); } if (joinError) { return ( <> nav('/')} />
); } if (!room || room.code !== code || !ui) { return ( <> nav('/')} />
방에 들어가는 중…
); } 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(); nav('/'); }; return ( <> {needsRefresh && (
새 버전이 있어요.
)} {inGame && game ? ( ) : ( )} {room.status === 'finished' && result && game && } {chatOpen && !roomChatClosed && ( setChatOpen(false)} label="채팅"> )} {menu && ( setMenu(false)} label="메뉴">

메뉴

{isHost && room.status === 'paused' && ( )}
)} {leaveAsk && ( s?.user.id === me.id) ? '게임 중에 나가면 기권으로 처리돼요.' : undefined} confirm="나가기" danger onConfirm={doLeave} onClose={() => setLeaveAsk(false)} /> )} ); } /** Blink the tab title when it's my turn and the tab is hidden (docs/07 §6). */ function useTurnTitle(ui: GameUI | null, view: unknown, me: string) { const myTurn = !!ui && !!view && ui.isMyTurn(view, me); const prev = useRef(false); useEffect(() => { if (myTurn && !prev.current && document.visibilityState === 'hidden') sounds.myTurn(); prev.current = myTurn; if (!myTurn) return; const base = document.title; let on = false; const t = setInterval(() => { if (document.visibilityState === 'visible') { document.title = base; return; } on = !on; document.title = on ? '● 내 차례예요' : base; }, 1000); return () => { clearInterval(t); document.title = base; }; }, [myTurn]); } // ---------------------------------------------------------------- lobby function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) { const [qr, setQr] = useState(false); const [settings, setSettings] = useState(false); const isHost = room.hostId === me.id; const mySeat = room.seats.findIndex((s) => s?.user.id === me.id); const seated = room.seats.filter(Boolean).length; const need = Math.max(0, room.minPlayers - seated); const url = `${location.origin}/r/${room.code}`; const gName = gameName(room.gameId); const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname; const shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`; const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length; const [askStart, setAskStart] = useState(false); // Computer players (docs/06 §12): the host fills empty seats; a room with them is a practice room. const canAddBot = isHost && botsAllowed(room.gameId); const practice = room.seats.some((s) => s && isBotId(s.user.id)); // Offered on the first empty seat only, so big rooms don't show a column of the same button. const firstEmpty = room.seats.findIndex((s) => s === null); const wide = useWide(); const invite = (

친구에게 이 코드를 알려 주세요

); const seats = (

자리 ({seated}/{room.maxPlayers})

4 ? '1fr 1fr' : '1fr', gap: 10 }}> {room.seats.map((s, i) => s && isBotId(s.user.id) ? (
{s.user.nickname}
🤖 컴퓨터
{isHost && ( )}
) : s ? (
{room.hostId === s.user.id && 👑 } {s.user.nickname} {s.user.id === me.id && (나)}
{s.connected ? '접속 중' : s.away ? '자리 비움' : '연결 끊김'} {room.hostId !== s.user.id && (s.ready ? 준비 완료 : 준비 중)}
{isHost && s.user.id !== me.id && }
) : (
{canAddBot && i === firstEmpty && ( )}
), )}
{room.spectators.length > 0 &&

관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}

} {mySeat >= 0 && room.allowSpectators && ( )}
); const settingsCard = (

게임 설정

{ui.optionsSummary(room.options)}

{isHost && !wide && ( )}

{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}

{practice &&

🤖 연습 게임이에요. 기록에는 남지 않아요.

} {room.lockedOptions.length > 0 &&

🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.

} {isHost && wide && }
); const action = (
{isHost ? ( ) : mySeat >= 0 ? ( room.seats[mySeat]!.ready ? ( ) : ( ) ) : ( )}
); return (
{invite} {wide ? settingsCard : seats} {wide && (

규칙 요약

)} {!wide && settingsCard}
{wide && seats} {action} {wide && }
{qr && setQr(false)} />} {settings && setSettings(false)} />} {askStart && ( socket.send({ t: 'start' })} onClose={() => setAskStart(false)} /> )}
); } function HostMenu({ room, target }: { room: RoomView; target: PublicUser }) { const [open, setOpen] = useState(false); const [kick, setKick] = useState(false); void room; return ( <> {open && ( setOpen(false)} label={`${target.nickname} 관리`}>

{target.nickname}

)} {kick && ( socket.send({ t: 'kick', userId: target.id })} onClose={() => setKick(false)} /> )} ); } function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onClose: () => void }) { return (

게임 설정

); } /** Host settings: game options + room options. Inline on PC, in a sheet on narrow screens. */ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI; inline?: boolean; onDone?: () => void }) { const [options, setOptions] = useState(room.options); const [visibility, setVisibility] = useState(room.visibility); const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators); const [chatEnabled, setChatEnabled] = useState(room.chatEnabled); // Follow server-side changes (e.g. admin locks) when not editing. useEffect(() => setOptions(room.options), [room.options]); const dirty = JSON.stringify(options) !== JSON.stringify(room.options) || visibility !== room.visibility || allowSpectators !== room.allowSpectators || chatEnabled !== room.chatEnabled; const save = () => { socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled }); onDone?.(); }; const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => (
); return (
{room.lockedOptions.length > 0 &&

🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.

}
방 공개 {room.seats.some((s) => s && isBotId(s.user.id)) ? (

컴퓨터가 있는 방은 친구만 들어와요.

) : ( seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만') )}
관전 {seg(allowSpectators, setAllowSpectators, '허용', '안 됨')}
채팅 {seg(chatEnabled, setChatEnabled, '켜기', '끄기')}
{!inline && ( )}
); } // ---------------------------------------------------------------- game function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui: GameUI; me: PublicUser; players: Record; pending: boolean; slow: boolean }) { const game = useRoom((s) => s.game)!; const tapConfirm = useSession((s) => s.settings.tapConfirm); const wide = useWide(); const myTurn = ui.isMyTurn(game.view, me.id); const isPlayer = room.seats.some((s) => s?.user.id === me.id) && game.active !== undefined; const props = { view: game.view, me: me.id, players, act: (a: unknown) => socket.act(a), pending, deadline: game.deadline, active: game.active, tapConfirm, }; const status = (
{room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)} {slow && · 연결 확인 중…} {!isPlayer && ( 관전 중 )}
); // [?] 지금 할 수 있는 것 sits right next to the turn status (docs/07 §5). const statusRow = (
{status}
); return (
{!wide && statusRow}
); } function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: PublicUser; players: Record; onLeave: () => void }) { const result = useRoom((s) => s.result)!; const [hidden, setHidden] = useState(false); const played = useRef(false); const seatedAll = room.seats.filter(Boolean).map((s) => s!.user.id); const amSeated = seatedAll.includes(me.id); // Computer players always agree to a rematch. const seated = seatedAll.filter((u) => !isBotId(u)); const voted = room.rematchVotes.includes(me.id); // Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win. const solo = result.ranking.flat().length === 1; const allFirst = !solo && result.ranking.length === 1; const won = !solo && !allFirst && result.ranking[0]?.includes(me.id); const lost = !solo && !allFirst && !won && result.ranking.some((g) => g.includes(me.id)); useEffect(() => { if (played.current) return; played.current = true; if (won) sounds.win(); else if (lost) sounds.lose(); }, [won, lost]); if (hidden) { return (
); } return ( setHidden(true)} label="게임 결과">

{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}

{result.summary}

{seatedAll.length > seated.length &&

🤖 연습 게임이라 기록에는 남지 않아요.

}
{result.ranking.map((group, i) => group.map((u) => (
{i + 1}등 {players[u] && } {players[u]?.nickname ?? '나간 사람'} 이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판
)), )}
{amSeated && ( )} {room.lastGamePk && result.ranking.some((g) => g.includes(me.id)) && ( ▶ 처음부터 다시 보기 )} {room.lastSeed && ( )}
); } /** * docs/04 §5: the seed's SHA-256 was published when the game started; the seed is revealed at the end. * The browser recomputes the hash itself so players don't have to trust the server's word. */ function FairnessCheck({ seedHash, seed }: { seedHash: string | null; seed: string }) { const [verdict, setVerdict] = useState(null); const check = async () => { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed)); const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); setVerdict(hex === seedHash); }; return (
공정성 확인

시작할 때 공개한 값(SHA-256): {seedHash ?? '-'}

끝나고 공개한 난수 씨앗: {seed}

{verdict !== null && (

{verdict ? '✓ 일치해요. 게임 도중 카드 순서나 난수가 바뀌지 않았어요.' : '✗ 일치하지 않아요. 관리자에게 알려 주세요.'}

)}
); } // ---------------------------------------------------------------- chat function ChatButton({ onOpen }: { onOpen: () => void }) { const total = useRoom((s) => s.chat.length); const seen = useChatSeen((s) => s.seen); const unread = Math.max(0, total - seen); return ( ); }