From 17b1d0fcdae1c6e7f85ce797a12afdbfc4206d5a Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sun, 4 Oct 2026 04:37:33 +0900 Subject: [PATCH] =?UTF-8?q?M1=20=EC=9B=B9=20+=20=EB=B0=B0=ED=8F=AC:=20?= =?UTF-8?q?=ED=99=88=C2=B7=EC=BD=94=EB=93=9C=20=EC=9E=85=EC=9E=A5=C2=B7?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=EC=8B=A4=C2=B7=EC=98=A4=EB=AA=A9=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=C2=B7=EA=B2=B0=EA=B3=BC=C2=B7=EA=B3=84=EC=A0=95,=20E2?= =?UTF-8?q?E,=20Docker/Caddy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 웹(React+Vite): 홈(큰 버튼 2개·하던 게임 돌아가기), 6자리 코드 입력(붙여넣기 감지·자동 입장), 링크 입장(닉네임 한 칸), 대기실(코드/링크 복사·공유·QR·자리·준비·방장 메뉴·설정), 오목(SVG 판·두 번 눌러 확정·금수/위험 표시·시계·무르기·무승부·기권·키보드 조작·스크린리더 안내), 결과(한 판 더 투표·방 전적·공정성 확인), 채팅·반응, 계정(닉네임 변경·글자 크기·테마·소리·진동) - WS 클라이언트: 지수 백오프+지터 재연결, 화면 복귀/온라인 즉시 재연결, seq 순서 처리, ping/pong 지연·시계 보정, 서버 재시작 안내, 다른 창 접속 처리 - @bg/shared/lite: 브라우저용 진입점(zod 제외) → 첫 화면 JS 약 90KB gzip - e2e/omok.e2e.ts: 브라우저 두 개(모바일·PC)로 방 만들기→링크 입장→대국→새로고침 복구→승리→한 판 더 - deploy: Dockerfile(운영 의존성만, 267MB), docker-compose(app+Caddy), Caddyfile(보안 헤더·CSP), .env.example - 프록시 뒤에서만 X-Forwarded-For 신뢰(TRUST_PROXY=1) Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 7 + README.md | 34 +- apps/server/src/config.ts | 3 + apps/server/src/server.ts | 4 +- apps/server/src/ws/gateway.ts | 3 + apps/web/index.html | 19 + apps/web/package.json | 4 +- apps/web/public/favicon.svg | 1 + apps/web/src/components/CodeInput.tsx | 82 +++ apps/web/src/components/NicknameForm.tsx | 60 +++ apps/web/src/components/QrModal.tsx | 42 ++ apps/web/src/components/TurnTimer.tsx | 44 ++ apps/web/src/components/ui.tsx | 163 ++++++ apps/web/src/games/omok/index.tsx | 431 ++++++++++++++++ apps/web/src/games/registry.ts | 22 + apps/web/src/games/types.ts | 27 + apps/web/src/lib/catalog-ui.ts | 13 + apps/web/src/lib/feedback.ts | 48 ++ apps/web/src/main.tsx | 64 +++ apps/web/src/net/api.ts | 72 +++ apps/web/src/net/socket.ts | 344 +++++++++++++ apps/web/src/pages/GamePage.tsx | 103 ++++ apps/web/src/pages/Home.tsx | 109 ++++ apps/web/src/pages/JoinPage.tsx | 36 ++ apps/web/src/pages/MePage.tsx | 206 ++++++++ apps/web/src/pages/NotFound.tsx | 12 + apps/web/src/pages/RoomPage.tsx | 621 +++++++++++++++++++++++ apps/web/src/pages/RulesPage.tsx | 24 + apps/web/src/store/session.ts | 88 ++++ apps/web/src/store/toast.ts | 17 + apps/web/src/styles/global.css | 530 +++++++++++++++++++ apps/web/tsconfig.json | 9 + apps/web/vite.config.ts | 19 + bun.lock | 7 + deploy/.env.example | 9 + deploy/Caddyfile | 17 + deploy/Dockerfile | 35 ++ deploy/docker-compose.yml | 33 ++ docs/03-realtime-protocol.md | 1 + e2e/omok.e2e.ts | 129 +++++ package.json | 1 + packages/shared/package.json | 9 +- packages/shared/src/index.ts | 4 +- packages/shared/src/lite.ts | 5 + packages/shared/src/protocol.ts | 86 +--- packages/shared/src/types.ts | 83 +++ 46 files changed, 3590 insertions(+), 90 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/web/index.html create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/components/CodeInput.tsx create mode 100644 apps/web/src/components/NicknameForm.tsx create mode 100644 apps/web/src/components/QrModal.tsx create mode 100644 apps/web/src/components/TurnTimer.tsx create mode 100644 apps/web/src/components/ui.tsx create mode 100644 apps/web/src/games/omok/index.tsx create mode 100644 apps/web/src/games/registry.ts create mode 100644 apps/web/src/games/types.ts create mode 100644 apps/web/src/lib/catalog-ui.ts create mode 100644 apps/web/src/lib/feedback.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/net/api.ts create mode 100644 apps/web/src/net/socket.ts create mode 100644 apps/web/src/pages/GamePage.tsx create mode 100644 apps/web/src/pages/Home.tsx create mode 100644 apps/web/src/pages/JoinPage.tsx create mode 100644 apps/web/src/pages/MePage.tsx create mode 100644 apps/web/src/pages/NotFound.tsx create mode 100644 apps/web/src/pages/RoomPage.tsx create mode 100644 apps/web/src/pages/RulesPage.tsx create mode 100644 apps/web/src/store/session.ts create mode 100644 apps/web/src/store/toast.ts create mode 100644 apps/web/src/styles/global.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 deploy/.env.example create mode 100644 deploy/Caddyfile create mode 100644 deploy/Dockerfile create mode 100644 deploy/docker-compose.yml create mode 100644 e2e/omok.e2e.ts create mode 100644 packages/shared/src/lite.ts create mode 100644 packages/shared/src/types.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f1b9c2d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +**/node_modules +apps/web/dist +test-results +data +.git +deploy/.env diff --git a/README.md b/README.md index 59f07e5..f101f38 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,34 @@ -# joke-app +# 같이 놀자 보드게임 +광고 없이 가볍고, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임 사이트. +계획서는 [docs/](docs/README.md)에 있고, 구현은 계획서를 따른다. + +## 개발 +```bash +bun install +bun run --filter '@bg/server' dev # 서버 :3000 +bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록시) +``` +브라우저에서 http://localhost:5173 을 연다. 디스코드 값이 없으면 게스트 로그인만 보인다. + +## 확인 +```bash +bun run typecheck +bun test # 규칙·서버 통합·방 단위 테스트 +(cd apps/web && bunx vite build) # 웹 빌드 +bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요) +``` + +## 배포 +`deploy/.env.example`을 `deploy/.env`로 복사해 값을 채운 뒤: +```bash +cd deploy && docker compose up -d --build +``` +자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md). + +## 구조 +- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구 +- `packages/shared` — 프로토콜, 닉네임·방 코드 규칙, 게임 목록 (`/lite`는 브라우저용) +- `packages/games/` — 게임 규칙(순수 함수) +- `apps/server` — Bun 서버(HTTP API, WebSocket, 방 관리, SQLite) +- `apps/web` — React 웹 diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index eb5d388..a8dd278 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -7,6 +7,8 @@ export interface Config { discord: { clientId: string; clientSecret: string } | null; sessionSecret: string; secureCookies: boolean; + /** Trust X-Forwarded-For (only when running behind our reverse proxy). */ + trustProxy: boolean; } export function loadConfig(env: Record = process.env): Config { @@ -26,5 +28,6 @@ export function loadConfig(env: Record = process.env discord: clientId && clientSecret ? { clientId, clientSecret } : null, sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me', secureCookies: publicOrigin.startsWith('https://'), + trustProxy: env.TRUST_PROXY === '1', }; } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f6d82b8..4953756 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -45,7 +45,9 @@ export function startServer(opts: StartOptions) { let ready = true; const ipOf = (req: Request) => - req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || server?.requestIP(req)?.address || 'unknown'; + (config.trustProxy ? req.headers.get('x-forwarded-for')?.split(',').pop()?.trim() : undefined) || + server?.requestIP(req)?.address || + 'unknown'; const app = createHttpApp({ config, diff --git a/apps/server/src/ws/gateway.ts b/apps/server/src/ws/gateway.ts index 52097e4..b0201fc 100644 --- a/apps/server/src/ws/gateway.ts +++ b/apps/server/src/ws/gateway.ts @@ -185,6 +185,9 @@ export class Gateway { case 'rematch': e = room.rematch(u); break; + case 'void': + e = room.voidGame(u); + break; case 'sync': room.sendSync(this.connFor(ws)); return; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..9362ef9 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + 같이 놀자 보드게임 + + +
+ + + + diff --git a/apps/web/package.json b/apps/web/package.json index 567176a..76c584e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "@bg/shared": "workspace:*", + "pretendard": "1.3.9", "qrcode-generator": "2.0.4", "react": "19.3.0", "react-dom": "19.3.0", "react-router": "8.4.0", - "zustand": "5.0.15" + "zustand": "5.0.15", + "@bg/games": "workspace:*" }, "devDependencies": { "@types/react": "19.3.0", diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..6b07bf1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/components/CodeInput.tsx b/apps/web/src/components/CodeInput.tsx new file mode 100644 index 0000000..ef91d6e --- /dev/null +++ b/apps/web/src/components/CodeInput.tsx @@ -0,0 +1,82 @@ +/** 6-digit code entry: big boxes, numeric keypad, paste detection, auto-submit (docs/06 §3). */ +import { useEffect, useRef, useState } from 'react'; +import { normalizeRoomCode } from '@bg/shared/lite'; + +export function CodeInput({ onComplete, busy }: { onComplete: (code: string) => void; busy?: boolean }) { + const [digits, setDigits] = useState(''); + const [clip, setClip] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + // Offer a big paste button if the clipboard already holds a code or invite link. + navigator.clipboard + ?.readText?.() + .then((t) => setClip(normalizeRoomCode(t))) + .catch(() => {}); + }, []); + + const set = (v: string) => { + const code = normalizeRoomCode(v); + const next = code ?? v.replace(/\D/g, '').slice(0, 6); + setDigits(next); + if (next.length === 6) onComplete(next); + }; + + return ( +
+ +
inputRef.current?.focus()}> +
+ {[0, 1, 2, -1, 3, 4, 5].map((i, k) => + i < 0 ? ( + + · + + ) : ( + + {digits[i] ?? ''} + + ), + )} +
+ set(e.target.value)} + onPaste={(e) => { + e.preventDefault(); + set(e.clipboardData.getData('text')); + }} + inputMode="numeric" + autoComplete="one-time-code" + maxLength={40} + disabled={busy} + style={{ position: 'absolute', inset: 0, opacity: 0, fontSize: 16 }} + /> +
+ {clip && digits.length < 6 && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/NicknameForm.tsx b/apps/web/src/components/NicknameForm.tsx new file mode 100644 index 0000000..6dd56f5 --- /dev/null +++ b/apps/web/src/components/NicknameForm.tsx @@ -0,0 +1,60 @@ +/** One-field start: nickname (pre-filled suggestion) + start button; Discord as secondary (docs/05 §1). */ +import { useState } from 'react'; +import { nicknameError, normalizeNickname, suggestNickname } from '@bg/shared/lite'; +import { api, discordLoginUrl } from '../net/api'; +import { useSession } from '../store/session'; + +export function NicknameForm({ next, submitLabel = '이 이름으로 시작하기', onDone }: { next: string; submitLabel?: string; onDone?: () => void }) { + const [nick, setNick] = useState(() => suggestNickname()); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const discord = useSession((s) => s.discordEnabled); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const n = normalizeNickname(nick); + const err = nicknameError(n); + if (err) return setError(err); + setBusy(true); + try { + const r = await api.guest(n); + useSession.getState().setMe(r.me); + await useSession.getState().refresh(); + onDone?.(); + } catch (ex) { + setError((ex as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
+ + {error &&

{error}

} + + {discord && ( + + 디스코드로 로그인 + + )} +

이름은 나중에 언제든 바꿀 수 있어요.

+
+ ); +} diff --git a/apps/web/src/components/QrModal.tsx b/apps/web/src/components/QrModal.tsx new file mode 100644 index 0000000..f37a3df --- /dev/null +++ b/apps/web/src/components/QrModal.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import { Sheet, CodeLabel } from './ui'; + +/** QR is generated lazily so the library is not in the first-page bundle. */ +export function QrModal({ url, code, onClose }: { url: string; code: string; onClose: () => void }) { + const [cells, setCells] = useState<{ n: number; path: string } | null>(null); + useEffect(() => { + let alive = true; + import('qrcode-generator').then(({ default: qrcode }) => { + const qr = qrcode(0, 'M'); + qr.addData(url); + qr.make(); + const n = qr.getModuleCount(); + let path = ''; + for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (qr.isDark(y, x)) path += `M${x} ${y}h1v1h-1z`; + if (alive) setCells({ n, path }); + }); + return () => { + alive = false; + }; + }, [url]); + return ( + +

휴대폰 카메라로 찍으면 들어와요

+
+ {cells ? ( + + + + ) : ( +

만드는 중…

+ )} +
+

+ 코드 +

+ +
+ ); +} diff --git a/apps/web/src/components/TurnTimer.tsx b/apps/web/src/components/TurnTimer.tsx new file mode 100644 index 0000000..689971c --- /dev/null +++ b/apps/web/src/components/TurnTimer.tsx @@ -0,0 +1,44 @@ +/** Ring countdown to a server deadline (docs/07 §4). */ +import { useEffect, useRef, useState } from 'react'; +import { socket } from '../net/socket'; +import { haptic, sounds } from '../lib/feedback'; + +export function useServerNow(intervalMs = 250): number { + const [now, setNow] = useState(() => socket.now()); + useEffect(() => { + const t = setInterval(() => setNow(socket.now()), intervalMs); + return () => clearInterval(t); + }, [intervalMs]); + return now; +} + +export function TurnTimer({ deadline, totalMs, mine, size = 44 }: { deadline: number | null; totalMs: number; mine: boolean; size?: number }) { + const now = useServerNow(); + const warned = useRef(null); + const left = deadline ? Math.max(0, deadline - now) : null; + const sec = left === null ? null : Math.ceil(left / 1000); + useEffect(() => { + if (!mine || sec === null) return; + if ((sec === 10 || sec === 5) && warned.current !== sec) { + warned.current = sec; + sounds.warn(); + if (sec === 5) haptic([60, 40, 60]); + } + }, [sec, mine]); + if (left === null) return null; + const frac = Math.min(1, left / Math.max(1, totalMs)); + const color = sec! <= 5 ? 'var(--danger)' : sec! <= 10 ? 'var(--warning)' : 'var(--success)'; + const r = size / 2 - 4; + const c = 2 * Math.PI * r; + return ( + + + + + + 40 ? '0.95rem' : '0.8rem', color }}> + {sec! >= 600 ? `${Math.floor(sec! / 60)}분` : sec! >= 100 ? `${Math.floor(sec! / 60)}:${String(sec! % 60).padStart(2, '0')}` : sec} + + + ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx new file mode 100644 index 0000000..d38fe0d --- /dev/null +++ b/apps/web/src/components/ui.tsx @@ -0,0 +1,163 @@ +/** Shared UI primitives (docs/07 §4). */ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import type { PublicUser } from '@bg/shared/lite'; +import { formatRoomCode } from '@bg/shared/lite'; +import { useToasts, toast } from '../store/toast'; +import { useRoom } from '../net/socket'; +import { haptic } from '../lib/feedback'; + +const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626']; + +export function Avatar({ user, size = 40 }: { user: Pick; size?: number }) { + let h = 0; + for (const ch of user.id) h = (h * 31 + ch.charCodeAt(0)) >>> 0; + const bg = AVATAR_COLORS[h % AVATAR_COLORS.length]; + return ( + + {user.avatar ? : [...user.nickname][0]} + + ); +} + +export function CopyButton({ text, label, done = '복사했어요 ✓', className = 'btn btn-secondary' }: { text: string; label: ReactNode; done?: string; className?: string }) { + const [copied, setCopied] = useState(false); + const onClick = async () => { + try { + await navigator.clipboard.writeText(text); + } catch { + // Fallback for old browsers / insecure contexts. + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + ta.remove(); + if (!ok) { + toast(`복사가 안 돼요. 직접 적어 주세요: ${text}`, 'error', 6000); + return; + } + } + haptic(30); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + return ( + + ); +} + +export function ShareButton({ title, text, url, className = 'btn btn-secondary' }: { title: string; text: string; url: string; className?: string }) { + const canShare = typeof navigator !== 'undefined' && 'share' in navigator; + if (!canShare) return ; + return ( + + ); +} + +export function Sheet({ onClose, children, label }: { onClose: () => void; children: ReactNode; label: string }) { + const ref = useRef(null); + useEffect(() => { + const prev = document.activeElement as HTMLElement | null; + ref.current?.focus(); + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => { + window.removeEventListener('keydown', onKey); + prev?.focus?.(); + }; + }, [onClose]); + return ( +
e.target === e.currentTarget && onClose()}> +
+ {children} +
+
+ ); +} + +export function ConfirmSheet({ + title, + body, + confirm, + cancel = '취소', + danger, + onConfirm, + onClose, +}: { + title: string; + body?: ReactNode; + confirm: string; + cancel?: string; + danger?: boolean; + onConfirm: () => void; + onClose: () => void; +}) { + return ( + +

{title}

+ {body &&
{body}
} +
+ + +
+
+ ); +} + +export function Toasts() { + const toasts = useToasts((s) => s.toasts); + return ( +
+ {toasts.map((t) => ( +
+ {t.text} +
+ ))} +
+ ); +} + +export function ConnectionBanner() { + const status = useRoom((s) => s.status); + const code = useRoom((s) => s.code); + if (!code) return null; + if (status === 'reconnecting' || status === 'connecting') return
다시 연결하는 중…
; + if (status === 'offline') return
인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.
; + return null; +} + +export function CodeLabel({ code }: { code: string }) { + return {formatRoomCode(code)}; +} + +export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) { + return ( +
+ {onBack && ( + + )} +
{title}
+ {right} +
+ ); +} diff --git a/apps/web/src/games/omok/index.tsx b/apps/web/src/games/omok/index.tsx new file mode 100644 index 0000000..dd76bdd --- /dev/null +++ b/apps/web/src/games/omok/index.tsx @@ -0,0 +1,431 @@ +/** 오목 화면 (docs/games/omok.md §9). */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { OmokOptions, OmokView, Pt } from '@bg/games/omok'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; +import { TurnTimer, useServerNow } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +type Color = 'black' | 'white'; +const COLOR_KO: Record = { black: '흑', white: '백' }; +const COLS = 'ABCDEFGHIJKLMNOPQRS'; +const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`; +const RULE_KO: Record = { korean: '한국식', renju: '렌주룰', free: '자유룰' }; + +function stars(size: number): Pt[] { + const s = size === 19 ? [3, 9, 15] : [3, 7, 11]; + return s.flatMap((x) => s.map((y) => ({ x, y }))); +} + +function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) { + return ( + + + {color === 'black' ? ( + + ) : ( + + )} + + ); +} + +function OmokBoard({ view: v, me, players, act, pending, deadline, tapConfirm }: BoardProps) { + const n = v.size; + const myTurn = v.phase === 'playing' && v.myColor === v.turn; + const [preview, setPreview] = useState(null); + const [cursor, setCursor] = useState({ x: (n - 1) / 2, y: (n - 1) / 2 }); + const [confirm, setConfirm] = useState(null); + const [announce, setAnnounce] = useState(''); + const [kbd, setKbd] = useState(false); + const svgRef = useRef(null); + const prevCount = useRef(v.moveCount); + const prevTurn = useRef(v.turn); + + const forbidden = useMemo(() => new Set(v.forbidden.map((p) => p.y * n + p.x)), [v.forbidden, n]); + + // Sounds, vibration and screen-reader announcements on new moves. + useEffect(() => { + if (v.moveCount > prevCount.current && v.lastMove) { + sounds.stone(); + const who = v.moves[v.moves.length - 1]!.color; + setAnnounce(`${COLOR_KO[who]}이 ${label(n, v.lastMove)}에 두었어요.`); + } + if (v.phase === 'playing' && myTurn && (prevTurn.current !== v.turn || v.moveCount === 0) && v.moveCount !== prevCount.current) { + haptic(40); + if (document.visibilityState === 'hidden') sounds.myTurn(); + } + prevCount.current = v.moveCount; + prevTurn.current = v.turn; + setPreview(null); + }, [v.moveCount, v.turn]); // eslint-disable-line react-hooks/exhaustive-deps + + const canPlace = (p: Pt) => + myTurn && !pending && v.board[p.y * n + p.x] === 0 && !forbidden.has(p.y * n + p.x) && (!v.firstMoveCenter || v.moveCount > 0 || (p.x === (n - 1) / 2 && p.y === (n - 1) / 2)); + + const place = (p: Pt) => { + act({ type: 'place', x: p.x, y: p.y }); + setPreview(null); + }; + + const tap = (p: Pt) => { + setCursor(p); + if (!myTurn) return; + if (v.board[p.y * n + p.x] !== 0) return; + if (forbidden.has(p.y * n + p.x)) { + haptic([30, 30, 30]); + setAnnounce('여기는 금수 자리라 둘 수 없어요.'); + } + if (!tapConfirm) { + place(p); + return; + } + if (preview && preview.x === p.x && preview.y === p.y) place(p); + else setPreview(p); + }; + + const pointToCell = (e: React.PointerEvent): Pt | null => { + const svg = svgRef.current; + if (!svg) return null; + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse()); + const x = Math.round(loc.x); + const y = Math.round(loc.y); + return x >= 0 && y >= 0 && x < n && y < n ? { x, y } : null; + }; + + const onKey = (e: React.KeyboardEvent) => { + setKbd(true); + const d: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; + if (d[e.key]) { + e.preventDefault(); + setCursor((c) => ({ x: Math.min(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) })); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (canPlace(cursor)) place(cursor); + } + }; + + const winSet = new Set((v.winLine ?? []).map((p) => p.y * n + p.x)); + const incoming = + v.phase === 'playing' && v.myColor + ? v.undo.pending && v.undo.pending.by !== v.myColor + ? 'undo' + : v.draw.pendingBy && v.draw.pendingBy !== v.myColor + ? 'draw' + : null + : null; + const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor); + const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null; + const opp: Color | null = v.myColor ? (v.myColor === 'black' ? 'white' : 'black') : null; + + const pad = 0.6; + return ( +
+ +
+ { + setKbd(false); + const p = pointToCell(e); + if (p) tap(p); + }} + tabIndex={0} + role="grid" + aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`} + onKeyDown={onKey} + > + {Array.from({ length: n }, (_, i) => ( + + + + + ))} + {stars(n).map((p) => ( + + ))} + {myTurn && v.hints?.urgent.map((p) => )} + {myTurn && v.hints?.warn.map((p) => )} + {v.forbidden.map((p) => ( + + + + + ))} + {v.board.map((c, i) => + c === 0 ? null : , + )} + {v.lastMove && } + {[...winSet].map((i) => ( + + ))} + {preview && v.myColor && } + {myTurn && kbd && } + +
+

