diff --git a/app/api/rooms/[roomId]/consume-dice/route.ts b/app/api/rooms/[roomId]/consume-dice/route.ts new file mode 100644 index 0000000..d8bb0a8 --- /dev/null +++ b/app/api/rooms/[roomId]/consume-dice/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { + consumeDice, + getRoom, + notifyRoom, + toPublicRoom, +} from '@/lib/rooms/store'; + +export const runtime = 'nodejs'; + +type Params = { params: Promise<{ roomId: string }> }; + +export async function POST(request: Request, { params }: Params) { + const { roomId } = await params; + const room = getRoom(roomId); + if (!room) { + return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 }); + } + + const body = (await request.json().catch(() => ({}))) as { count?: number }; + const count = Math.max(1, Math.trunc(body.count ?? 1)); + + if (!room.settings.chzzkEnabled) { + return NextResponse.json({ room: toPublicRoom(room), consumed: 0 }); + } + + const updated = consumeDice(roomId, count); + if (!updated) { + return NextResponse.json( + { error: '남은 주사위가 없습니다.', room: toPublicRoom(room) }, + { status: 409 }, + ); + } + notifyRoom(roomId); + return NextResponse.json({ room: toPublicRoom(updated), consumed: count }); +} diff --git a/app/api/rooms/[roomId]/events/route.ts b/app/api/rooms/[roomId]/events/route.ts new file mode 100644 index 0000000..fe3dd8d --- /dev/null +++ b/app/api/rooms/[roomId]/events/route.ts @@ -0,0 +1,83 @@ +import { + cancelScheduledDisconnectRoomChat, + scheduleDisconnectRoomChat, +} from '@/lib/chzzk/chat-session'; +import { + addRoomViewer, + getRoom, + removeRoomViewer, + subscribeRoom, + toPublicRoom, + type PublicRoomState, +} from '@/lib/rooms/store'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type Params = { params: Promise<{ roomId: string }> }; + +export async function GET(request: Request, { params }: Params) { + const { roomId } = await params; + const room = getRoom(roomId); + if (!room) { + return new Response(JSON.stringify({ error: '방을 찾을 수 없습니다.' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const clientId = + new URL(request.url).searchParams.get('clientId')?.trim() || + crypto.randomUUID(); + + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | null = null; + let heartbeat: ReturnType | null = null; + let presenceHeld = false; + + const release = () => { + if (!presenceHeld) return; + presenceHeld = false; + const { emptied } = removeRoomViewer(roomId, clientId); + if (emptied) { + // 즉시 끊지 않음 — 빠른 재입장과 경쟁하지 않도록 유예 + scheduleDisconnectRoomChat(roomId); + } + }; + + const stream = new ReadableStream({ + start(controller) { + // 재입장 시 예약된 disconnect 취소 + cancelScheduledDisconnectRoomChat(roomId); + addRoomViewer(roomId, clientId); + presenceHeld = true; + + const send = (event: string, data: unknown) => { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ); + }; + + send('room', toPublicRoom(room)); + unsubscribe = subscribeRoom(roomId, (state: PublicRoomState) => { + send('room', state); + }); + heartbeat = setInterval(() => { + controller.enqueue(encoder.encode(`: ping\n\n`)); + }, 15000); + }, + cancel() { + if (unsubscribe) unsubscribe(); + if (heartbeat) clearInterval(heartbeat); + release(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} diff --git a/app/api/rooms/[roomId]/leave/route.ts b/app/api/rooms/[roomId]/leave/route.ts new file mode 100644 index 0000000..ad14eee --- /dev/null +++ b/app/api/rooms/[roomId]/leave/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server'; +import { scheduleDisconnectRoomChat } from '@/lib/chzzk/chat-session'; +import { + getRoom, + getRoomViewerCount, + removeRoomViewer, + toPublicRoom, +} from '@/lib/rooms/store'; + +export const runtime = 'nodejs'; + +type Params = { params: Promise<{ roomId: string }> }; + +/** + * 탭/창 종료·페이지 이탈 시 호출. + * 마지막 시청자면 치지직 채팅 웹소켓을 유예 후 끊는다 (빠른 재입장 대비). + */ +export async function POST(request: Request, { params }: Params) { + const { roomId } = await params; + const room = getRoom(roomId); + if (!room) { + return NextResponse.json({ ok: true, emptied: true }); + } + + let clientId = ''; + const contentType = request.headers.get('content-type') || ''; + try { + if (contentType.includes('application/json')) { + const body = (await request.json()) as { clientId?: string }; + clientId = body.clientId?.trim() || ''; + } else { + const text = await request.text(); + if (text.startsWith('{')) { + const body = JSON.parse(text) as { clientId?: string }; + clientId = body.clientId?.trim() || ''; + } else { + clientId = new URLSearchParams(text).get('clientId')?.trim() || ''; + } + } + } catch { + clientId = ''; + } + + if (!clientId) { + return NextResponse.json({ ok: true, viewers: getRoomViewerCount(roomId) }); + } + + const { viewers, emptied } = removeRoomViewer(roomId, clientId); + if (emptied) { + scheduleDisconnectRoomChat(roomId); + } + + return NextResponse.json({ + ok: true, + viewers, + emptied, + room: toPublicRoom(getRoom(roomId) ?? room), + }); +} diff --git a/app/api/rooms/[roomId]/route.ts b/app/api/rooms/[roomId]/route.ts new file mode 100644 index 0000000..42ad143 --- /dev/null +++ b/app/api/rooms/[roomId]/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getRoom, toPublicRoom } from '@/lib/rooms/store'; + +export const runtime = 'nodejs'; + +type Params = { params: Promise<{ roomId: string }> }; + +export async function GET(_request: Request, { params }: Params) { + const { roomId } = await params; + const room = getRoom(roomId); + if (!room) { + return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 }); + } + return NextResponse.json({ room: toPublicRoom(room) }); +} diff --git a/app/api/rooms/[roomId]/simulate-donation/route.ts b/app/api/rooms/[roomId]/simulate-donation/route.ts new file mode 100644 index 0000000..f2f5237 --- /dev/null +++ b/app/api/rooms/[roomId]/simulate-donation/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import { + addDiceFromDonation, + getRoom, + notifyRoom, + toPublicRoom, +} from '@/lib/rooms/store'; + +export const runtime = 'nodejs'; + +type Params = { params: Promise<{ roomId: string }> }; + +export async function POST(request: Request, { params }: Params) { + const { roomId } = await params; + const room = getRoom(roomId); + if (!room) { + return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 }); + } + + const body = (await request.json().catch(() => ({}))) as { + payAmount?: number; + donatorNickname?: string; + }; + const payAmount = Number(body.payAmount ?? 0); + if (!Number.isFinite(payAmount) || payAmount <= 0) { + return NextResponse.json({ error: 'payAmount가 필요합니다.' }, { status: 400 }); + } + + if (!room.settings.chzzkEnabled) { + return NextResponse.json( + { error: '연동 없는 룸에서는 후원 시뮬레이션을 사용할 수 없습니다.' }, + { status: 400 }, + ); + } + + const result = addDiceFromDonation(roomId, { + payAmount, + donatorNickname: body.donatorNickname || '테스트후원', + donationText: 'simulate', + simulated: true, + }); + if (!result) { + return NextResponse.json({ error: '처리 실패' }, { status: 500 }); + } + notifyRoom(roomId); + return NextResponse.json({ + room: toPublicRoom(result.room), + diceAdded: result.diceAdded, + }); +} diff --git a/app/api/rooms/join/route.ts b/app/api/rooms/join/route.ts new file mode 100644 index 0000000..153e8ce --- /dev/null +++ b/app/api/rooms/join/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server'; +import { parseChzzkChannelId } from '@/lib/chzzk/channel'; +import { + cancelScheduledDisconnectRoomChat, + ensureRoomChat, +} from '@/lib/chzzk/chat-session'; +import { + createRoom, + findRoomByChannelId, + getRoom, + toPublicRoom, +} from '@/lib/rooms/store'; + +export const runtime = 'nodejs'; + +/** + * 치지직 채널 ID(또는 라이브 URL)로 룸 입장. + * 없으면 전달된 치즈 설정으로 새 룸을 만들고 채팅 연결을 시도한다. + */ +export async function POST(request: Request) { + const body = (await request.json().catch(() => ({}))) as { + channelIdOrUrl?: string; + cheesePerDice?: number; + multiplyByCheese?: boolean; + maxMultiplier?: number; + }; + + const raw = body.channelIdOrUrl || ''; + const channelId = parseChzzkChannelId(raw); + if (!channelId) { + return NextResponse.json( + { + error: + '유효한 치지직 채널 ID 또는 라이브 URL이 필요합니다. 예: https://chzzk.naver.com/live/{채널ID}', + }, + { status: 400 }, + ); + } + + let room = findRoomByChannelId(channelId); + let created = false; + + if (!room) { + created = true; + room = createRoom({ + chzzkEnabled: true, + liveUrl: raw.trim(), + channelId, + cheesePerDice: body.cheesePerDice, + multiplyByCheese: body.multiplyByCheese, + maxMultiplier: body.maxMultiplier, + }); + } + + // 이전 세션 leave가 늦게 와도 끊기지 않게 예약 해제 + 실제 WS 생존 여부 확인 + cancelScheduledDisconnectRoomChat(room.id); + + if (room.settings.chzzkEnabled) { + try { + await ensureRoomChat(room.id); + } catch (e) { + if (created) { + return NextResponse.json( + { + error: + e instanceof Error + ? e.message + : '채팅 연결에 실패했습니다. 방송 중인지 확인해 주세요.', + room: toPublicRoom(room), + created, + }, + { status: 502 }, + ); + } + } + } + + const latest = getRoom(room.id) ?? room; + return NextResponse.json({ + room: toPublicRoom(latest), + created, + }); +} diff --git a/app/api/rooms/route.ts b/app/api/rooms/route.ts new file mode 100644 index 0000000..8c41052 --- /dev/null +++ b/app/api/rooms/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { createRoom, toPublicRoom } from '@/lib/rooms/store'; + +export const runtime = 'nodejs'; + +/** 치지직·채팅 연동 없이 수동 굴림 룸 생성 */ +export async function POST(_request: Request) { + const room = createRoom({ + chzzkEnabled: false, + }); + + return NextResponse.json({ room: toPublicRoom(room), reused: false }); +} diff --git a/app/game/chat-feed.tsx b/app/game/chat-feed.tsx new file mode 100644 index 0000000..38329c4 --- /dev/null +++ b/app/game/chat-feed.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +export type ChatFeedItem = { + id: string; + at: number; + nickname: string; + message: string; + kind: 'chat' | 'donation'; + payAmount?: number; +}; + +export default function ChatFeedPanel({ + items, + visible, + compact = false, +}: { + items: ChatFeedItem[]; + visible: boolean; + compact?: boolean; +}) { + const listRef = useRef(null); + + useEffect(() => { + const el = listRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [items.length, items[items.length - 1]?.id]); + + if (!visible) return null; + + return ( +
+
+ 채팅 +
+
+ {items.length === 0 ? ( +

채팅 대기 중…

+ ) : ( + items.map((item) => ( +

+ {item.nickname} + : + {item.message || (item.kind === 'donation' ? '치즈 후원' : '')} + {item.kind === 'donation' && item.payAmount != null ? ( + ({item.payAmount}치즈) + ) : null} +

+ )) + )} +
+
+ ); +} diff --git a/app/game/dice.tsx b/app/game/dice.tsx index ebe3f6b..e3f5acf 100644 --- a/app/game/dice.tsx +++ b/app/game/dice.tsx @@ -1,6 +1,15 @@ 'use client'; -import { Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + memo, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber'; import { Physics, @@ -33,10 +42,10 @@ const WALL_HALF_H = WALL_HEIGHT / 2; const STILL_SPEED_SQ = 0.05; const STILL_FRAMES = 18; +const MIN_VIEW_PX = 16; const DEFAULT_DICE_FACE_TEXTURES = getDiceFaceTextureList(DEFAULT_ASSETS.dice); -// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면 const DICE_FACES = [ { value: 1, localNormal: new Vector3(0, 1, 0) }, { value: 6, localNormal: new Vector3(0, -1, 0) }, @@ -51,9 +60,7 @@ const _normal = new Vector3(); const WORLD_UP = new Vector3(0, 1, 0); export type DiceRollResult = { - /** 이동에 쓰는 합 */ sum: number; - /** 각 주사위 윗면 */ faces: number[]; }; @@ -248,6 +255,7 @@ function RapierDiceExample({ faceUrlSets, floorColor, floorImage, + canvasKey, }: { diceRefs: React.RefObject[]; diceCount: DiceCount; @@ -256,15 +264,18 @@ function RapierDiceExample({ faceUrlSets: string[][]; floorColor: string; floorImage: string | null; + canvasKey: number; }) { const spawnX = diceCount === 2 ? [-0.85, 0.85] : [0]; return ( @@ -282,7 +293,7 @@ function RapierDiceExample({ {spawnX.map((x, index) => ( - + + + + + } + > void; disabled?: boolean; + requireDiceQueue?: boolean; + diceQueue?: number; + onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>; }) { + const hostRef = useRef(null); const diceRefA = useRef(null); const diceRefB = useRef(null); - const diceRefs = [diceRefA, diceRefB]; + const diceRefs = useMemo(() => [diceRefA, diceRefB], []); + + const [view, setView] = useState({ w: 0, h: 0, gen: 0 }); const [rolling, setRolling] = useState(false); const [result, setResult] = useState(null); const [diceCount, setDiceCount] = useState(1); @@ -347,6 +378,7 @@ export default function Dice({ ]); const [floorColor, setFloorColor] = useState(DEFAULT_DICE_FLOOR_COLOR); const [floorImage, setFloorImage] = useState(null); + const [queueHint, setQueueHint] = useState(null); useEffect(() => { const assets = getAssets(); @@ -360,6 +392,36 @@ export default function Dice({ setFloorImage(assets.diceFloorImage); }, []); + // 칸 크기가 잡히기 전에 Canvas가 마운트되면 WebGL이 깨져 주사위가 안 보임 + useEffect(() => { + const el = hostRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + + const apply = (width: number, height: number) => { + const w = Math.floor(width); + const h = Math.floor(height); + setView((prev) => { + const ready = w >= MIN_VIEW_PX && h >= MIN_VIEW_PX; + const wasReady = prev.w >= MIN_VIEW_PX && prev.h >= MIN_VIEW_PX; + if (!ready) return { w, h, gen: prev.gen }; + if (!wasReady) return { w, h, gen: prev.gen + 1 }; + if (Math.abs(w - prev.w) > 4 || Math.abs(h - prev.h) > 4) { + return { w, h, gen: prev.gen + 1 }; + } + return prev; + }); + }; + + apply(el.clientWidth, el.clientHeight); + const ro = new ResizeObserver((entries) => { + const cr = entries[0]?.contentRect; + if (!cr) return; + apply(cr.width, cr.height); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const onSettled = useCallback( (next: DiceRollResult) => { setResult(next); @@ -369,42 +431,91 @@ export default function Dice({ [onRolled], ); - const busy = rolling || disabled; + const canvasReady = view.w >= MIN_VIEW_PX && view.h >= MIN_VIEW_PX && view.gen > 0; + const busy = + rolling || disabled || !canvasReady || (requireDiceQueue && diceQueue <= 0); return ( -
-
- +
+
+ {canvasReady ? ( + + ) : ( +
+ 주사위 준비 중… +
+ )}
- {rolling - ? '굴리는 중…' - : disabled - ? '이동 중…' - : formatResult(result)} + {queueHint + ? queueHint + : !canvasReady + ? '주사위 준비 중…' + : rolling + ? '굴리는 중…' + : disabled + ? '이동 중…' + : requireDiceQueue && diceQueue <= 0 + ? '후원 대기 중…' + : formatResult(result)}
); -} +}); diff --git a/app/game/diceplane.tsx b/app/game/diceplane.tsx index 0abc616..37d93cc 100644 --- a/app/game/diceplane.tsx +++ b/app/game/diceplane.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import Dice from './dice'; +import ChatFeedPanel, { type ChatFeedItem } from './chat-feed'; import { Chessman } from './chessman'; import ChessmanPiece from './chessman-piece'; import { applyLandEvent, type PlaneCell } from './cell-event'; @@ -20,7 +21,19 @@ function cellLabel(cell: PlaneCell | undefined, index: number) { return index === 0 ? '시작' : cell.type; } -export default function DicePlane() { +export default function DicePlane({ + requireDiceQueue = false, + diceQueue = 0, + onConsumeDice, + chatFeed = [], + showChatFeed = false, +}: { + requireDiceQueue?: boolean; + diceQueue?: number; + onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>; + chatFeed?: ChatFeedItem[]; + showChatFeed?: boolean; +}) { const initial = getOrCreateBoardConfig(); const [planeSize, setPlaneSize] = useState({ width: initial.width, @@ -35,6 +48,11 @@ export default function DicePlane() { const [chessmanSrc, setChessmanSrc] = useState('/chessman/default.png'); const [hopKey, setHopKey] = useState(0); const [moving, setMoving] = useState(false); + const [chatOpen, setChatOpen] = useState(true); + + useEffect(() => { + if (showChatFeed) setChatOpen(true); + }, [showChatFeed]); const [landedLabel, setLandedLabel] = useState(null); const [landedKey, setLandedKey] = useState(0); const [statusText, setStatusText] = useState('시계방향'); @@ -225,13 +243,72 @@ export default function DicePlane() { minWidth: 0, minHeight: 0, zIndex: 6, + position: 'relative', }} > - +
+ + {showChatFeed && ( +
+ + + {chatOpen && ( +
+ +
+ )} +
+ )}
-
+
{statusText} {islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''}
diff --git a/app/game/page.tsx b/app/game/page.tsx index 359266f..5c032e5 100644 --- a/app/game/page.tsx +++ b/app/game/page.tsx @@ -1,13 +1,21 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import DicePlane from './diceplane'; import { DEFAULT_ASSETS, getAssets } from './asset-storage'; +import { useRoom } from '@/lib/rooms/client'; -export default function GamePage() { +function GamePageInner() { + const searchParams = useSearchParams(); + const roomId = searchParams.get('roomId'); const [backgroundSrc, setBackgroundSrc] = useState(DEFAULT_ASSETS.background); const [ready, setReady] = useState(false); + const [simAmount, setSimAmount] = useState(1000); + const { room, error, consumeDice, simulateDonation } = useRoom(roomId); + const chzzkOn = Boolean(room?.settings.chzzkEnabled); + const manualMode = Boolean(roomId) && room != null && !chzzkOn; useEffect(() => { setBackgroundSrc(getAssets().background); @@ -28,16 +36,91 @@ export default function GamePage() { )}
- - 설정으로 - +
+ + 홈 + + {roomId && chzzkOn && ( +
+

주사위 큐: {room?.diceQueue ?? '…'}

+ {room?.settings.channelId && ( +

+ 채널 ID: {room.settings.channelId} +

+ )} + {room && ( +

+ {room.channelName || room.settings.channelId || '—'} · {room.chatStatus} + {` · ${room.settings.cheesePerDice}원/${ + room.settings.multiplyByCheese + ? `배수${room.settings.maxMultiplier > 0 ? `≤${room.settings.maxMultiplier}` : ''}` + : '1회' + }`} +

+ )} + {error &&

{error}

} + {room?.chatError &&

{room.chatError}

} + {room?.lastDonations?.[0] && ( +

+ 최근: {room.lastDonations[0].donatorNickname}{' '} + {room.lastDonations[0].payAmount}원 → +{room.lastDonations[0].diceAdded} +

+ )} +
+ setSimAmount(Number(e.target.value) || 1)} + className="w-24 rounded border border-white/20 bg-black/50 px-2 py-1 text-white" + /> + +
+
+ )} + {manualMode && ( +
+

수동 모드

+

+ 치지직·채팅 연동 없음 · 주사위는 자유롭게 굴릴 수 있습니다. +

+ {error &&

{error}

} +
+ )} +
- +
); } + +export default function GamePage() { + return ( + + 불러오는 중… +
+ } + > + +
+ ); +} diff --git a/app/main/page.tsx b/app/main/page.tsx index a6bceab..b54fa07 100644 --- a/app/main/page.tsx +++ b/app/main/page.tsx @@ -1,31 +1,6 @@ -'use client'; +import { redirect } from 'next/navigation'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; - -export default function MainPage() { - const router = useRouter(); - - return ( -
-

주루마블!

-
- - - 커스텀 설정 - -
-
- ); +/** /main 은 / 로 통합되었습니다. */ +export default function MainRedirect() { + redirect('/'); } diff --git a/app/page.tsx b/app/page.tsx index f84983d..f310f1a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import { ASSET_ASPECT, @@ -316,6 +316,7 @@ function BoardEditor({ } export default function Home() { + const router = useRouter(); const [assets, setAssets] = useState(DEFAULT_ASSETS); const [board, setBoard] = useState(null); const [ready, setReady] = useState(false); @@ -323,6 +324,13 @@ export default function Home() { const [busy, setBusy] = useState(false); const packInputRef = useRef(null); + const [roomCheesePerDice, setRoomCheesePerDice] = useState(1000); + const [roomMultiplyByCheese, setRoomMultiplyByCheese] = useState(true); + const [roomMaxMultiplier, setRoomMaxMultiplier] = useState(10); + const [joinChannelInput, setJoinChannelInput] = useState(''); + const [roomBusy, setRoomBusy] = useState(false); + const [roomError, setRoomError] = useState(null); + const reload = () => { setAssets(getAssets()); setBoard(getOrCreateBoardConfig()); @@ -333,6 +341,67 @@ export default function Home() { setReady(true); }, []); + const roomCheeseOptions = () => ({ + cheesePerDice: roomCheesePerDice, + multiplyByCheese: roomMultiplyByCheese, + maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0, + }); + + const joinByChzzkId = async () => { + const value = joinChannelInput.trim(); + if (!value) { + setRoomError('치지직 채널 ID 또는 라이브 URL을 입력하세요.'); + return; + } + try { + setRoomBusy(true); + setRoomError(null); + const res = await fetch('/api/rooms/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channelIdOrUrl: value, + ...roomCheeseOptions(), + }), + }); + const json = await res.json(); + if (!res.ok) { + setRoomError(json.error || '룸을 찾을 수 없습니다.'); + if (json.room?.id) { + router.push(`/game?roomId=${json.room.id}`); + } + return; + } + router.push(`/game?roomId=${json.room.id}`); + } catch (e) { + setRoomError(e instanceof Error ? e.message : '룸 입장에 실패했습니다.'); + } finally { + setRoomBusy(false); + } + }; + + const enterWithoutIntegration = async () => { + try { + setRoomBusy(true); + setRoomError(null); + const res = await fetch('/api/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const json = await res.json(); + if (!res.ok) { + setRoomError(json.error || '룸 생성에 실패했습니다.'); + return; + } + router.push(`/game?roomId=${json.room.id}`); + } catch (e) { + setRoomError(e instanceof Error ? e.message : '입장에 실패했습니다.'); + } finally { + setRoomBusy(false); + } + }; + const save = async ( kind: 'background' | 'chessman' | 'diceFloor' | { slot: DiceSlot; face: DiceFace }, file: File, @@ -390,23 +459,110 @@ export default function Home() { return (
-
-
-

온라인 주루마블

-

Drinkmarble

-

- 이미지와 판 칸 설정은 서버가 아니라 이 브라우저 localStorage에만 저장됩니다. +

+
+ {/*

온라인 주루마블

*/} +

+ + 온라인 주루마블! + + by kimyu. + + +

+

+

+

+ 이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장됩니다. 처음이면 칸은 랜덤으로 채워집니다.

- - 게임 시작 -
+
+
+

룸 입장

+

+ 치즈 규칙을 정한 뒤, 치지직 채널로 입장하세요. 룸이 없으면 자동으로 만들어집니다. +

+
+ +
+ + + {roomMultiplyByCheese && ( + + )} +
+ +
+ + +
+ + + + {roomError && ( +

{roomError}

+ )} +
+ {!ready || !board ? (

불러오는 중…

) : ( diff --git a/lib/chzzk/channel.ts b/lib/chzzk/channel.ts new file mode 100644 index 0000000..082966e --- /dev/null +++ b/lib/chzzk/channel.ts @@ -0,0 +1,135 @@ +/** 치지직 라이브/채널 URL에서 channelId 추출 */ +export function parseChzzkChannelId(input: string): string | null { + const raw = input.trim(); + if (!raw) return null; + + // 해시만 붙여 넣은 경우 + if (/^[a-f0-9]{32}$/i.test(raw)) return raw.toLowerCase(); + + try { + const url = new URL(raw.startsWith('http') ? raw : `https://${raw}`); + if (!url.hostname.includes('chzzk.naver.com')) return null; + const parts = url.pathname.split('/').filter(Boolean); + // /live/{id} | /{id} | /channel/{id} + const liveIdx = parts.indexOf('live'); + if (liveIdx >= 0 && parts[liveIdx + 1]) { + const id = parts[liveIdx + 1]; + return /^[a-f0-9]{32}$/i.test(id) ? id.toLowerCase() : null; + } + const channelIdx = parts.indexOf('channel'); + if (channelIdx >= 0 && parts[channelIdx + 1]) { + const id = parts[channelIdx + 1]; + return /^[a-f0-9]{32}$/i.test(id) ? id.toLowerCase() : null; + } + if (parts[0] && /^[a-f0-9]{32}$/i.test(parts[0])) { + return parts[0].toLowerCase(); + } + } catch { + return null; + } + return null; +} + +const CHZZK_API = 'https://api.chzzk.naver.com'; +const GAME_API = 'https://comm-api.game.naver.com/nng_main'; + +const DEFAULT_HEADERS = { + Accept: 'application/json', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + Origin: 'https://chzzk.naver.com', + Referer: 'https://chzzk.naver.com/', +}; + +async function getJson(url: string): Promise { + const res = await fetch(url, { + headers: DEFAULT_HEADERS, + cache: 'no-store', + }); + if (!res.ok) { + throw new Error(`치지직 API 오류 (${res.status})`); + } + return (await res.json()) as T; +} + +export type ChannelInfo = { + channelId: string; + channelName: string | null; +}; + +export async function fetchChannelInfo(channelId: string): Promise { + const data = await getJson<{ + content?: { channelId?: string; channelName?: string }; + }>(`${CHZZK_API}/service/v1/channels/${channelId}`); + return { + channelId: data.content?.channelId || channelId, + channelName: data.content?.channelName ?? null, + }; +} + +export type LiveChatInfo = { + chatChannelId: string; + liveTitle: string | null; + status: string | null; +}; + +/** 방송 중일 때 chatChannelId를 얻는다. */ +export async function fetchLiveChatInfo(channelId: string): Promise { + // polling live-status 우선 + try { + const status = await getJson<{ + content?: { chatChannelId?: string; status?: string; liveTitle?: string }; + }>(`${CHZZK_API}/polling/v2/channels/${channelId}/live-status`); + if (status.content?.chatChannelId) { + return { + chatChannelId: status.content.chatChannelId, + liveTitle: status.content.liveTitle ?? null, + status: status.content.status ?? null, + }; + } + } catch { + // fallback below + } + + const detail = await getJson<{ + content?: { chatChannelId?: string; liveTitle?: string; status?: string }; + }>(`${CHZZK_API}/service/v3/channels/${channelId}/live-detail`); + + if (!detail.content?.chatChannelId) { + throw new Error('방송이 꺼져 있거나 chatChannelId를 찾지 못했습니다.'); + } + + return { + chatChannelId: detail.content.chatChannelId, + liveTitle: detail.content.liveTitle ?? null, + status: detail.content.status ?? null, + }; +} + +export async function fetchChatAccessToken(chatChannelId: string): Promise { + const data = await getJson<{ + code?: number; + message?: string; + content?: { accessToken?: string }; + }>( + `${GAME_API}/v1/chats/access-token?channelId=${encodeURIComponent(chatChannelId)}&chatType=STREAMING`, + ); + + if (data.code === 42601) { + throw new Error('연령 제한 방송이라 익명 채팅 토큰을 받을 수 없습니다.'); + } + const token = data.content?.accessToken; + if (!token) { + throw new Error(data.message || '채팅 accessToken을 받지 못했습니다.'); + } + return token; +} + +export function chatServerUrl(chatChannelId: string) { + const sum = chatChannelId + .split('') + .map((c) => c.charCodeAt(0)) + .reduce((a, b) => a + b, 0); + const serverId = (Math.abs(sum) % 9) + 1; + return `wss://kr-ss${serverId}.chat.naver.com/chat`; +} diff --git a/lib/chzzk/chat-session.ts b/lib/chzzk/chat-session.ts new file mode 100644 index 0000000..9c618d1 --- /dev/null +++ b/lib/chzzk/chat-session.ts @@ -0,0 +1,505 @@ +import WebSocket from 'ws'; +import { + chatServerUrl, + fetchChannelInfo, + fetchChatAccessToken, + fetchLiveChatInfo, +} from './channel'; +import { + addChatMessage, + addDiceFromDonation, + clearChatFeed, + getRoom, + getRoomViewerCount, + hasSeenDonationKey, + markSeenDonationKey, + notifyRoom, + setRoomMeta, +} from '../rooms/store'; + +const ChatCmd = { + PING: 0, + PONG: 10000, + CONNECT: 100, + CONNECTED: 10100, + REQUEST_RECENT_CHAT: 5101, + RECENT_CHAT: 15101, + CHAT: 93101, + DONATION: 93102, +} as const; + +const ChatType = { + TEXT: 1, + DONATION: 10, + SUBSCRIPTION: 11, +} as const; + +type SessionHandle = { + roomId: string; + ws: WebSocket; + chatChannelId: string; + sid: string | null; + pingTimer: ReturnType | null; + pollTimer: ReturnType | null; + closed: boolean; + /** 이번 연결에서 이미 피드에 넣은 메시지 키 */ + seenMessageKeys: Set; +}; + +const SEEN_KEYS_MAX = 800; +const RECENT_CHAT_COUNT = 50; + +const globalForSessions = globalThis as typeof globalThis & { + __drinkmarbleChatSessions?: Map; +}; + +function getSessions() { + if (!globalForSessions.__drinkmarbleChatSessions) { + globalForSessions.__drinkmarbleChatSessions = new Map(); + } + return globalForSessions.__drinkmarbleChatSessions; +} + +function clearPing(handle: SessionHandle) { + if (handle.pingTimer) clearTimeout(handle.pingTimer); + handle.pingTimer = null; +} + +function schedulePing(handle: SessionHandle) { + clearPing(handle); + handle.pingTimer = setTimeout(() => { + if (handle.closed || handle.ws.readyState !== WebSocket.OPEN) return; + handle.ws.send(JSON.stringify({ cmd: ChatCmd.PING, ver: '2' })); + schedulePing(handle); + }, 20000); +} + +function parseMaybeJson(value: unknown): T | null { + if (value == null) return null; + if (typeof value === 'object') return value as T; + if (typeof value !== 'string') return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} + +function messageTimeMs(chat: Record): number | null { + const raw = chat.msgTime ?? chat.messageTime; + const n = typeof raw === 'string' ? Number(raw) : Number(raw); + if (!Number.isFinite(n) || n <= 0) return null; + return n; +} + +function messageKey( + chat: Record, + kind: 'donation' | 'chat', +): string | null { + const id = chat.msgUid ?? chat.messageId ?? null; + if (id != null && String(id).length > 0) { + return `${kind}:${String(id)}`; + } + const time = messageTimeMs(chat); + const profile = parseMaybeJson<{ nickname?: string; userIdHash?: string }>( + chat.profile, + ); + const extras = parseMaybeJson<{ payAmount?: number }>(chat.extras); + const who = profile?.userIdHash || profile?.nickname || ''; + const msg = String(chat.msg ?? chat.content ?? ''); + if (time == null && !who && !msg) return null; + if (kind === 'donation') { + return `donation:${time ?? 0}:${who}:${Number(extras?.payAmount ?? 0)}:${msg}`; + } + return `chat:${time ?? 0}:${who}:${msg}`; +} + +function rememberMessageKey(handle: SessionHandle, key: string | null): boolean { + if (!key) return true; + if (handle.seenMessageKeys.has(key)) return false; + handle.seenMessageKeys.add(key); + if (handle.seenMessageKeys.size > SEEN_KEYS_MAX) { + const oldest = handle.seenMessageKeys.values().next().value; + if (oldest != null) handle.seenMessageKeys.delete(oldest); + } + return true; +} + +function handleDonationBody( + roomId: string, + handle: SessionHandle, + chat: Record, + isRecent: boolean, +): boolean { + const key = messageKey(chat, 'donation'); + if (!rememberMessageKey(handle, key)) return false; + + const extras = parseMaybeJson<{ + payAmount?: number; + donationType?: string; + }>(chat.extras); + const profile = parseMaybeJson<{ nickname?: string }>(chat.profile); + const payAmount = Number(extras?.payAmount ?? 0); + if (!Number.isFinite(payAmount) || payAmount <= 0) return false; + + // 히스토리 후원: 피드에만 표시하고, 이미 본 것으로 표시해 재적용 방지 + if (isRecent) { + if (key) markSeenDonationKey(roomId, key); + addDiceFromDonation(roomId, { + payAmount, + donatorNickname: profile?.nickname, + donationText: String(chat.msg ?? chat.content ?? ''), + applyDice: false, + }); + return true; + } + + if (key && hasSeenDonationKey(roomId, key)) { + return false; + } + if (key) markSeenDonationKey(roomId, key); + + addDiceFromDonation(roomId, { + payAmount, + donatorNickname: profile?.nickname, + donationText: String(chat.msg ?? chat.content ?? ''), + applyDice: true, + }); + return true; +} + +function handleChatBody( + roomId: string, + handle: SessionHandle, + chat: Record, +): boolean { + const key = messageKey(chat, 'chat'); + if (!rememberMessageKey(handle, key)) return false; + + const profile = parseMaybeJson<{ nickname?: string }>(chat.profile); + const message = String(chat.msg ?? chat.content ?? '').trim(); + if (!message) return false; + addChatMessage(roomId, { + nickname: profile?.nickname, + message, + }); + return true; +} + +function processMessageList( + roomId: string, + handle: SessionHandle, + body: Record | unknown[] | undefined, + cmd: number, +) { + const isRecent = cmd === ChatCmd.RECENT_CHAT; + const list = Array.isArray(body) + ? body + : Array.isArray((body as { messageList?: unknown[] })?.messageList) + ? ((body as { messageList: unknown[] }).messageList) + : body + ? [body] + : []; + + let changed = false; + for (const item of list) { + if (!item || typeof item !== 'object') continue; + const chat = item as Record; + const type = Number(chat.msgTypeCode ?? chat.messageTypeCode ?? 0); + if (cmd === ChatCmd.DONATION || type === ChatType.DONATION) { + if (handleDonationBody(roomId, handle, chat, isRecent)) changed = true; + } else if ( + type === ChatType.TEXT || + type === ChatType.SUBSCRIPTION || + !type + ) { + if (handleChatBody(roomId, handle, chat)) changed = true; + } + } + // 최근 채팅 50개를 메시지마다 SSE로 보내면 주사위 Canvas가 깨질 수 있어 한 번만 알림 + if (changed) notifyRoom(roomId); +} + +function requestRecentChat(handle: SessionHandle) { + if ( + handle.closed || + !handle.sid || + handle.ws.readyState !== WebSocket.OPEN + ) { + return; + } + handle.ws.send( + JSON.stringify({ + ver: '2', + cmd: ChatCmd.REQUEST_RECENT_CHAT, + svcid: 'game', + cid: handle.chatChannelId, + sid: handle.sid, + tid: 2, + bdy: { recentMessageCount: RECENT_CHAT_COUNT }, + }), + ); +} + +function onSocketMessage(roomId: string, handle: SessionHandle, raw: WebSocket.RawData) { + let json: { + cmd?: number; + bdy?: unknown; + }; + try { + json = JSON.parse(String(raw)); + } catch { + return; + } + + const cmd = json.cmd; + const body = json.bdy as Record | unknown[] | undefined; + + if (cmd === ChatCmd.CONNECTED) { + const sid = + body && !Array.isArray(body) && typeof body.sid === 'string' + ? body.sid + : null; + handle.sid = sid; + handle.seenMessageKeys.clear(); + clearChatFeed(roomId); + setRoomMeta(roomId, { chatStatus: 'connected', chatError: null }); + notifyRoom(roomId); + schedulePing(handle); + requestRecentChat(handle); + return; + } + + if (cmd === ChatCmd.PING) { + if (handle.ws.readyState === WebSocket.OPEN) { + handle.ws.send(JSON.stringify({ cmd: ChatCmd.PONG, ver: '2' })); + } + return; + } + + if ( + cmd === ChatCmd.CHAT || + cmd === ChatCmd.DONATION || + cmd === ChatCmd.RECENT_CHAT + ) { + processMessageList(roomId, handle, body, cmd ?? 0); + schedulePing(handle); + } +} + +async function openSocket(roomId: string, channelId: string): Promise { + const live = await fetchLiveChatInfo(channelId); + const accessToken = await fetchChatAccessToken(live.chatChannelId); + const url = chatServerUrl(live.chatChannelId); + + setRoomMeta(roomId, { + chatChannelId: live.chatChannelId, + chatStatus: 'connecting', + chatError: null, + }); + notifyRoom(roomId); + + const ws = new WebSocket(url, { + headers: { + Origin: 'https://chzzk.naver.com', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + }, + }); + + const handle: SessionHandle = { + roomId, + ws, + chatChannelId: live.chatChannelId, + sid: null, + pingTimer: null, + pollTimer: null, + closed: false, + seenMessageKeys: new Set(), + }; + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('채팅 웹소켓 연결 시간 초과')); + }, 12000); + + ws.on('open', () => { + ws.send( + JSON.stringify({ + ver: '2', + cmd: ChatCmd.CONNECT, + svcid: 'game', + cid: live.chatChannelId, + tid: 1, + bdy: { + uid: null, + devType: 2001, + accTkn: accessToken, + auth: 'READ', + }, + }), + ); + }); + + ws.on('message', (data) => { + try { + const json = JSON.parse(String(data)) as { cmd?: number }; + if (json.cmd === ChatCmd.CONNECTED) { + clearTimeout(timer); + onSocketMessage(roomId, handle, data); + resolve(); + return; + } + } catch { + // fallthrough + } + onSocketMessage(roomId, handle, data); + }); + + ws.on('error', (err) => { + clearTimeout(timer); + reject(err instanceof Error ? err : new Error('웹소켓 오류')); + }); + + ws.on('close', () => { + handle.closed = true; + clearPing(handle); + if (handle.pollTimer) clearInterval(handle.pollTimer); + const current = getSessions().get(roomId); + if (current === handle) { + getSessions().delete(roomId); + setRoomMeta(roomId, { chatStatus: 'disconnected' }); + notifyRoom(roomId); + } + }); + }); + + // chatChannelId 변경 폴링 (방송 재시작 대응) + handle.pollTimer = setInterval(() => { + void (async () => { + try { + const next = await fetchLiveChatInfo(channelId); + if (next.chatChannelId && next.chatChannelId !== handle.chatChannelId) { + await disconnectRoomChat(roomId); + await connectRoomChat(roomId); + } + } catch { + // ignore poll errors + } + })(); + }, 30000); + + getSessions().set(roomId, handle); + return handle; +} + +export function isRoomChatLive(roomId: string) { + const handle = getSessions().get(roomId); + return Boolean( + handle && + !handle.closed && + handle.ws.readyState === WebSocket.OPEN && + handle.sid, + ); +} + +const globalForDisconnect = globalThis as typeof globalThis & { + __drinkmarbleChatDisconnectTimers?: Map>; +}; + +function getDisconnectTimers() { + if (!globalForDisconnect.__drinkmarbleChatDisconnectTimers) { + globalForDisconnect.__drinkmarbleChatDisconnectTimers = new Map(); + } + return globalForDisconnect.__drinkmarbleChatDisconnectTimers; +} + +/** 빠른 재입장 시 이전 leave가 새 연결을 끊지 않도록 예약 해제 */ +export function cancelScheduledDisconnectRoomChat(roomId: string) { + const timers = getDisconnectTimers(); + const t = timers.get(roomId); + if (!t) return; + clearTimeout(t); + timers.delete(roomId); +} + +/** + * 시청자가 0이 되어도 바로 끊지 않고 잠시 대기. + * 그 사이 재입장하면 채팅 세션을 유지한다. + */ +export function scheduleDisconnectRoomChat(roomId: string, delayMs = 4000) { + cancelScheduledDisconnectRoomChat(roomId); + const timers = getDisconnectTimers(); + const t = setTimeout(() => { + timers.delete(roomId); + if (getRoomViewerCount(roomId) > 0) return; + void disconnectRoomChat(roomId); + }, delayMs); + timers.set(roomId, t); +} + +/** 살아 있으면 유지(최근 채팅만 재요청), 아니면 재연결 */ +export async function ensureRoomChat(roomId: string) { + cancelScheduledDisconnectRoomChat(roomId); + const room = getRoom(roomId); + if (!room) throw new Error('방을 찾을 수 없습니다.'); + if (!room.settings.chzzkEnabled) { + throw new Error('이 방은 치지직 연동이 꺼져 있습니다.'); + } + + if (isRoomChatLive(roomId)) { + const handle = getSessions().get(roomId); + if (handle) requestRecentChat(handle); + setRoomMeta(roomId, { chatStatus: 'connected', chatError: null }); + notifyRoom(roomId); + return; + } + + await connectRoomChat(roomId); +} + +export async function connectRoomChat(roomId: string) { + cancelScheduledDisconnectRoomChat(roomId); + const room = getRoom(roomId); + if (!room) throw new Error('방을 찾을 수 없습니다.'); + if (!room.settings.chzzkEnabled) { + throw new Error('이 방은 치지직 연동이 꺼져 있습니다.'); + } + const channelId = room.settings.channelId; + if (!channelId) throw new Error('channelId가 없습니다.'); + + await disconnectRoomChat(roomId); + + try { + const info = await fetchChannelInfo(channelId); + setRoomMeta(roomId, { channelName: info.channelName, chatStatus: 'connecting' }); + notifyRoom(roomId); + await openSocket(roomId, channelId); + } catch (e) { + const message = e instanceof Error ? e.message : '채팅 연결 실패'; + setRoomMeta(roomId, { chatStatus: 'error', chatError: message }); + notifyRoom(roomId); + throw e instanceof Error ? e : new Error(message); + } +} + +export async function disconnectRoomChat(roomId: string) { + cancelScheduledDisconnectRoomChat(roomId); + const handle = getSessions().get(roomId); + if (!handle) { + setRoomMeta(roomId, { chatStatus: 'disconnected' }); + notifyRoom(roomId); + return; + } + handle.closed = true; + clearPing(handle); + if (handle.pollTimer) clearInterval(handle.pollTimer); + getSessions().delete(roomId); + try { + handle.ws.close(); + } catch { + // ignore + } + setRoomMeta(roomId, { chatStatus: 'disconnected' }); + notifyRoom(roomId); +} diff --git a/lib/rooms/client.ts b/lib/rooms/client.ts new file mode 100644 index 0000000..e8d6d42 --- /dev/null +++ b/lib/rooms/client.ts @@ -0,0 +1,140 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +export type PublicRoomState = { + id: string; + settings: { + chzzkEnabled: boolean; + liveUrl: string; + channelId: string | null; + cheesePerDice: number; + multiplyByCheese: boolean; + maxMultiplier: number; + }; + diceQueue: number; + channelName: string | null; + chatChannelId: string | null; + chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected'; + chatError: string | null; + lastDonations: Array<{ + id: string; + at: number; + payAmount: number; + diceAdded: number; + donatorNickname: string; + donationText?: string; + simulated?: boolean; + }>; + chatFeed: Array<{ + id: string; + at: number; + nickname: string; + message: string; + kind: 'chat' | 'donation'; + payAmount?: number; + }>; +}; + +export function useRoom(roomId: string | null) { + const [room, setRoom] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!roomId) return; + + const clientId = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `c-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + void fetch(`/api/rooms/${roomId}`) + .then((r) => r.json()) + .then((j) => { + if (j.room) setRoom(j.room as PublicRoomState); + else setError(j.error || '방을 불러오지 못했습니다.'); + }) + .catch(() => setError('방을 불러오지 못했습니다.')); + + const es = new EventSource( + `/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`, + ); + es.addEventListener('room', (ev) => { + try { + setRoom(JSON.parse((ev as MessageEvent).data) as PublicRoomState); + setError(null); + } catch { + // ignore + } + }); + es.onerror = () => { + // keep last state; browser will retry SSE + }; + + const leave = () => { + try { + const payload = JSON.stringify({ clientId }); + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { + navigator.sendBeacon( + `/api/rooms/${roomId}/leave`, + new Blob([payload], { type: 'application/json' }), + ); + } else { + void fetch(`/api/rooms/${roomId}/leave`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, + }); + } + } catch { + // ignore + } + }; + + window.addEventListener('pagehide', leave); + return () => { + window.removeEventListener('pagehide', leave); + es.close(); + leave(); + }; + }, [roomId]); + + const consumeDice = useCallback( + async (count = 1) => { + if (!roomId) return { ok: true as const }; + const res = await fetch(`/api/rooms/${roomId}/consume-dice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count }), + }); + const json = await res.json(); + if (!res.ok) { + return { + ok: false as const, + error: (json.error as string) || '주사위를 소모할 수 없습니다.', + }; + } + setRoom(json.room as PublicRoomState); + return { ok: true as const }; + }, + [roomId], + ); + + const simulateDonation = useCallback( + async (payAmount: number) => { + if (!roomId) return; + const res = await fetch(`/api/rooms/${roomId}/simulate-donation`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ payAmount }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || '시뮬레이션 실패'); + setRoom(json.room as PublicRoomState); + }, + [roomId], + ); + + return { room, error, consumeDice, simulateDonation }; +} diff --git a/lib/rooms/store.ts b/lib/rooms/store.ts new file mode 100644 index 0000000..6c9c090 --- /dev/null +++ b/lib/rooms/store.ts @@ -0,0 +1,353 @@ +import { randomUUID } from 'crypto'; + +export type RoomSettings = { + /** 치지직 채팅 연동 사용 */ + chzzkEnabled: boolean; + /** 라이브/채널 URL 원문 */ + liveUrl: string; + channelId: string | null; + /** 이 원(치즈)당 주사위 1개 */ + cheesePerDice: number; + /** true면 floor(금액/기준), false면 기준 이상 시 1개 */ + multiplyByCheese: boolean; + /** + * 배수 누적 시 한 번 후원으로 쌓을 수 있는 최대 주사위 수. + * 0 이하면 제한 없음. + */ + maxMultiplier: number; +}; + +export type DonationLogItem = { + id: string; + at: number; + payAmount: number; + diceAdded: number; + donatorNickname: string; + donationText?: string; + simulated?: boolean; +}; + +export type ChatFeedItem = { + id: string; + at: number; + nickname: string; + message: string; + kind: 'chat' | 'donation'; + /** 후원일 때 치즈(원) */ + payAmount?: number; +}; + +export type RoomRecord = { + id: string; + createdAt: number; + settings: RoomSettings; + diceQueue: number; + channelName: string | null; + chatChannelId: string | null; + chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected'; + chatError: string | null; + lastDonations: DonationLogItem[]; + chatFeed: ChatFeedItem[]; + /** 이미 반영한 치지직 후원 메시지 키 (재연결 시 중복 방지) */ + seenDonationKeys: Set; +}; + +export type PublicRoomState = { + id: string; + settings: RoomSettings; + diceQueue: number; + channelName: string | null; + chatChannelId: string | null; + chatStatus: RoomRecord['chatStatus']; + chatError: string | null; + lastDonations: DonationLogItem[]; + chatFeed: ChatFeedItem[]; +}; + +type RoomStore = { + rooms: Map; + /** 룸별 접속 클라이언트 ID 집합 */ + viewers: Map>; +}; + +const globalForRooms = globalThis as typeof globalThis & { + __drinkmarbleChatRooms?: RoomStore; +}; + +function getStore(): RoomStore { + if (!globalForRooms.__drinkmarbleChatRooms) { + globalForRooms.__drinkmarbleChatRooms = { + rooms: new Map(), + viewers: new Map(), + }; + } + if (!globalForRooms.__drinkmarbleChatRooms.viewers) { + globalForRooms.__drinkmarbleChatRooms.viewers = new Map(); + } + return globalForRooms.__drinkmarbleChatRooms; +} + +export function createRoom(input: { + chzzkEnabled: boolean; + liveUrl?: string; + channelId?: string | null; + cheesePerDice?: number; + multiplyByCheese?: boolean; + maxMultiplier?: number; +}): RoomRecord { + const room: RoomRecord = { + id: randomUUID(), + createdAt: Date.now(), + settings: { + chzzkEnabled: Boolean(input.chzzkEnabled), + liveUrl: input.liveUrl?.trim() || '', + channelId: input.channelId ?? null, + cheesePerDice: Math.max(1, Math.trunc(input.cheesePerDice ?? 1000)), + multiplyByCheese: input.multiplyByCheese !== false, + maxMultiplier: Math.max(0, Math.trunc(input.maxMultiplier ?? 0)), + }, + diceQueue: 0, + channelName: null, + chatChannelId: null, + chatStatus: 'idle', + chatError: null, + lastDonations: [], + chatFeed: [], + seenDonationKeys: new Set(), + }; + getStore().rooms.set(room.id, room); + return room; +} + +export function getRoom(roomId: string) { + return getStore().rooms.get(roomId) ?? null; +} + +/** 치지직 channelId로 기존 룸 찾기 (가장 최근 생성된 것) */ +export function findRoomByChannelId(channelId: string): RoomRecord | null { + const id = channelId.trim().toLowerCase(); + if (!id) return null; + let latest: RoomRecord | null = null; + for (const room of getStore().rooms.values()) { + if (room.settings.channelId?.toLowerCase() !== id) continue; + if (!latest || room.createdAt > latest.createdAt) latest = room; + } + return latest; +} + +export function setRoomMeta( + roomId: string, + patch: Partial< + Pick + >, +) { + const room = getRoom(roomId); + if (!room) return null; + Object.assign(room, patch); + return room; +} + +export function calcDiceFromDonation(payAmount: number, settings: RoomSettings) { + const amount = Math.max(0, Math.trunc(payAmount)); + const unit = Math.max(1, settings.cheesePerDice); + if (amount < unit) return 0; + if (!settings.multiplyByCheese) return 1; + let dice = Math.floor(amount / unit); + const max = Math.max(0, Math.trunc(settings.maxMultiplier ?? 0)); + if (max > 0) dice = Math.min(dice, max); + return dice; +} + +export function hasSeenDonationKey(roomId: string, key: string) { + const room = getRoom(roomId); + if (!room || !key) return false; + if (!room.seenDonationKeys) room.seenDonationKeys = new Set(); + return room.seenDonationKeys.has(key); +} + +export function markSeenDonationKey(roomId: string, key: string) { + const room = getRoom(roomId); + if (!room || !key) return; + if (!room.seenDonationKeys) room.seenDonationKeys = new Set(); + room.seenDonationKeys.add(key); + // 삽입 순서 기준 오래된 키부터 정리 + while (room.seenDonationKeys.size > 800) { + const oldest = room.seenDonationKeys.values().next().value; + if (oldest == null) break; + room.seenDonationKeys.delete(oldest); + } +} + +export function clearChatFeed(roomId: string) { + const room = getRoom(roomId); + if (!room) return null; + room.chatFeed = []; + return room; +} + +export function addDiceFromDonation( + roomId: string, + input: { + payAmount: number; + donatorNickname?: string; + donationText?: string; + simulated?: boolean; + /** false면 피드/로그만 남기고 주사위 큐에는 넣지 않음 (최근 채팅 히스토리용) */ + applyDice?: boolean; + }, +) { + const room = getRoom(roomId); + if (!room) return null; + const applyDice = input.applyDice !== false; + const diceAdded = applyDice + ? calcDiceFromDonation(input.payAmount, room.settings) + : 0; + if (diceAdded > 0) room.diceQueue += diceAdded; + const nickname = input.donatorNickname?.trim() || '익명'; + const payAmount = Math.max(0, Math.trunc(input.payAmount)); + const log: DonationLogItem = { + id: randomUUID(), + at: Date.now(), + payAmount, + diceAdded, + donatorNickname: nickname, + donationText: input.donationText, + simulated: input.simulated, + }; + room.lastDonations = [log, ...room.lastDonations].slice(0, 20); + appendChatFeed(room, { + nickname, + message: input.donationText?.trim() || '치즈 후원', + kind: 'donation', + payAmount, + }); + return { room, diceAdded }; +} + +export function addChatMessage( + roomId: string, + input: { nickname?: string; message: string }, +) { + const room = getRoom(roomId); + if (!room) return null; + const message = input.message?.trim(); + if (!message) return room; + appendChatFeed(room, { + nickname: input.nickname?.trim() || '익명', + message, + kind: 'chat', + }); + return room; +} + +function appendChatFeed( + room: RoomRecord, + item: Omit, +) { + const entry: ChatFeedItem = { + id: randomUUID(), + at: Date.now(), + ...item, + }; + room.chatFeed = [...room.chatFeed, entry].slice(-60); +} + +export function consumeDice(roomId: string, count = 1) { + const room = getRoom(roomId); + if (!room) return null; + const n = Math.max(1, Math.trunc(count)); + if (room.diceQueue < n) return null; + room.diceQueue -= n; + return room; +} + +export function toPublicRoom(room: RoomRecord): PublicRoomState { + return { + id: room.id, + settings: { ...room.settings }, + diceQueue: room.diceQueue, + channelName: room.channelName, + chatChannelId: room.chatChannelId, + chatStatus: room.chatStatus, + chatError: room.chatError, + lastDonations: room.lastDonations, + chatFeed: room.chatFeed, + }; +} + +type RoomListener = (room: PublicRoomState) => void; + +const globalForListeners = globalThis as typeof globalThis & { + __drinkmarbleChatRoomListeners?: Map>; +}; + +function getListeners() { + if (!globalForListeners.__drinkmarbleChatRoomListeners) { + globalForListeners.__drinkmarbleChatRoomListeners = new Map(); + } + return globalForListeners.__drinkmarbleChatRoomListeners; +} + +export function subscribeRoom(roomId: string, listener: RoomListener) { + const map = getListeners(); + let set = map.get(roomId); + if (!set) { + set = new Set(); + map.set(roomId, set); + } + set.add(listener); + return () => { + set!.delete(listener); + if (set!.size === 0) map.delete(roomId); + }; +} + +export function notifyRoom(roomId: string) { + const room = getRoom(roomId); + if (!room) return; + const state = toPublicRoom(room); + const set = getListeners().get(roomId); + if (!set) return; + for (const listener of set) { + try { + listener(state); + } catch { + // ignore + } + } +} + +export function getRoomViewerCount(roomId: string) { + return getStore().viewers.get(roomId)?.size ?? 0; +} + +/** 클라이언트 입장. 같은 clientId면 한 번만 집계 */ +export function addRoomViewer(roomId: string, clientId: string) { + const store = getStore(); + if (!store.rooms.has(roomId) || !clientId) return 0; + let set = store.viewers.get(roomId); + if (!set) { + set = new Set(); + store.viewers.set(roomId, set); + } + set.add(clientId); + return set.size; +} + +/** + * 클라이언트 퇴장. 이미 빠진 clientId면 no-op. + * viewer가 0이 되면 emptied=true (채팅 끊어도 됨). + */ +export function removeRoomViewer(roomId: string, clientId: string) { + const store = getStore(); + const set = store.viewers.get(roomId); + if (!set || !clientId) { + return { viewers: set?.size ?? 0, emptied: !set || set.size === 0 }; + } + set.delete(clientId); + if (set.size === 0) { + store.viewers.delete(roomId); + return { viewers: 0, emptied: true }; + } + return { viewers: set.size, emptied: false }; +} diff --git a/logs/build.log b/logs/build.log new file mode 100644 index 0000000..f8f2be6 --- /dev/null +++ b/logs/build.log @@ -0,0 +1,31 @@ + +> drinkmarble@0.1.0 build +> next build + +▲ Next.js 16.3.3 (Turbopack) +⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble). + To use this directory, set `turbopack.root` in your Next.js config. + +✓ Running next.config.ts took 25ms + + Creating an optimized production build ... +✓ Compiled successfully in 3.6s + Running TypeScript ... + Finished TypeScript in 2.6s ... + Collecting page data using 7 workers ... + Generating static pages using 7 workers (0/6) ... + Generating static pages using 7 workers (1/6) + Generating static pages using 7 workers (2/6) + Generating static pages using 7 workers (4/6) +✓ Generating static pages using 7 workers (6/6) in 677ms + Finalizing page optimization ... + +Route (app) +┌ ○ / +├ ○ /_not-found +├ ○ /game +└ ○ /main + + +○ (Static) prerendered as static content + diff --git a/logs/drinkmarble.log b/logs/drinkmarble.log new file mode 100644 index 0000000..449ae23 --- /dev/null +++ b/logs/drinkmarble.log @@ -0,0 +1,26 @@ + +> drinkmarble@0.1.0 start +> next start -p 13000 + +▲ Next.js 16.3.3 +- Local: http://localhost:13000 +- Network: http://172.30.1.31:13000 +✓ Ready in 149ms +⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble). + To use this directory, set `outputFileTracingRoot` in your Next.js config. + +✓ Running next.config.ts took 30ms +Error: Could not find a production build in the '.next' directory. Try building your app with 'next build' before starting the production server. https://nextjs.org/docs/messages/production-start-no-build-id + at ignore-listed frames + +> drinkmarble@0.1.0 start +> next start -p 13000 + +▲ Next.js 16.3.3 +- Local: http://localhost:13000 +- Network: http://172.30.1.31:13000 +✓ Ready in 151ms +⚠ Warning: Next.js ignored package-lock.json in /home/kimyu because it is outside the current Git repository (/home/kimyu/workspace/nextjs/drinkmarble). + To use this directory, set `outputFileTracingRoot` in your Next.js config. + +✓ Running next.config.ts took 30ms diff --git a/logs/drinkmarble.pid b/logs/drinkmarble.pid new file mode 100644 index 0000000..3fff9e9 --- /dev/null +++ b/logs/drinkmarble.pid @@ -0,0 +1 @@ +2448360 diff --git a/package-lock.json b/package-lock.json index bc83b62..e8cb009 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,15 @@ "react": "19.2.8", "react-dom": "19.2.8", "three": "^0.185.1", - "three.js": "^0.77.0" + "three.js": "^0.77.0", + "ws": "^8.21.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "eslint": "^9", "eslint-config-next": "16.3.3", "tailwindcss": "^4", @@ -1744,6 +1746,15 @@ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -6940,6 +6951,26 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f40bc40..76dc3e0 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,15 @@ "react": "19.2.8", "react-dom": "19.2.8", "three": "^0.185.1", - "three.js": "^0.77.0" + "three.js": "^0.77.0", + "ws": "^8.21.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "eslint": "^9", "eslint-config-next": "16.3.3", "tailwindcss": "^4",