+ {announce} +

+ + {preview && myTurn && ( +
+ + +
+ )} + + + + {v.phase === 'playing' && v.myColor && ( +
+ {v.undo.allowed && ( + + )} + {v.draw.allowed && ( + + )} + +
+ )} + {outgoing && ( +
+ 상대의 답을 기다리는 중이에요… + +
+ )} + {incoming && ( + {}} label="상대의 요청"> +

{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}

+

{incoming === 'undo' ? '수락하면 돌이 되돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}

+
+ + +
+
+ )} + {confirm === 'resign' && ( + act({ type: 'resign' })} onClose={() => setConfirm(null)} /> + )} + {confirm === 'draw' && ( + act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} /> + )} +
+ ); +} + +function PlayerBar({ view: v, color, players, deadline, me }: { view: OmokView; color: Color; players: Record; deadline: number | null; me: string | null }) { + const id = v.players[color]; + const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const }; + const active = v.phase === 'playing' && v.turn === color; + const now = useServerNow(500); + const tc = v.timeControl; + let clock: React.ReactNode = null; + if (tc.kind === 'fischer') { + const ms = Math.max(0, v.remainingMs[color] - (active ? now - v.turnStartedAt : 0)); + const s = Math.ceil(ms / 1000); + clock = ( + + {Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} + + ); + } else if (active) { + const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : 300_000; + clock = ; + } + return ( +
+ + + + +
+
+ {u.nickname} + {id === me && (나)} +
+
{COLOR_KO[color]}{active ? ' · 두는 중' : ''}
+
+ {clock} +
+ ); +} + +function statusText(v: OmokView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요'; + if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요'; + if (v.myColor === v.turn) { + if (v.moveCount === 0 && v.firstMoveCenter) return '내 차례예요. 첫 수는 가운데에 둬요'; + return `내 차례예요 (${COLOR_KO[v.turn]}). 놓을 곳을 눌러 주세요`; + } + const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn]; + return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +const TIME_PRESETS: [string, string, Partial][] = [ + ['pm10', '한 수 10초', { kind: 'perMove', perMoveSec: 10 }], + ['pm20', '한 수 20초', { kind: 'perMove', perMoveSec: 20 }], + ['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }], + ['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }], + ['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }], + ['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }], + ['f10', '10분 + 5초', { kind: 'fischer', baseSec: 600, incSec: 5 }], + ['none', '시간 제한 없음', { kind: 'none' }], +]; + +function timeKey(tc: OmokOptions['timeControl']): string { + if (tc.kind === 'none') return 'none'; + if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`; + return `f${Math.round(tc.baseSec / 60)}`; +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+
+ 규칙 + set({ ruleSet })} /> +
+
+ 시간 + [k, l] as [string, string])} + onChange={(k) => set({ timeControl: { ...o.timeControl, ...TIME_PRESETS.find((p) => p[0] === k)![2] } })} + /> +
+
+ 흑(먼저 두는 쪽) + set({ colorAssignment })} + /> +
+ + {more && ( + <> +
+ 판 크기 + set({ boardSize })} /> +
+ {o.ruleSet === 'free' && ( +
+ 자유룰 승리 + set({ freeWin })} /> +
+ )} +
+ 무르기 + set({ undo })} /> +
+ {o.timeControl.kind === 'perMove' && ( +
+ 시간이 다 되면 + set({ timeoutPolicy })} /> +
+ )} +
+ 첫 수 가운데 + set({ firstMoveCenter })} /> +
+
+ 도움 표시 + set({ showForbidden: k === 'on' })} /> + set({ hints })} /> + set({ drawOffer: k === 'on' })} /> +
+ + )} +
+ ); +} + +function optionsSummary(o: OmokOptions): string { + const t = TIME_PRESETS.find((p) => p[0] === timeKey(o.timeControl))?.[1] ?? ''; + return [RULE_KO[o.ruleSet], `${o.boardSize}줄`, t].join(' · '); +} + +function Rules({ options }: { options?: OmokOptions }) { + const rule = options?.ruleSet ?? 'korean'; + return ( +
+

흑과 백이 번갈아 한 수씩 판의 빈 교차점에 돌을 놓아요. 가로, 세로, 대각선 중 한 줄로 내 돌 5개를 먼저 이으면 이겨요. 흑이 먼저 둬요.

+ {rule === 'korean' && ( +

+ 한국식: 흑과 백 모두 쌍삼(열린 3을 두 개 동시에 만드는 수)을 둘 수 없어요. 6개 이상 이어지면 이긴 것이 아니에요. +

+ )} + {rule === 'renju' && ( +

+ 렌주룰: 흑만 쌍삼, 사사(4를 두 개 동시에), 6목 이상을 둘 수 없어요. 백은 제한이 없고 6목 이상도 이겨요. 첫 수는 가운데에 둬요. +

+ )} + {rule === 'free' && ( +

+ 자유룰: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요. +

+ )} +

둘 수 없는 자리는 판에 빨간 ×로 표시돼요. 상대가 곧 이길 수 있는 자리는 빨간 동그라미, 위험한 자리는 노란 점선으로 알려 줘요(설정에서 끌 수 있어요).

+

휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.

+
+ ); +} + +const ui: GameUI = { + Board: OmokBoard, + statusText, + isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts new file mode 100644 index 0000000..0b663cb --- /dev/null +++ b/apps/web/src/games/registry.ts @@ -0,0 +1,22 @@ +import type { GameUI } from './types'; + +/** Game screens are code-split: each is downloaded only when that game is opened (docs/09 §6). */ +export const GAME_UI: Record Promise<{ default: GameUI }>> = { + omok: () => import('./omok'), +}; + +const cache = new Map(); + +export async function loadGameUI(id: string): Promise { + const hit = cache.get(id); + if (hit) return hit; + const loader = GAME_UI[id]; + if (!loader) return null; + const mod = (await loader()).default; + cache.set(id, mod); + return mod; +} + +export function cachedGameUI(id: string): GameUI | null { + return cache.get(id) ?? null; +} diff --git a/apps/web/src/games/types.ts b/apps/web/src/games/types.ts new file mode 100644 index 0000000..34829bc --- /dev/null +++ b/apps/web/src/games/types.ts @@ -0,0 +1,27 @@ +import type { ComponentType } from 'react'; +import type { PublicUser } from '@bg/shared/lite'; + +export interface BoardProps { + view: V; + me: string | null; + players: Record; + act: (a: unknown) => void; + pending: boolean; + deadline: number | null; + active: string[]; + tapConfirm: boolean; +} + +export interface OptionsProps { + value: O; + onChange: (next: O) => void; +} + +export interface GameUI { + Board: ComponentType>; + statusText(view: V, me: string | null, players: Record): string; + isMyTurn(view: V, me: string | null): boolean; + Options: ComponentType>; + optionsSummary(options: O): string; + Rules: ComponentType<{ options?: O }>; +} diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts new file mode 100644 index 0000000..8f1f62b --- /dev/null +++ b/apps/web/src/lib/catalog-ui.ts @@ -0,0 +1,13 @@ +export const GAME_ICON: Record = { + omok: '⚫', + baduk: '⚪', + chess: '♟️', + janggi: '🀄', + seotda: '🎴', + poker: '🃏', + mahjong: '🀇', + yut: '🪵', +}; + +/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ +export const UPCOMING = ['체스', '바둑', '장기', '오셀로', '섯다', '포커', '윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리']; diff --git a/apps/web/src/lib/feedback.ts b/apps/web/src/lib/feedback.ts new file mode 100644 index 0000000..b87be37 --- /dev/null +++ b/apps/web/src/lib/feedback.ts @@ -0,0 +1,48 @@ +/** Tiny synthesized sounds + vibration (no audio files; docs/07 §6). */ +import { useSession } from '../store/session'; + +let ctx: AudioContext | null = null; + +function audio(): AudioContext | null { + if (!useSession.getState().settings.sound) return null; + try { + ctx ??= new AudioContext(); + if (ctx.state === 'suspended') void ctx.resume(); + return ctx; + } catch { + return null; + } +} + +function tone(freq: number, ms: number, type: OscillatorType = 'sine', gain = 0.15, delay = 0): void { + const a = audio(); + if (!a) return; + const t = a.currentTime + delay / 1000; + const o = a.createOscillator(); + const g = a.createGain(); + o.type = type; + o.frequency.value = freq; + g.gain.setValueAtTime(gain, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + ms / 1000); + o.connect(g).connect(a.destination); + o.start(t); + o.stop(t + ms / 1000 + 0.02); +} + +export const sounds = { + stone: () => tone(900, 70, 'triangle', 0.25), + myTurn: () => { + tone(660, 120); + tone(880, 160, 'sine', 0.15, 120); + }, + warn: () => tone(520, 90, 'square', 0.06), + win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 180, 'triangle', 0.18, i * 120)), + lose: () => [392, 330].forEach((f, i) => tone(f, 220, 'sine', 0.15, i * 180)), +}; + +export function haptic(pattern: number | number[]): void { + if (!useSession.getState().settings.vibrate) return; + try { + navigator.vibrate?.(pattern); + } catch {} +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..ff196a9 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,64 @@ +import { StrictMode, Suspense, lazy, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router'; +import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css'; +import './styles/global.css'; +import { ConnectionBanner, Toasts } from './components/ui'; +import { useSession } from './store/session'; +import { toast } from './store/toast'; +import { Home } from './pages/Home'; + +const JoinPage = lazy(() => import('./pages/JoinPage')); +const RoomPage = lazy(() => import('./pages/RoomPage')); +const GamePage = lazy(() => import('./pages/GamePage')); +const MePage = lazy(() => import('./pages/MePage')); +const RulesPage = lazy(() => import('./pages/RulesPage')); +const NotFound = lazy(() => import('./pages/NotFound')); + +function Shell() { + const loaded = useSession((s) => s.loaded); + const nav = useNavigate(); + const loc = useLocation(); + useEffect(() => { + void useSession.getState().refresh(); + const onUnauthorized = () => void useSession.getState().refresh(); + window.addEventListener('bg:unauthorized', onUnauthorized); + return () => window.removeEventListener('bg:unauthorized', onUnauthorized); + }, []); + useEffect(() => { + const q = new URLSearchParams(loc.search); + if (q.get('linked') === '1') toast('디스코드 계정과 연결했어요.'); + if (q.get('login') === 'failed') toast('디스코드 로그인에 실패했어요. 다시 시도해 주세요.', 'error'); + if (q.has('linked') || q.has('login')) { + q.delete('linked'); + q.delete('login'); + nav({ pathname: loc.pathname, search: q.toString() }, { replace: true }); + } + }, [loc.search]); // eslint-disable-line react-hooks/exhaustive-deps + if (!loaded) return
불러오는 중…
; + return ( + <> + + + 불러오는 중…}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/apps/web/src/net/api.ts b/apps/web/src/net/api.ts new file mode 100644 index 0000000..3048be2 --- /dev/null +++ b/apps/web/src/net/api.ts @@ -0,0 +1,72 @@ +import type { CatalogEntry, PublicUser } from '@bg/shared/lite'; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +async function call(method: string, path: string, body?: unknown): Promise { + let res: Response; + try { + res = await fetch(path, { + method, + credentials: 'same-origin', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch { + throw new ApiError('인터넷 연결을 확인해 주세요.', 0); + } + const data = (await res.json().catch(() => ({}))) as { error?: string } & T; + if (!res.ok) throw new ApiError(data.error ?? '잠시 후 다시 시도해 주세요.', res.status); + return data; +} + +export interface Settings { + fontScale?: 'normal' | 'large' | 'xlarge'; + theme?: 'auto' | 'light' | 'dark'; + sound?: boolean; + vibrate?: boolean; + colorBlind?: boolean; + tapConfirm?: boolean; +} + +export interface MeResponse { + me: PublicUser | null; + settings?: Settings; + useAvatar?: boolean; + hasDiscordAvatar?: boolean; + activeRoom?: string | null; +} + +export interface RoomPreview { + code: string; + codeLabel: string; + gameId: string; + host: string; + seated: number; + maxPlayers: number; + status: string; +} + +export const api = { + config: () => call<{ discord: boolean; catalog: CatalogEntry[] }>('GET', '/api/config'), + me: () => call('GET', '/api/me'), + guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }), + updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch), + logout: () => call<{ ok: true }>('POST', '/api/auth/logout', {}), + logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}), + deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }), + stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'), + createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }), + quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}), + room: (code: string) => call('GET', `/api/rooms/${code}`), + publicRooms: (gameId: string) => + call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`), +}; + +export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`; diff --git a/apps/web/src/net/socket.ts b/apps/web/src/net/socket.ts new file mode 100644 index 0000000..60a32d2 --- /dev/null +++ b/apps/web/src/net/socket.ts @@ -0,0 +1,344 @@ +/** + * WebSocket client with auto-reconnect, seq ordering, ping/clock sync (docs/03 §4–§8). + * Room/game state lives in the zustand store below; components only read it. + */ +import { create } from 'zustand'; +import { PROTOCOL, type GameResultView, type PublicUser, type RoomView, type ServerMessage } from '@bg/shared/lite'; +import { toast } from '../store/toast'; + +export type ConnStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'replaced' | 'offline'; + +export interface ChatLine { + id: number; + from: PublicUser | null; + text: string; + at: number; +} + +export interface GameState { + seq: number; + view: unknown; + active: string[]; + deadline: number | null; + events: unknown[]; +} + +interface RoomStore { + status: ConnStatus; + me: PublicUser | null; + code: string | null; + room: RoomView | null; + roomSeq: number; + game: GameState | null; + result: GameResultView | null; + chat: ChatLine[]; + emotes: { id: number; from: string; emote: string }[]; + pending: number | null; + slow: boolean; + rttMs: number | null; + clockOffset: number; + joinError: string | null; + kicked: boolean; + needsRefresh: boolean; +} + +export const useRoom = create(() => ({ + status: 'idle', + me: null, + code: null, + room: null, + roomSeq: -1, + game: null, + result: null, + chat: [], + emotes: [], + pending: null, + slow: false, + rttMs: null, + clockOffset: 0, + joinError: null, + kicked: false, + needsRefresh: false, +})); + +const BACKOFF = [500, 1000, 2000, 4000, 8000]; +const MAX_BACKOFF = 10_000; +const PING_EVERY = 10_000; +const PONG_TIMEOUT = 5_000; + +class GameSocket { + private ws: WebSocket | null = null; + private attempt = 0; + private retryTimer: ReturnType | null = null; + private pingTimer: ReturnType | null = null; + private pongTimer: ReturnType | null = null; + private pendingTimer: ReturnType | null = null; + private offsets: number[] = []; + private wanted = false; + private cs = Date.now(); + private chatId = 1; + private onMessageHooks = new Set<(m: ServerMessage) => void>(); + + constructor() { + if (typeof window !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') this.kick(); + }); + window.addEventListener('online', () => this.kick()); + window.addEventListener('offline', () => useRoom.setState({ status: 'offline' })); + } + } + + /** Start (or keep) a connection. Safe to call repeatedly. */ + start(): void { + this.wanted = true; + if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return; + this.open(); + } + + stop(): void { + this.wanted = false; + this.clearTimers(); + this.ws?.close(); + this.ws = null; + useRoom.setState({ status: 'idle' }); + } + + /** Reconnect immediately if we should be connected but are not. */ + private kick(): void { + if (!this.wanted) return; + if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) { + if (this.retryTimer) clearTimeout(this.retryTimer); + this.retryTimer = null; + this.open(); + } else if (this.ws.readyState === WebSocket.OPEN) { + this.ping(); + } + } + + private open(): void { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${proto}//${location.host}/ws`); + this.ws = ws; + useRoom.setState({ status: this.attempt === 0 ? 'connecting' : 'reconnecting' }); + ws.onopen = () => { + this.attempt = 0; + this.pingTimer = setInterval(() => this.ping(), PING_EVERY); + }; + ws.onmessage = (e) => { + let m: ServerMessage; + try { + m = JSON.parse(String(e.data)); + } catch { + return; + } + this.handle(m); + for (const h of this.onMessageHooks) h(m); + }; + ws.onclose = (e) => { + if (this.ws !== ws) return; + this.clearTimers(); + this.ws = null; + const st = useRoom.getState().status; + if (!this.wanted || st === 'replaced' || useRoom.getState().kicked) return; + if (e.code === 4401 || e.code === 1008) { + // Not logged in (session expired): stop and let the page ask for a nickname. + useRoom.setState({ status: 'idle' }); + this.wanted = false; + window.dispatchEvent(new Event('bg:unauthorized')); + return; + } + if (this.attempt >= 1) { + // Repeated failures may mean the session is gone (the upgrade got a 401). + fetch('/api/me', { credentials: 'same-origin' }) + .then((r) => r.json()) + .then((d: { me: unknown }) => { + if (d.me === null) { + this.wanted = false; + useRoom.setState({ status: 'idle' }); + window.dispatchEvent(new Event('bg:unauthorized')); + } + }) + .catch(() => {}); + } + this.scheduleReconnect(); + }; + } + + private scheduleReconnect(extraDelay = 0): void { + const base = BACKOFF[this.attempt] ?? MAX_BACKOFF; + const jitter = base * (0.7 + Math.random() * 0.6); + this.attempt++; + useRoom.setState({ status: navigator.onLine === false ? 'offline' : 'reconnecting' }); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + if (this.wanted) this.open(); + }, jitter + extraDelay); + } + + private clearTimers(): void { + if (this.pingTimer) clearInterval(this.pingTimer); + if (this.pongTimer) clearTimeout(this.pongTimer); + this.pingTimer = null; + this.pongTimer = null; + } + + private ping(): void { + if (this.ws?.readyState !== WebSocket.OPEN || this.pongTimer) return; + this.send({ t: 'ping', ts: Date.now() }); + this.pongTimer = setTimeout(() => { + this.pongTimer = null; + // Dead connection: force a reconnect. + this.ws?.close(); + }, PONG_TIMEOUT); + } + + send(m: unknown): boolean { + if (this.ws?.readyState !== WebSocket.OPEN) return false; + this.ws.send(JSON.stringify(m)); + return true; + } + + subscribe(fn: (m: ServerMessage) => void): () => void { + this.onMessageHooks.add(fn); + return () => this.onMessageHooks.delete(fn); + } + + // ------------------------------------------------------------- room commands + + join(code: string, as: 'player' | 'spectator' = 'player'): void { + const cur = useRoom.getState(); + if (cur.code !== code) { + useRoom.setState({ code, room: null, roomSeq: -1, game: null, result: null, chat: [], joinError: null, kicked: false, pending: null }); + } + this.start(); + this.send({ t: 'join', code, as }); + } + + leave(): void { + this.send({ t: 'leave' }); + useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null }); + } + + /** Sends a game action; returns false when not connected. */ + act(a: unknown): boolean { + const cs = ++this.cs; + if (!this.send({ t: 'act', cs, a })) { + toast('연결이 끊겨 있어요. 다시 연결되면 해 주세요.', 'error'); + return false; + } + useRoom.setState({ pending: cs, slow: false }); + if (this.pendingTimer) clearTimeout(this.pendingTimer); + this.pendingTimer = setTimeout(() => { + if (useRoom.getState().pending === cs) { + useRoom.setState({ slow: true }); + this.ping(); + } + }, 2000); + return true; + } + + /** Server time estimate. */ + now(): number { + return Date.now() + useRoom.getState().clockOffset; + } + + // ------------------------------------------------------------- inbound + + private handle(m: ServerMessage): void { + const st = useRoom.getState(); + switch (m.t) { + case 'welcome': { + if (m.protocol !== PROTOCOL) useRoom.setState({ needsRefresh: true }); + useRoom.setState({ status: 'open', me: m.me }); + this.offsets = [m.serverTime - Date.now()]; + useRoom.setState({ clockOffset: this.offsets[0]! }); + // Re-join the room we were in (reconnect path). + if (st.code && !st.kicked) this.send({ t: 'join', code: st.code, as: 'player' }); + break; + } + case 'room': + if (m.seq >= st.roomSeq || m.room.code !== st.room?.code) { + useRoom.setState({ room: m.room, roomSeq: m.seq, joinError: null }); + if (m.room.status === 'playing' && st.result) useRoom.setState({ result: null }); + } + break; + case 'state': { + if (st.game && m.seq < st.game.seq) break; // stale + const patch: Partial = { + game: { seq: m.seq, view: m.view, active: m.active, deadline: m.deadline, events: m.events }, + }; + if (m.cs !== undefined && m.cs === st.pending) { + patch.pending = null; + patch.slow = false; + } + useRoom.setState(patch); + break; + } + case 'reject': + if (m.cs === st.pending) useRoom.setState({ pending: null, slow: false }); + toast(m.reason, 'error'); + break; + case 'result': + useRoom.setState({ result: m.result }); + break; + case 'chat': + useRoom.setState({ chat: [...st.chat, { id: this.chatId++, from: m.from, text: m.text, at: m.at }].slice(-100) }); + break; + case 'emote': { + const id = this.chatId++; + useRoom.setState({ emotes: [...st.emotes, { id, from: m.from, emote: m.id }].slice(-10) }); + setTimeout(() => useRoom.setState((s) => ({ emotes: s.emotes.filter((e) => e.id !== id) })), 2500); + break; + } + case 'notice': + toast(m.message); + break; + case 'error': + if (m.code === 'room-not-found' || m.code === 'join-failed') useRoom.setState({ joinError: m.message }); + else toast(m.message, 'error'); + break; + case 'pong': { + if (this.pongTimer) clearTimeout(this.pongTimer); + this.pongTimer = null; + const now = Date.now(); + const rtt = now - m.ts; + const offset = m.serverTime + rtt / 2 - now; + this.offsets = [...this.offsets, offset].slice(-5); + const sorted = [...this.offsets].sort((a, b) => a - b); + useRoom.setState({ rttMs: rtt, clockOffset: sorted[Math.floor(sorted.length / 2)]! }); + if (st.slow && st.pending) { + // Connection is alive but our action got no answer: resync instead of resending. + this.send({ t: 'sync' }); + useRoom.setState({ pending: null, slow: false }); + } + break; + } + case 'bye': + if (m.reason === 'restart') { + toast('서버를 업데이트하고 있어요. 잠시 후 자동으로 다시 연결돼요.'); + this.attempt = 0; + this.ws?.close(); + // onclose schedules a reconnect; add 1–3s so clients don't all return at once. + this.ws = null; + this.clearTimers(); + this.scheduleReconnect(1000 + Math.random() * 2000); + } else if (m.reason === 'replaced') { + useRoom.setState({ status: 'replaced' }); + } else if (m.reason === 'kicked') { + useRoom.setState({ kicked: true, joinError: '방장이 방에서 내보냈어요.' }); + } + break; + } + } + + /** "Use here" after another tab took over. */ + reclaim(): void { + useRoom.setState({ status: 'connecting' }); + this.wanted = true; + this.attempt = 0; + this.open(); + } +} + +export const socket = new GameSocket(); diff --git a/apps/web/src/pages/GamePage.tsx b/apps/web/src/pages/GamePage.tsx new file mode 100644 index 0000000..da460c9 --- /dev/null +++ b/apps/web/src/pages/GamePage.tsx @@ -0,0 +1,103 @@ +/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { catalogById, formatRoomCode } from '@bg/shared/lite'; +import { Sheet, TopBar } from '../components/ui'; +import { NicknameForm } from '../components/NicknameForm'; +import { api } from '../net/api'; +import { useSession } from '../store/session'; +import { toast } from '../store/toast'; +import { GAME_ICON } from '../lib/catalog-ui'; + +export default function GamePage() { + const { gameId = '' } = useParams(); + const g = catalogById(gameId); + const nav = useNavigate(); + const me = useSession((s) => s.me); + const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]); + const [needName, setNeedName] = useState void)>(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!g) return; + const load = () => api.publicRooms(gameId).then((r) => setRooms(r.rooms)).catch(() => {}); + load(); + const t = setInterval(load, 5000); + return () => clearInterval(t); + }, [gameId]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!g) return
없는 게임이에요.
; + const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn)); + const run = (p: () => Promise<{ code: string }>) => + withName(async () => { + setBusy(true); + try { + nav(`/r/${(await p()).code}`); + } catch (e) { + toast((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }); + + return ( + <> + nav('/')} /> +
+
+ + {GAME_ICON[g.id] ?? '🎲'} + +
+

{g.nameKo}

+

{g.blurb}

+

+ {g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · 약 {g.minutes}분 · {g.level} +

+
+
+
+ + + + 📖 규칙 보기 + +
+
+

기다리는 방

+ {rooms.length === 0 &&

지금 열린 공개 방이 없어요. [빠른 시작]을 누르면 새 방이 열려요.

} + {rooms.map((r) => ( +
+
+ {r.host}님의 방 +
+ {r.seated}/{r.max}명 · 코드 {formatRoomCode(r.code)} +
+
+ +
+ ))} +
+ {needName && ( + setNeedName(null)} label="이름 정하기"> +

먼저 이름을 정해 주세요

+ { + const fn = needName; + setNeedName(null); + fn(); + }} + /> +
+ )} +
+ + ); +} diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx new file mode 100644 index 0000000..4bde710 --- /dev/null +++ b/apps/web/src/pages/Home.tsx @@ -0,0 +1,109 @@ +/** 홈: 큰 버튼 두 개 + 진행 중 게임 + 게임 카드 (docs/06 §2). */ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { CATALOG } from '@bg/shared/lite'; +import { Avatar, Sheet } from '../components/ui'; +import { NicknameForm } from '../components/NicknameForm'; +import { api } from '../net/api'; +import { useSession } from '../store/session'; +import { toast } from '../store/toast'; +import { formatRoomCode } from '@bg/shared/lite'; +import { GAME_ICON, UPCOMING } from '../lib/catalog-ui'; + +export function Home() { + const { me, activeRoom } = useSession(); + const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG)); + const nav = useNavigate(); + const [picker, setPicker] = useState(false); + const [needName, setNeedName] = useState void)>(null); + const [busy, setBusy] = useState(false); + const available = catalog.filter((g) => g.available); + + const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn)); + + const create = (gameId: string) => + withName(async () => { + setBusy(true); + try { + const { code } = await api.createRoom(gameId); + nav(`/r/${code}`); + } catch (e) { + toast((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }); + + return ( +
+
+

같이 놀자 보드게임

+ + {me ? : '👤'} {me ? me.nickname : '내 정보'} + +
+ + {activeRoom && ( + + ▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)}) + + )} + +
+ + + 🔢 코드로 참여하기 + +
+ +
+

게임 고르기

+
+ {available.map((g) => ( + + + {GAME_ICON[g.id] ?? '🎲'} + + {g.nameKo} + {g.blurb} + + {g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · {g.minutes}분 + + + ))} +
+
+ +
+

곧 나와요

+

{UPCOMING.join(' · ')}

+
+ + {picker && ( + setPicker(false)} label="게임 고르기"> +

어떤 게임을 할까요?

+ {available.map((g) => ( + + ))} +
+ )} + {needName && ( + setNeedName(null)} label="이름 정하기"> +

먼저 이름을 정해 주세요

+ { + const fn = needName; + setNeedName(null); + fn(); + }} + /> +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/JoinPage.tsx b/apps/web/src/pages/JoinPage.tsx new file mode 100644 index 0000000..2d321cd --- /dev/null +++ b/apps/web/src/pages/JoinPage.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { CodeInput } from '../components/CodeInput'; +import { TopBar } from '../components/ui'; +import { api } from '../net/api'; + +export default function JoinPage() { + const nav = useNavigate(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [key, setKey] = useState(0); + const go = async (code: string) => { + setBusy(true); + setError(null); + try { + await api.room(code); + nav(`/r/${code}`); + } catch (e) { + setError((e as Error).message); + setKey((k) => k + 1); // reset the boxes + } finally { + setBusy(false); + } + }; + return ( + <> + nav('/')} /> +
+

방 코드 6자리를 눌러 주세요

+

방을 만든 사람에게 받은 숫자예요. 다 누르면 바로 들어가요.

+ + {error &&

{error}

} +
+ + ); +} diff --git a/apps/web/src/pages/MePage.tsx b/apps/web/src/pages/MePage.tsx new file mode 100644 index 0000000..d6ca8e0 --- /dev/null +++ b/apps/web/src/pages/MePage.tsx @@ -0,0 +1,206 @@ +/** 계정 페이지 (docs/05 §7). */ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet, TopBar } from '../components/ui'; +import { NicknameForm } from '../components/NicknameForm'; +import { api, discordLoginUrl } from '../net/api'; +import { useSession } from '../store/session'; +import { toast } from '../store/toast'; + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
+ {label} +
+ {options.map(([k, l]) => ( + + ))} +
+
+ ); +} + +export default function MePage() { + const nav = useNavigate(); + const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings } = useSession(); + const [editing, setEditing] = useState(false); + const [nick, setNick] = useState(me?.nickname ?? ''); + const [error, setError] = useState(null); + const [stats, setStats] = useState<{ gameId: string; played: number; wins: number; draws: number }[]>([]); + const [confirm, setConfirm] = useState(null); + + useEffect(() => { + if (me) api.stats().then((r) => setStats(r.stats)).catch(() => {}); + }, [me]); + + const saveNick = async () => { + const n = normalizeNickname(nick); + const e = nicknameError(n); + if (e) return setError(e); + try { + const r = await api.updateMe({ nickname: n }); + useSession.getState().setMe(r.me); + setEditing(false); + toast('닉네임을 바꿨어요.'); + } catch (ex) { + setError((ex as Error).message); + } + }; + + const display = ( +
+

화면 설정

+ updateSettings({ fontScale })} /> + updateSettings({ theme })} /> + updateSettings({ sound: v === 'on' })} /> + updateSettings({ vibrate: v === 'on' })} /> + updateSettings({ tapConfirm: v === 'on' })} + /> +
+ ); + + if (!me) { + return ( + <> + nav('/')} /> +
+
+

시작하기

+ +
+ {display} +
+ + ); + } + + return ( + <> + nav('/')} /> +
+
+ + {editing ? ( +
+ (setNick(e.target.value), setError(null))} maxLength={20} autoFocus aria-label="새 닉네임" /> + {error &&

{error}

} +
+ + +
+

게임 중에 바꾸면 그 게임이 끝난 뒤에 보여요.

+
+ ) : ( + <> +

{me.nickname}

+ + + )} +

{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}

+ {me.kind === 'guest' && discordEnabled && ( + + 디스코드 연결하기 (기록을 안전하게 보관) + + )} + {me.kind === 'member' && hasDiscordAvatar && ( + + )} +
+ + {display} + +
+

내 기록

+ {stats.length === 0 ? ( +

아직 끝까지 한 게임이 없어요.

+ ) : ( + stats.map((s) => ( +
+ {catalogById(s.gameId)?.nameKo ?? s.gameId} + + {s.played}판 {s.wins}승{s.draws ? ` ${s.draws}무` : ''} + +
+ )) + )} +
+ +
+ + {me.kind === 'member' && ( + + )} + +
+ + {confirm === 'logout' && ( + { + await api.logout(); + await useSession.getState().refresh(); + nav('/'); + }} + onClose={() => setConfirm(null)} + /> + )} + {confirm === 'logoutAll' && ( + { + await api.logoutAll(); + await useSession.getState().refresh(); + nav('/'); + }} + onClose={() => setConfirm(null)} + /> + )} + {confirm === 'delete' && ( + { + await api.deleteMe(); + await useSession.getState().refresh(); + nav('/'); + }} + onClose={() => setConfirm(null)} + /> + )} +
+ + ); +} diff --git a/apps/web/src/pages/NotFound.tsx b/apps/web/src/pages/NotFound.tsx new file mode 100644 index 0000000..a7bca7b --- /dev/null +++ b/apps/web/src/pages/NotFound.tsx @@ -0,0 +1,12 @@ +import { Link } from 'react-router'; + +export default function NotFound() { + return ( +
+

페이지를 찾을 수 없어요

+ + 홈으로 + +
+ ); +} diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx new file mode 100644 index 0000000..981e669 --- /dev/null +++ b/apps/web/src/pages/RoomPage.tsx @@ -0,0 +1,621 @@ +/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { create } from 'zustand'; +import { useNavigate, useParams } from 'react-router'; +import { CHAT_MAX, EMOTE_LABEL, EMOTES, catalogById, formatRoomCode, 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 { 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'; + +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 ? ( + <> +

+ {catalogById(preview.gameId)?.nameKo ?? '게임'} 방에 초대받았어요 +

+

+ 방장 {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 = catalogById(room.gameId); + const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game); + const isHost = room.hostId === me.id; + const doLeave = () => { + socket.leave(); + nav('/'); + }; + + return ( + <> + + {g?.nameKo} · + + } + onBack={() => nav('/')} + right={ + <> + setChatOpen(true)} /> + + + } + /> + {needsRefresh && ( +
+ 새 버전이 있어요. + +
+ )} + {inGame && game ? ( + + ) : ( + + )} + {room.status === 'finished' && result && game && } + setChatOpen(false)} /> + {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 gameName = catalogById(room.gameId)?.nameKo ?? '게임'; + const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname; + const shareText = `${host}님이 ${gameName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`; + const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length; + const [askStart, setAskStart] = useState(false); + + return ( +
+
+

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

+

+ +

+
+ + + + +
+
+ +
+

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

+ {room.seats.map((s, i) => + 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 && } +
+ ) : ( + + ), + )} + {room.spectators.length > 0 &&

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

} + {mySeat >= 0 && room.allowSpectators && ( + + )} +
+ +
+
+
+

게임 설정

+

{ui.optionsSummary(room.options)}

+
+ {isHost && ( + + )} +
+

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

+
+ +
+
+ {isHost ? ( + + ) : mySeat >= 0 ? ( + room.seats[mySeat]!.ready ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ + {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 }) { + const [options, setOptions] = useState(room.options); + const [visibility, setVisibility] = useState(room.visibility); + const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators); + const [chatEnabled, setChatEnabled] = useState(room.chatEnabled); + const save = () => { + socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled }); + onClose(); + }; + const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => ( +
+ + +
+ ); + return ( + +

게임 설정

+ +
+ 방 공개 + {seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나(공개)', '친구만')} + 관전 + {seg(allowSpectators, setAllowSpectators, '관전 허용', '관전 안 됨')} + 채팅 + {seg(chatEnabled, setChatEnabled, '채팅 켜기', '채팅 끄기')} +
+ + +
+
+ ); +} + +// ---------------------------------------------------------------- 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 myTurn = ui.isMyTurn(game.view, me.id); + const isPlayer = room.seats.some((s) => s?.user.id === me.id) && game.active !== undefined; + return ( +
+
+ {room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)} + {slow && · 연결 확인 중…} + {!isPlayer && 관전 중} +
+ socket.act(a)} + pending={pending} + deadline={game.deadline} + active={game.active} + tapConfirm={tapConfirm} + /> +
+ ); +} + +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 seated = room.seats.filter(Boolean).map((s) => s!.user.id); + const amSeated = seated.includes(me.id); + const voted = room.rematchVotes.includes(me.id); + const allFirst = result.ranking.length === 1; + const won = !allFirst && result.ranking[0]?.includes(me.id); + const lost = !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}

+
+ {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.lastSeed && ( +
+ 공정성 확인 +

시작할 때 공개한 값: {room.seedHash ?? '-'}

+

이번 판의 난수 씨앗: {room.lastSeed}

+

씨앗의 SHA-256이 위 값과 같으면 서버가 게임 도중 난수를 바꾸지 않았다는 뜻이에요.

+
+ )} +
+ ); +} + +// ---------------------------------------------------------------- chat + +/** Unread counter shared between the top-bar button and the chat sheet. */ +const useChatSeen = create<{ seen: number }>(() => ({ seen: 0 })); + +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 ( + + ); +} + +function ChatDock({ room, me, open, onClose }: { room: RoomView; me: PublicUser; open: boolean; onClose: () => void }) { + const chat = useRoom((s) => s.chat); + const emotes = useRoom((s) => s.emotes); + const setOpen = (v: boolean) => !v && onClose(); + const [text, setText] = useState(''); + const listRef = useRef(null); + const [hiddenUsers, setHiddenUsers] = useState>(new Set()); + useEffect(() => { + if (open) { + useChatSeen.setState({ seen: chat.length }); + listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); + } + }, [open, chat.length]); + const nick = (id: string) => room.seats.find((s) => s?.user.id === id)?.user.nickname ?? room.spectators.find((u) => u.id === id)?.nickname ?? ''; + return ( + <> +
+ {emotes.map((e) => ( +
+ {nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''} +
+ ))} +
+ {open && ( + setOpen(false)} label="채팅"> +

채팅

+
+ {EMOTES.map((id) => ( + + ))} +
+
+ {chat.length === 0 &&

아직 메시지가 없어요.

} + {chat + .filter((m) => !m.from || !hiddenUsers.has(m.from.id)) + .map((m) => ( +
+ {m.from?.nickname ?? '알림'}{' '} + {m.from && m.from.id !== me.id && ( + + )} +
{m.text}
+
+ ))} +
+ {room.chatEnabled ? ( +
{ + e.preventDefault(); + if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) }); + setText(''); + }} + > + setText(e.target.value)} maxLength={CHAT_MAX} placeholder="메시지 입력" aria-label="메시지" enterKeyHint="send" /> + +
+ ) : ( +

이 방은 채팅을 쓰지 않아요. 위의 반응 버튼은 쓸 수 있어요.

+ )} +
+ )} + + ); +} diff --git a/apps/web/src/pages/RulesPage.tsx b/apps/web/src/pages/RulesPage.tsx new file mode 100644 index 0000000..eec4b1e --- /dev/null +++ b/apps/web/src/pages/RulesPage.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { catalogById } from '@bg/shared/lite'; +import { TopBar } from '../components/ui'; +import { loadGameUI } from '../games/registry'; +import type { GameUI } from '../games/types'; + +export default function RulesPage() { + const { gameId = '' } = useParams(); + const nav = useNavigate(); + const [ui, setUi] = useState(undefined); + useEffect(() => { + loadGameUI(gameId).then(setUi); + }, [gameId]); + const g = catalogById(gameId); + return ( + <> + nav(-1)} /> +
+ {ui === undefined ?

불러오는 중…

: ui ? :

아직 준비 중인 게임이에요.

} +
+ + ); +} diff --git a/apps/web/src/store/session.ts b/apps/web/src/store/session.ts new file mode 100644 index 0000000..2b47904 --- /dev/null +++ b/apps/web/src/store/session.ts @@ -0,0 +1,88 @@ +/** Who am I + display settings (docs/05 §7, docs/07 §2). */ +import { create } from 'zustand'; +import type { CatalogEntry, PublicUser } from '@bg/shared/lite'; +import { api, type Settings } from '../net/api'; + +const LS_KEY = 'bg.settings'; + +export const DEFAULT_SETTINGS: Required = { + fontScale: 'normal', + theme: 'auto', + sound: true, + vibrate: true, + colorBlind: false, + tapConfirm: true, +}; + +function loadLocal(): Required { + try { + return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(LS_KEY) ?? '{}') }; + } catch { + return DEFAULT_SETTINGS; + } +} + +export function applySettings(s: Required): void { + const html = document.documentElement; + html.dataset.fontScale = s.fontScale; + html.dataset.theme = s.theme; + html.dataset.colorBlind = String(s.colorBlind); + const dark = s.theme === 'dark' || (s.theme === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches); + document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#16181d' : '#F7F5F0'); +} + +interface SessionState { + loaded: boolean; + me: PublicUser | null; + activeRoom: string | null; + discordEnabled: boolean; + catalog: CatalogEntry[]; + settings: Required; + useAvatar: boolean; + hasDiscordAvatar: boolean; + refresh(): Promise; + setMe(me: PublicUser | null): void; + updateSettings(patch: Settings): void; +} + +export const useSession = create((set, get) => ({ + loaded: false, + me: null, + activeRoom: null, + discordEnabled: false, + catalog: [], + settings: loadLocal(), + useAvatar: true, + hasDiscordAvatar: false, + async refresh() { + const [cfg, me] = await Promise.all([ + api.config().catch(() => ({ discord: false, catalog: [] as CatalogEntry[] })), + api.me().catch(() => ({ me: null }) as Awaited>), + ]); + const settings = { ...get().settings, ...(me.settings ?? {}) }; + applySettings(settings); + localStorage.setItem(LS_KEY, JSON.stringify(settings)); + set({ + loaded: true, + me: me.me, + activeRoom: me.activeRoom ?? null, + discordEnabled: cfg.discord, + catalog: cfg.catalog, + settings, + useAvatar: me.useAvatar ?? true, + hasDiscordAvatar: me.hasDiscordAvatar ?? false, + }); + }, + setMe(me) { + set({ me }); + }, + updateSettings(patch) { + const settings = { ...get().settings, ...patch }; + applySettings(settings); + localStorage.setItem(LS_KEY, JSON.stringify(settings)); + set({ settings }); + if (get().me) api.updateMe({ settings }).catch(() => {}); + }, +})); + +applySettings(loadLocal()); diff --git a/apps/web/src/store/toast.ts b/apps/web/src/store/toast.ts new file mode 100644 index 0000000..6871f57 --- /dev/null +++ b/apps/web/src/store/toast.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +export interface Toast { + id: number; + text: string; + kind: 'info' | 'error'; +} + +let nextId = 1; + +export const useToasts = create<{ toasts: Toast[] }>(() => ({ toasts: [] })); + +export function toast(text: string, kind: Toast['kind'] = 'info', ms = 3000): void { + const id = nextId++; + useToasts.setState((s) => ({ toasts: [...s.toasts.filter((t) => t.text !== text), { id, text, kind }].slice(-3) })); + setTimeout(() => useToasts.setState((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), ms); +} diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css new file mode 100644 index 0000000..098aaf1 --- /dev/null +++ b/apps/web/src/styles/global.css @@ -0,0 +1,530 @@ +/* Design tokens: docs/07-ui-ux.md §2 */ +:root { + --bg: #f7f5f0; + --surface: #ffffff; + --surface-2: #efece4; + --text: #1c1e21; + --text-sub: #4a4f57; + --border: #d9d5cb; + --primary: #2563eb; + --primary-press: #1d4fd0; + --primary-text: #ffffff; + --primary-soft: #dbe7ff; + --success: #15803d; + --success-soft: #dcfce7; + --warning: #b45309; + --warning-soft: #fef3c7; + --danger: #b91c1c; + --danger-soft: #fee2e2; + --felt: #1f6b4a; + --wood: #e3b76b; + --wood-line: #7a5a22; + --focus: #f59e0b; + --shadow: 0 2px 10px rgba(0, 0, 0, 0.08); + --radius: 12px; + --radius-lg: 16px; + --tap: 48px; + --btn-h: 56px; + color-scheme: light; +} + +:root[data-theme='dark'] { + --bg: #16181d; + --surface: #20232a; + --surface-2: #2a2e37; + --text: #f2f3f5; + --text-sub: #b8bcc4; + --border: #3a3f4a; + --primary: #5b8def; + --primary-press: #4a7de0; + --primary-text: #0b1020; + --primary-soft: #1e2a44; + --success: #4ade80; + --success-soft: #133222; + --warning: #fbbf24; + --warning-soft: #3a2c0c; + --danger: #f87171; + --danger-soft: #3d1717; + --felt: #17513a; + --wood: #b98c45; + --wood-line: #4a3612; + --shadow: 0 2px 10px rgba(0, 0, 0, 0.4); + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root[data-theme='auto'] { + --bg: #16181d; + --surface: #20232a; + --surface-2: #2a2e37; + --text: #f2f3f5; + --text-sub: #b8bcc4; + --border: #3a3f4a; + --primary: #5b8def; + --primary-press: #4a7de0; + --primary-text: #0b1020; + --primary-soft: #1e2a44; + --success: #4ade80; + --success-soft: #133222; + --warning: #fbbf24; + --warning-soft: #3a2c0c; + --danger: #f87171; + --danger-soft: #3d1717; + --felt: #17513a; + --wood: #b98c45; + --wood-line: #4a3612; + --shadow: 0 2px 10px rgba(0, 0, 0, 0.4); + color-scheme: dark; + } +} + +/* Font scale setting: 보통 / 크게 / 아주 크게. Everything else is in rem. */ +html { + font-size: 106.25%; /* 17px */ +} +html[data-font-scale='large'] { + font-size: 127.5%; +} +html[data-font-scale='xlarge'] { + font-size: 148.75%; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif; + font-weight: 500; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; + word-break: keep-all; + overflow-wrap: anywhere; +} + +h1, +h2, +h3 { + margin: 0; + font-weight: 700; + line-height: 1.3; +} +h1 { + font-size: 1.65rem; +} +h2 { + font-size: 1.3rem; +} +h3 { + font-size: 1.1rem; +} +p { + margin: 0; +} + +button, +input, +select { + font: inherit; + color: inherit; +} + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +/* ---------- layout ---------- */ +.page { + max-width: 960px; + margin: 0 auto; + padding: 16px 16px calc(120px + env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + gap: 20px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.card { + background: var(--surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 16px; +} + +.muted { + color: var(--text-sub); +} +.small { + font-size: 0.9rem; +} + +/* ---------- buttons ---------- */ +.btn { + min-height: var(--btn-h); + min-width: var(--tap); + padding: 0 20px; + border-radius: var(--radius); + border: 2px solid transparent; + font-size: 1.06rem; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: transform 0.08s ease, background 0.15s ease; + text-decoration: none; + user-select: none; +} +.btn:active:not(:disabled) { + transform: scale(0.98); +} +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.btn-primary { + background: var(--primary); + color: var(--primary-text); +} +.btn-primary:hover:not(:disabled) { + background: var(--primary-press); +} +.btn-secondary { + background: var(--surface); + color: var(--text); + border-color: var(--border); +} +.btn-soft { + background: var(--primary-soft); + color: var(--text); +} +.btn-danger { + background: var(--surface); + color: var(--danger); + border-color: var(--danger); +} +.btn-success { + background: var(--success); + color: #fff; +} +.btn-block { + width: 100%; +} +.btn-sm { + min-height: var(--tap); + padding: 0 14px; + font-size: 0.95rem; +} +.btn-ghost { + background: transparent; + color: var(--text); + min-height: var(--tap); + padding: 0 12px; +} + +/* ---------- inputs ---------- */ +.input { + min-height: var(--btn-h); + width: 100%; + border-radius: var(--radius); + border: 2px solid var(--border); + background: var(--surface); + padding: 0 16px; + font-size: 1.1rem; +} +.input:focus { + border-color: var(--primary); + outline: none; +} +.field-error { + color: var(--danger); + font-size: 0.95rem; +} + +label.field { + display: flex; + flex-direction: column; + gap: 6px; + font-weight: 700; +} + +.segmented { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.segmented button { + min-height: var(--tap); + padding: 0 14px; + border-radius: 999px; + border: 2px solid var(--border); + background: var(--surface); + cursor: pointer; + font-weight: 600; +} +.segmented button[aria-pressed='true'] { + border-color: var(--primary); + background: var(--primary-soft); + font-weight: 700; +} + +/* ---------- top bar ---------- */ +.topbar { + position: sticky; + top: 0; + z-index: 20; + background: var(--bg); + display: flex; + align-items: center; + gap: 8px; + min-height: 56px; + padding: 4px 8px; + padding-top: calc(4px + env(safe-area-inset-top)); + border-bottom: 1px solid var(--border); +} +.topbar-title { + flex: 1; + font-weight: 700; + font-size: 1.05rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------- bottom action bar ---------- */ +.bottom-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 15; + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(6px); + border-top: 1px solid var(--border); + padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); +} +.bottom-bar-inner { + max-width: 960px; + margin: 0 auto; + display: flex; + gap: 8px; +} +.bottom-bar-inner > * { + flex: 1; +} + +/* ---------- banners & toasts ---------- */ +.conn-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 50; + background: var(--warning); + color: #fff; + text-align: center; + padding: 8px 12px; + padding-top: calc(8px + env(safe-area-inset-top)); + font-weight: 700; +} + +.toasts { + position: fixed; + top: calc(64px + env(safe-area-inset-top)); + left: 50%; + transform: translateX(-50%); + z-index: 60; + display: flex; + flex-direction: column; + gap: 8px; + width: min(92vw, 420px); + pointer-events: none; +} +.toast { + background: var(--text); + color: var(--bg); + border-radius: var(--radius); + padding: 12px 16px; + font-weight: 600; + box-shadow: var(--shadow); + animation: toast-in 0.2s ease; +} +.toast.error { + background: var(--danger); + color: #fff; +} +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(-8px); + } +} + +/* ---------- sheets / modals ---------- */ +.sheet-backdrop { + position: fixed; + inset: 0; + z-index: 40; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: flex-end; + justify-content: center; + animation: fade 0.15s ease; +} +@media (min-width: 720px) { + .sheet-backdrop { + align-items: center; + } +} +.sheet { + background: var(--surface); + width: min(100%, 520px); + max-height: 88vh; + overflow: auto; + border-radius: 20px 20px 0 0; + padding: 20px 16px calc(20px + env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + gap: 16px; + animation: sheet-up 0.2s ease; +} +@media (min-width: 720px) { + .sheet { + border-radius: 20px; + } +} +@keyframes sheet-up { + from { + transform: translateY(24px); + opacity: 0; + } +} +@keyframes fade { + from { + opacity: 0; + } +} + +/* ---------- avatar ---------- */ +.avatar { + border-radius: 50%; + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + overflow: hidden; +} +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 999px; + padding: 2px 10px; + font-size: 0.85rem; + font-weight: 700; + background: var(--surface-2); +} +.badge.success { + background: var(--success-soft); + color: var(--success); +} +.badge.warning { + background: var(--warning-soft); + color: var(--warning); +} +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +/* Icon + small label stacked (keeps text with icons but saves width on phones). */ +.icon-btn { + min-width: var(--tap); + min-height: var(--tap); + padding: 2px 8px; + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; + border-radius: var(--radius); + font-size: 1.25rem; + line-height: 1.1; + position: relative; +} +.icon-btn .lbl { + font-size: 0.7rem; + font-weight: 700; + color: var(--text-sub); +} +.icon-btn .badge { + position: absolute; + top: 0; + right: 0; + padding: 0 6px; + font-size: 0.7rem; +} +.sheet:focus { + outline: none; +} +.board-wrap { + position: relative; + width: 100%; + max-width: min(100%, calc(100vh - 300px)); + min-width: min(100%, 320px); + align-self: center; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..4205d7e --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..a08e545 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:3000', + '/healthz': 'http://localhost:3000', + '/ws': { target: 'ws://localhost:3000', ws: true }, + }, + }, + build: { + target: 'es2022', + cssCodeSplit: true, + sourcemap: false, + }, +}); diff --git a/bun.lock b/bun.lock index 6105987..09a905a 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "boardgame-site", "devDependencies": { "@types/bun": "1.4.2", + "playwright-core": "1.63.0", "typescript": "5.9.3", }, }, @@ -25,7 +26,9 @@ "name": "@bg/web", "version": "0.0.0", "dependencies": { + "@bg/games": "workspace:*", "@bg/shared": "workspace:*", + "pretendard": "1.3.9", "qrcode-generator": "2.0.4", "react": "19.3.0", "react-dom": "19.3.0", @@ -163,8 +166,12 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "pretendard": ["pretendard@1.3.9", "", {}, "sha512-PaQAADyLY5v4kYFwkpSJHbSSYIkiriY/1xXw75TKoZ9UQQqeU+tvP05yTdZAWibiIYoo8ZKtRv8PM7w0IaywSw=="], + "qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="], "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..aab6750 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,9 @@ +# Copy to deploy/.env and fill in. Never commit the real file. +SITE_DOMAIN=game.example.com +PUBLIC_ORIGIN=https://game.example.com +# 32+ random characters: openssl rand -base64 48 +SESSION_SECRET= +# Discord developer portal → OAuth2 (redirect: https:///api/auth/discord/callback). Leave empty to hide Discord login. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +LOG_LEVEL=info diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..b0a2afb --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,17 @@ +{$SITE_DOMAIN} { + encode zstd gzip + + # Internal metrics/health detail must not be public. + respond /internal/* 404 + + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Content-Security-Policy "default-src 'self'; img-src 'self' https://cdn.discordapp.com data:; connect-src 'self' wss://{$SITE_DOMAIN}; style-src 'self' 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + -Server + } + + # The app serves the built web assets with long-lived caching for /assets/* and no-cache for index.html. + reverse_proxy app:3000 +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..ca33b7f --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,35 @@ +# Multi-stage build: install → build web → slim runtime (docs/11-deployment-ops.md) +FROM oven/bun:1.3 AS build +WORKDIR /app +COPY package.json bun.lock bunfig.toml tsconfig.base.json ./ +COPY packages ./packages +COPY apps ./apps +RUN bun install --frozen-lockfile +RUN cd apps/web && bunx vite build + +# Production-only dependencies (no vite/typescript/playwright in the runtime image). +FROM oven/bun:1.3 AS deps +WORKDIR /app +COPY package.json bun.lock bunfig.toml ./ +COPY packages/engine/package.json packages/engine/ +COPY packages/shared/package.json packages/shared/ +COPY packages/games/package.json packages/games/ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +RUN bun install --frozen-lockfile --production --filter '@bg/server' + +FROM oven/bun:1.3-slim +WORKDIR /app +ENV NODE_ENV=production PORT=3000 DB_PATH=/data/app.db TRUST_PROXY=1 +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/package.json ./package.json +COPY --from=build /app/packages ./packages +COPY --from=build /app/apps/server ./apps/server +COPY --from=build /app/apps/web/dist ./apps/web/dist +RUN mkdir -p /data && chown bun:bun /data +USER bun +VOLUME ["/data"] +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD bun -e "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +STOPSIGNAL SIGTERM +CMD ["bun", "apps/server/src/index.ts"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..d698ddc --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,33 @@ +services: + app: + build: + context: .. + dockerfile: deploy/Dockerfile + image: boardgame-site:${APP_VERSION:-latest} + restart: unless-stopped + env_file: .env + environment: + TRUST_PROXY: "1" + volumes: + - app-data:/data + stop_grace_period: 15s + logging: + driver: json-file + options: { max-size: "50m", max-file: "5" } + + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + environment: + SITE_DOMAIN: ${SITE_DOMAIN:?set SITE_DOMAIN in deploy/.env} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + depends_on: [app] + +volumes: + app-data: + caddy-data: diff --git a/docs/03-realtime-protocol.md b/docs/03-realtime-protocol.md index 6c25d41..5fe7691 100644 --- a/docs/03-realtime-protocol.md +++ b/docs/03-realtime-protocol.md @@ -32,6 +32,7 @@ | `chat` | `text`(1~200자) | 채팅 | | `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) | | `rematch` | — | 게임 끝난 뒤 "한 판 더" 투표 | +| `void` | — | 방장: 오류로 멈춘 게임을 무효로 끝내기 | | `sync` | — | 전체 상태 다시 요청 | | `ping` | `ts` | 지연 측정(10초마다) | diff --git a/e2e/omok.e2e.ts b/e2e/omok.e2e.ts new file mode 100644 index 0000000..dacd0e5 --- /dev/null +++ b/e2e/omok.e2e.ts @@ -0,0 +1,129 @@ +/** + * E2E (docs/10 §2): guest start → create room → second user joins by link → omok to the end. + * Run: bun e2e/omok.e2e.ts (needs apps/web/dist built) + */ +import { chromium, type Page } from 'playwright-core'; +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from '../apps/server/src/config'; +import { startServer } from '../apps/server/src/server'; + +const PORT = Number(process.env.E2E_PORT ?? 4517); +const ORIGIN = `http://localhost:${PORT}`; +const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); +mkdirSync(SHOTS, { recursive: true }); + +const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')); +const app = startServer({ + config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }), + log: () => {}, + staticDir: join(import.meta.dir, '../apps/web/dist'), +}); + +const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); +let failed = false; +const step = async (name: string, fn: () => Promise) => { + const t0 = performance.now(); + try { + await fn(); + console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`); + } catch (e) { + failed = true; + console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`); + throw e; + } +}; + +async function tapCell(page: Page, x: number, y: number, n = 15) { + const box = (await page.locator('svg[role="grid"]').boundingBox())!; + const span = n - 1 + 1.2; + const px = box.x + ((x + 0.6) / span) * box.width; + const py = box.y + ((y + 0.6) / span) * box.height; + await page.mouse.click(px, py); + await page.waitForTimeout(60); + await page.mouse.click(px, py); // second tap confirms (tap-confirm mode) +} + +try { + const ctxA = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' }); + const ctxB = await browser.newContext({ viewport: { width: 1280, height: 860 }, locale: 'ko-KR' }); + const a = await ctxA.newPage(); + const b = await ctxB.newPage(); + const errors: string[] = []; + for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message)); + + let code = ''; + await step('A: 홈 → 방 만들기 → 닉네임 → 대기실', async () => { + await a.goto(ORIGIN + '/'); + await a.getByRole('button', { name: '+ 방 만들기' }).click(); + await a.getByLabel('내 이름(닉네임)').fill('호랑이'); + await a.getByRole('button', { name: '이 이름으로 시작하기' }).click(); + await a.waitForURL(/\/r\/\d{6}$/); + code = a.url().slice(-6); + await a.getByText('친구에게 이 코드를 알려 주세요').waitFor(); + await a.screenshot({ path: join(SHOTS, '1-lobby-mobile.png') }); + }); + + await step('B: 링크로 입장 → 닉네임 → 자동 착석', async () => { + await b.goto(`${ORIGIN}/r/${code}`); + await b.getByText('방에 초대받았어요').waitFor(); + await b.getByLabel('내 이름(닉네임)').fill('고양이'); + await b.getByRole('button', { name: '이 이름으로 입장하기' }).click(); + await b.getByText('고양이').first().waitFor(); + await a.getByText('고양이').waitFor(); + }); + + await step('A: 설정(방장 흑, 자유룰) → 시작', async () => { + await a.getByRole('button', { name: '설정 바꾸기' }).click(); + await a.getByRole('button', { name: '자유룰' }).click(); + await a.getByRole('button', { name: '방장이 흑' }).click(); + await a.getByRole('button', { name: '저장' }).click(); + await a.getByText('자유룰 · 15줄').waitFor(); + await a.getByRole('button', { name: '▶ 게임 시작' }).click(); + await a.getByRole('button', { name: '시작하기' }).click(); + await a.getByText('내 차례예요').waitFor(); + await b.getByText('호랑이님이 생각 중이에요').waitFor(); + }); + + await step('대국: 흑 가로 5목', async () => { + for (let i = 0; i < 4; i++) { + await tapCell(a, 5 + i, 7); + await b.getByText('내 차례예요').waitFor(); + await tapCell(b, 5 + i, 3); + await a.getByText('내 차례예요').waitFor(); + if (i === 1) await a.screenshot({ path: join(SHOTS, '2-game-mobile.png') }); + } + }); + + await step('B: 새로고침해도 판이 그대로', async () => { + await b.reload(); + await b.getByText('호랑이님이 생각 중이에요').waitFor(); + const stones = await b.locator('svg[role="grid"] circle[r="0.46"]').count(); + if (stones !== 8) throw new Error(`expected 8 stones after reload, got ${stones}`); + }); + + await step('흑 승리 → 결과 화면', async () => { + await tapCell(a, 9, 7); + await a.getByText('🎉 이겼어요!').waitFor(); + await b.getByText('아쉽게 졌어요').waitFor(); + await a.waitForTimeout(500); // let the sheet animation finish + await a.screenshot({ path: join(SHOTS, '3-result-mobile.png') }); + await b.screenshot({ path: join(SHOTS, '4-result-desktop.png') }); + }); + + await step('한 판 더 → 새 게임 시작', async () => { + await a.getByRole('button', { name: /한 판 더/ }).click(); + await b.getByRole('button', { name: /한 판 더/ }).click(); + await a.getByText(/차례예요|생각 중이에요/).waitFor(); + }); + + if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`); +} catch (e) { + failed = true; + console.error(e); +} finally { + await browser.close(); + await app.stop(); +} +console.log(failed ? 'E2E FAILED' : 'E2E PASSED'); +process.exit(failed ? 1 : 0); diff --git a/package.json b/package.json index 197d8b2..7ac4101 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@types/bun": "1.4.2", + "playwright-core": "1.63.0", "typescript": "5.9.3" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index c196f37..148aab3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,11 @@ "version": "0.0.0", "private": true, "type": "module", - "exports": { ".": "./src/index.ts" }, - "dependencies": { "zod": "4.6.5" } + "exports": { + ".": "./src/index.ts", + "./lite": "./src/lite.ts" + }, + "dependencies": { + "zod": "4.6.5" + } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0d11d6d..fb8fe7e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,2 @@ +export * from './lite'; export * from './protocol'; -export * from './nickname'; -export * from './room-code'; -export * from './catalog'; diff --git a/packages/shared/src/lite.ts b/packages/shared/src/lite.ts new file mode 100644 index 0000000..34614a7 --- /dev/null +++ b/packages/shared/src/lite.ts @@ -0,0 +1,5 @@ +/** Browser-safe exports (no zod). */ +export * from './types'; +export * from './nickname'; +export * from './room-code'; +export * from './catalog'; diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 11005d0..53cd53d 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -1,9 +1,8 @@ -/** WebSocket protocol, docs/03-realtime-protocol.md. */ +/** WebSocket protocol client→server schema (zod; server-side validation), docs/03-realtime-protocol.md. */ import { z } from 'zod'; +import { CHAT_MAX } from './types'; -export const PROTOCOL = 1; -export const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024; -export const CHAT_MAX = 200; +export * from './types'; const code = z.string().regex(/^\d{6}$/); const userId = z.string().min(1).max(64); @@ -32,86 +31,9 @@ export const ClientMessage = z.discriminatedUnion('t', [ z.object({ t: z.literal('chat'), text: z.string().min(1).max(CHAT_MAX) }), z.object({ t: z.literal('emote'), id: z.string().max(20) }), z.object({ t: z.literal('rematch') }), + z.object({ t: z.literal('void') }), z.object({ t: z.literal('sync') }), z.object({ t: z.literal('ping'), ts: z.number() }), ]); export type ClientMessage = z.infer; -export const EMOTES = ['thumbs', 'laugh', 'wow', 'sad', 'clap', 'nice', 'hurry', 'gg'] as const; -export const EMOTE_LABEL: Record<(typeof EMOTES)[number], string> = { - thumbs: '👍', - laugh: '😂', - wow: '😮', - sad: '😢', - clap: '👏', - nice: '잘했어요!', - hurry: '조금만 서둘러 주세요', - gg: '좋은 게임이었어요', -}; - -export interface PublicUser { - id: string; - nickname: string; - avatar: string | null; - kind: 'guest' | 'member'; -} - -export interface SeatView { - user: PublicUser; - ready: boolean; - connected: boolean; - away: boolean; -} - -export type RoomStatus = 'lobby' | 'playing' | 'finished' | 'paused' | 'broken'; - -export interface RoomView { - code: string; - hostId: string; - gameId: string; - options: unknown; - maxPlayers: number; - minPlayers: number; - visibility: 'private' | 'public'; - turnSeconds: number | null; - allowSpectators: boolean; - chatEnabled: boolean; - chatFilter: boolean; - seats: (SeatView | null)[]; - spectators: PublicUser[]; - status: RoomStatus; - gameNo: number; - rematchVotes: string[]; - sessionStats: Record; - seedHash: string | null; - /** Seed revealed after the game for fairness check. */ - lastSeed: string | null; -} - -export interface GameResultView { - ranking: string[][]; - scores?: Record; - summary: string; - reason: string; -} - -export type ServerMessage = - | { t: 'welcome'; me: PublicUser; serverTime: number; protocol: number; activeRoom: string | null } - | { t: 'room'; seq: number; room: RoomView } - | { - t: 'state'; - seq: number; - view: unknown; - events: unknown[]; - active: string[]; - deadline: number | null; - cs?: number; - } - | { t: 'reject'; cs: number; reason: string } - | { t: 'chat'; from: PublicUser | null; text: string; at: number; channel?: string } - | { t: 'emote'; from: string; id: string } - | { t: 'result'; seq: number; result: GameResultView } - | { t: 'notice'; code: string; message: string } - | { t: 'error'; code: string; message: string } - | { t: 'pong'; ts: number; serverTime: number } - | { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' }; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..4ba14e0 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,83 @@ +/** Protocol constants and message types shared by server and web (no runtime deps). */ +export const PROTOCOL = 1; +export const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024; +export const CHAT_MAX = 200; + +export const EMOTES = ['thumbs', 'laugh', 'wow', 'sad', 'clap', 'nice', 'hurry', 'gg'] as const; +export const EMOTE_LABEL: Record<(typeof EMOTES)[number], string> = { + thumbs: '👍', + laugh: '😂', + wow: '😮', + sad: '😢', + clap: '👏', + nice: '잘했어요!', + hurry: '조금만 서둘러 주세요', + gg: '좋은 게임이었어요', +}; + +export interface PublicUser { + id: string; + nickname: string; + avatar: string | null; + kind: 'guest' | 'member'; +} + +export interface SeatView { + user: PublicUser; + ready: boolean; + connected: boolean; + away: boolean; +} + +export type RoomStatus = 'lobby' | 'playing' | 'finished' | 'paused' | 'broken'; + +export interface RoomView { + code: string; + hostId: string; + gameId: string; + options: unknown; + maxPlayers: number; + minPlayers: number; + visibility: 'private' | 'public'; + turnSeconds: number | null; + allowSpectators: boolean; + chatEnabled: boolean; + chatFilter: boolean; + seats: (SeatView | null)[]; + spectators: PublicUser[]; + status: RoomStatus; + gameNo: number; + rematchVotes: string[]; + sessionStats: Record; + seedHash: string | null; + /** Seed revealed after the game for fairness check. */ + lastSeed: string | null; +} + +export interface GameResultView { + ranking: string[][]; + scores?: Record; + summary: string; + reason: string; +} + +export type ServerMessage = + | { t: 'welcome'; me: PublicUser; serverTime: number; protocol: number; activeRoom: string | null } + | { t: 'room'; seq: number; room: RoomView } + | { + t: 'state'; + seq: number; + view: unknown; + events: unknown[]; + active: string[]; + deadline: number | null; + cs?: number; + } + | { t: 'reject'; cs: number; reason: string } + | { t: 'chat'; from: PublicUser | null; text: string; at: number; channel?: string } + | { t: 'emote'; from: string; id: string } + | { t: 'result'; seq: number; result: GameResultView } + | { t: 'notice'; code: string; message: string } + | { t: 'error'; code: string; message: string } + | { t: 'pong'; ts: number; serverTime: number } + | { t: 'bye'; reason: 'restart' | 'replaced' | 'kicked' | 'closed' };