From 73520f9f0675aef0bf2bc1bae3c708ea9e997756 Mon Sep 17 00:00:00 2001 From: kimyu Date: Fri, 18 Sep 2026 04:09:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=98=EC=A7=80=EC=A7=81=20=EC=95=B1?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=97=B0=EB=8F=99=ED=95=98=EA=B3=A0,=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=ED=99=95=EC=9D=B8=EB=90=9C=20ID=EB=A7=8C?= =?UTF-8?q?=20=EC=A0=91=EC=86=8D=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95,=20=EA=B0=9C=EB=B0=9C=EC=9A=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=EB=8A=94=20=EC=B1=84=EB=84=90ID=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EC=A0=91=EC=86=8D?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD.=20=EC=B5=9C=EC=B4=88=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EB=B0=B0=EC=B9=98=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/logout/route.ts | 16 ++ app/api/auth/session/route.ts | 19 ++ app/api/chzzk/callback/route.ts | 112 +++++++++ app/api/chzzk/login/route.ts | 44 ++++ app/api/rooms/[roomId]/add-dice/route.ts | 31 +++ app/api/rooms/[roomId]/consume-dice/route.ts | 4 + app/api/rooms/[roomId]/events/route.ts | 6 +- app/api/rooms/[roomId]/route.ts | 5 +- .../rooms/[roomId]/simulate-donation/route.ts | 4 + app/api/rooms/join/route.ts | 45 +++- app/game/board-config.ts | 120 ++++++++-- app/game/cell-event.ts | 19 +- app/game/diceplane.tsx | 87 ++++++- app/game/page.tsx | 217 +++++++++++++----- app/page.tsx | 183 ++++++++++++--- lib/auth/mode.ts | 46 ++++ lib/auth/public-origin.ts | 59 +++++ lib/auth/room-access.ts | 68 ++++++ lib/auth/session.ts | 137 +++++++++++ lib/chzzk/oauth.ts | 89 +++++++ lib/rooms/client.ts | 72 ++++-- lib/rooms/store.ts | 9 + lib/ui/debug.ts | 9 + logs/release.log | 9 + next.config.ts | 20 +- package.json | 4 +- 26 files changed, 1272 insertions(+), 162 deletions(-) create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/session/route.ts create mode 100644 app/api/chzzk/callback/route.ts create mode 100644 app/api/chzzk/login/route.ts create mode 100644 app/api/rooms/[roomId]/add-dice/route.ts create mode 100644 lib/auth/mode.ts create mode 100644 lib/auth/public-origin.ts create mode 100644 lib/auth/room-access.ts create mode 100644 lib/auth/session.ts create mode 100644 lib/chzzk/oauth.ts create mode 100644 lib/ui/debug.ts create mode 100644 logs/release.log diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..404a1ab --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import { AUTH_COOKIE } from '@/lib/auth/session'; + +export const runtime = 'nodejs'; + +export async function POST() { + const res = NextResponse.json({ ok: true }); + res.cookies.set(AUTH_COOKIE, '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 0, + }); + return res; +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..b0ae479 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; +import { authModePublic } from '@/lib/auth/room-access'; +import { readAuthSession } from '@/lib/auth/session'; + +export const runtime = 'nodejs'; + +export async function GET() { + const session = await readAuthSession(); + return NextResponse.json({ + ...authModePublic(), + session: session + ? { + channelId: session.channelId, + channelName: session.channelName, + exp: session.exp, + } + : null, + }); +} diff --git a/app/api/chzzk/callback/route.ts b/app/api/chzzk/callback/route.ts new file mode 100644 index 0000000..1fa5a68 --- /dev/null +++ b/app/api/chzzk/callback/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; +import { getPublicOrigin } from '@/lib/auth/public-origin'; +import { + AUTH_COOKIE, + OAUTH_STATE_COOKIE, + authCookieOptions, + createAuthSession, + parseOAuthStateCookie, + serializeAuthSession, +} from '@/lib/auth/session'; +import { + exchangeAuthorizationCode, + fetchAuthedUser, + revokeToken, +} from '@/lib/chzzk/oauth'; +import { + cancelScheduledDisconnectRoomChat, + ensureRoomChat, +} from '@/lib/chzzk/chat-session'; +import { + createRoom, + findRoomByChannelId, + getRoom, +} from '@/lib/rooms/store'; + +export const runtime = 'nodejs'; + +function homeRedirect(origin: string, error?: string) { + const url = new URL('/', origin); + if (error) url.searchParams.set('authError', error); + else url.searchParams.set('auth', 'ok'); + return url; +} + +/** + * 치지직 OAuth 콜백. + * 채널 ID 확인 → 우리 세션 쿠키만 저장 → 치지직 토큰 폐기 → 룸 입장 + */ +export async function GET(request: Request) { + const url = new URL(request.url); + const origin = getPublicOrigin(request); + const code = url.searchParams.get('code') || ''; + const state = url.searchParams.get('state') || ''; + const stateCookie = request.headers + .get('cookie') + ?.match(/(?:^|;\s*)drinkmarble_oauth_state=([^;]+)/)?.[1]; + + const decodedState = stateCookie + ? parseOAuthStateCookie(decodeURIComponent(stateCookie)) + : null; + + if (!code || !state || !decodedState || decodedState.state !== state) { + const res = NextResponse.redirect( + homeRedirect(origin, '로그인 검증에 실패했습니다. 다시 시도해 주세요.'), + ); + res.cookies.delete(OAUTH_STATE_COOKIE); + return res; + } + + let accessToken: string | null = null; + let refreshToken: string | null = null; + + try { + const tokens = await exchangeAuthorizationCode(code, state); + accessToken = tokens.accessToken; + refreshToken = tokens.refreshToken; + + const user = await fetchAuthedUser(accessToken); + const session = createAuthSession(user.channelId, user.channelName); + + // 치지직 토큰은 저장하지 않고 즉시 폐기 + await revokeToken(accessToken, 'access_token'); + if (refreshToken) await revokeToken(refreshToken, 'refresh_token'); + accessToken = null; + refreshToken = null; + + let room = findRoomByChannelId(user.channelId); + if (!room) { + room = createRoom({ + chzzkEnabled: true, + liveUrl: `https://chzzk.naver.com/live/${user.channelId}`, + channelId: user.channelId, + cheesePerDice: decodedState.cheesePerDice, + multiplyByCheese: decodedState.multiplyByCheese, + maxMultiplier: decodedState.maxMultiplier, + }); + } + + cancelScheduledDisconnectRoomChat(room.id); + try { + await ensureRoomChat(room.id); + } catch { + // 방송 중이 아니어도 세션·룸은 유지하고 게임으로 보냄 + } + + // room.id = 서버 룸 UUID (게임 URL용) + // user.channelId = 치지직 채널 (세션·room.settings.channelId에 보관) + const roomId = getRoom(room.id)?.id ?? room.id; + const res = NextResponse.redirect(new URL(`/game?roomId=${roomId}`, origin)); + res.cookies.set(AUTH_COOKIE, serializeAuthSession(session), authCookieOptions()); + res.cookies.delete(OAUTH_STATE_COOKIE); + return res; + } catch (e) { + if (accessToken) await revokeToken(accessToken, 'access_token'); + if (refreshToken) await revokeToken(refreshToken, 'refresh_token'); + const message = + e instanceof Error ? e.message : '치지직 로그인에 실패했습니다.'; + const res = NextResponse.redirect(homeRedirect(origin, message)); + res.cookies.delete(OAUTH_STATE_COOKIE); + return res; + } +} diff --git a/app/api/chzzk/login/route.ts b/app/api/chzzk/login/route.ts new file mode 100644 index 0000000..60efbcb --- /dev/null +++ b/app/api/chzzk/login/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { isChzzkOAuthConfigured } from '@/lib/auth/mode'; +import { + createOAuthState, + OAUTH_STATE_COOKIE, + authCookieOptions, +} from '@/lib/auth/session'; +import { buildChzzkLoginUrl } from '@/lib/chzzk/oauth'; + +export const runtime = 'nodejs'; + +/** + * 치지직 로그인 시작. + * 쿼리로 치즈 설정을 넘기면 콜백에서 바로 룸 입장에 사용한다. + */ +export async function GET(request: Request) { + if (!isChzzkOAuthConfigured()) { + return NextResponse.json( + { + error: + '치지직 OAuth가 설정되지 않았습니다. CHZZK_CLIENT_ID / CHZZK_CLIENT_SECRET / CHZZK_REDIRECT_URI 를 확인해 주세요.', + }, + { status: 503 }, + ); + } + + const url = new URL(request.url); + const cheesePerDice = Number(url.searchParams.get('cheesePerDice') || 1000); + const multiplyByCheese = url.searchParams.get('multiplyByCheese') !== '0'; + const maxMultiplier = Number(url.searchParams.get('maxMultiplier') || 0); + + const { state, sealed } = createOAuthState({ + cheesePerDice: Number.isFinite(cheesePerDice) ? cheesePerDice : 1000, + multiplyByCheese, + maxMultiplier: Number.isFinite(maxMultiplier) ? maxMultiplier : 0, + }); + + const res = NextResponse.redirect(buildChzzkLoginUrl(state)); + res.cookies.set(OAUTH_STATE_COOKIE, sealed, { + ...authCookieOptions(600), + maxAge: 600, + }); + return res; +} diff --git a/app/api/rooms/[roomId]/add-dice/route.ts b/app/api/rooms/[roomId]/add-dice/route.ts new file mode 100644 index 0000000..7a976db --- /dev/null +++ b/app/api/rooms/[roomId]/add-dice/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { assertChannelRoomAccess } from '@/lib/auth/room-access'; +import { addDice, 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 access = await assertChannelRoomAccess(room, request); + if (!access.ok) return access.response; + + const body = (await request.json().catch(() => ({}))) as { count?: number }; + const count = Number(body.count ?? 1); + if (!Number.isFinite(count) || count < 1) { + return NextResponse.json({ error: 'count는 1 이상이어야 합니다.' }, { status: 400 }); + } + + const updated = addDice(roomId, count); + if (!updated) { + return NextResponse.json({ error: '처리 실패' }, { status: 500 }); + } + notifyRoom(roomId); + return NextResponse.json({ room: toPublicRoom(updated) }); +} diff --git a/app/api/rooms/[roomId]/consume-dice/route.ts b/app/api/rooms/[roomId]/consume-dice/route.ts index d8bb0a8..5c1c9d6 100644 --- a/app/api/rooms/[roomId]/consume-dice/route.ts +++ b/app/api/rooms/[roomId]/consume-dice/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { assertChannelRoomAccess } from '@/lib/auth/room-access'; import { consumeDice, getRoom, @@ -17,6 +18,9 @@ export async function POST(request: Request, { params }: Params) { return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 }); } + const access = await assertChannelRoomAccess(room, request); + if (!access.ok) return access.response; + const body = (await request.json().catch(() => ({}))) as { count?: number }; const count = Math.max(1, Math.trunc(body.count ?? 1)); diff --git a/app/api/rooms/[roomId]/events/route.ts b/app/api/rooms/[roomId]/events/route.ts index fe3dd8d..7c18cd4 100644 --- a/app/api/rooms/[roomId]/events/route.ts +++ b/app/api/rooms/[roomId]/events/route.ts @@ -2,6 +2,7 @@ import { cancelScheduledDisconnectRoomChat, scheduleDisconnectRoomChat, } from '@/lib/chzzk/chat-session'; +import { assertChannelRoomAccess } from '@/lib/auth/room-access'; import { addRoomViewer, getRoom, @@ -26,6 +27,9 @@ export async function GET(request: Request, { params }: Params) { }); } + const access = await assertChannelRoomAccess(room, request); + if (!access.ok) return access.response; + const clientId = new URL(request.url).searchParams.get('clientId')?.trim() || crypto.randomUUID(); @@ -40,14 +44,12 @@ export async function GET(request: Request, { params }: Params) { 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; diff --git a/app/api/rooms/[roomId]/route.ts b/app/api/rooms/[roomId]/route.ts index 42ad143..3c17fb7 100644 --- a/app/api/rooms/[roomId]/route.ts +++ b/app/api/rooms/[roomId]/route.ts @@ -1,15 +1,18 @@ import { NextResponse } from 'next/server'; +import { assertChannelRoomAccess } from '@/lib/auth/room-access'; 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) { +export async function GET(request: Request, { params }: Params) { const { roomId } = await params; const room = getRoom(roomId); if (!room) { return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 }); } + const access = await assertChannelRoomAccess(room, request); + if (!access.ok) return access.response; 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 index f2f5237..143b508 100644 --- a/app/api/rooms/[roomId]/simulate-donation/route.ts +++ b/app/api/rooms/[roomId]/simulate-donation/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { assertChannelRoomAccess } from '@/lib/auth/room-access'; import { addDiceFromDonation, getRoom, @@ -17,6 +18,9 @@ export async function POST(request: Request, { params }: Params) { return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 }); } + const access = await assertChannelRoomAccess(room, request); + if (!access.ok) return access.response; + const body = (await request.json().catch(() => ({}))) as { payAmount?: number; donatorNickname?: string; diff --git a/app/api/rooms/join/route.ts b/app/api/rooms/join/route.ts index 153e8ce..170f82f 100644 --- a/app/api/rooms/join/route.ts +++ b/app/api/rooms/join/route.ts @@ -1,5 +1,11 @@ import { NextResponse } from 'next/server'; import { parseChzzkChannelId } from '@/lib/chzzk/channel'; +import { + allowChannelIdJoin, + isChzzkOAuthConfigured, + requireChzzkLoginForChannelRooms, +} from '@/lib/auth/mode'; +import { getRequestAuthSession } from '@/lib/auth/room-access'; import { cancelScheduledDisconnectRoomChat, ensureRoomChat, @@ -15,7 +21,8 @@ export const runtime = 'nodejs'; /** * 치지직 채널 ID(또는 라이브 URL)로 룸 입장. - * 없으면 전달된 치즈 설정으로 새 룸을 만들고 채팅 연결을 시도한다. + * - 개발: 채널 ID 직접 입장 가능 (+ 로그인 세션 있으면 본인 채널도 가능) + * - 릴리즈: 로그인 세션의 채널 ID와 일치할 때만 입장 */ export async function POST(request: Request) { const body = (await request.json().catch(() => ({}))) as { @@ -25,8 +32,39 @@ export async function POST(request: Request) { maxMultiplier?: number; }; + const session = await getRequestAuthSession(request); const raw = body.channelIdOrUrl || ''; - const channelId = parseChzzkChannelId(raw); + let channelId = parseChzzkChannelId(raw); + + if (requireChzzkLoginForChannelRooms()) { + if (!session) { + return NextResponse.json( + { + error: + '릴리즈 환경에서는 치지직 로그인 후 본인 채널로만 입장할 수 있습니다.', + code: 'CHZZK_AUTH_REQUIRED', + allowChannelIdJoin: allowChannelIdJoin(), + oauthConfigured: isChzzkOAuthConfigured(), + }, + { status: 401 }, + ); + } + // 입력된 채널이 있으면 세션과 일치해야 함. 없으면 세션 채널 사용. + if (channelId && channelId !== session.channelId) { + return NextResponse.json( + { + error: '로그인한 채널과 다른 채널 ID로는 입장할 수 없습니다.', + code: 'CHANNEL_MISMATCH', + }, + { status: 403 }, + ); + } + channelId = session.channelId; + } else if (!channelId) { + // 개발: 세션만 있으면 세션 채널로 입장 가능 + if (session?.channelId) channelId = session.channelId; + } + if (!channelId) { return NextResponse.json( { @@ -44,7 +82,7 @@ export async function POST(request: Request) { created = true; room = createRoom({ chzzkEnabled: true, - liveUrl: raw.trim(), + liveUrl: raw.trim() || `https://chzzk.naver.com/live/${channelId}`, channelId, cheesePerDice: body.cheesePerDice, multiplyByCheese: body.multiplyByCheese, @@ -52,7 +90,6 @@ export async function POST(request: Request) { }); } - // 이전 세션 leave가 늦게 와도 끊기지 않게 예약 해제 + 실제 WS 생존 여부 확인 cancelScheduledDisconnectRoomChat(room.id); if (room.settings.chzzkEnabled) { diff --git a/app/game/board-config.ts b/app/game/board-config.ts index 4a3e90a..6c8b86a 100644 --- a/app/game/board-config.ts +++ b/app/game/board-config.ts @@ -5,15 +5,17 @@ export const MIN_PLANE_SIZE = 4; export const MAX_PLANE_SIZE = 16; export const CELL_TYPE_PRESETS = [ - '한잔', - '안주', - '모두한잔', - '두잔', - '벌칙1', - '벌칙2', + '한잔마셔', + '~~체 3분', + '안주 한입', + '노래 1절', + '다같이한잔', + '영어금지 3분', + '두잔마셔', + '애교/매도 100자', '무인도', '역방향', - '비행기', + '세계여행', ] as const; export type CellEventOption = { @@ -25,7 +27,7 @@ export const CELL_EVENT_OPTIONS: CellEventOption[] = [ { id: null, label: '없음' }, { id: 'island', label: '무인도' }, { id: 'reverse', label: '역방향' }, - { id: 'airplane', label: '비행기 → 시작' }, + { id: 'airplane', label: '세계여행 (원하는 칸)' }, ]; /** 저장용 칸 설정 (좌표는 지오메트리에서 합친다) */ @@ -46,7 +48,46 @@ export type BoardConfig = { }; const BOARD_STORAGE_KEY = 'drinkmarble.board'; -const BOARD_VERSION = 2; +/** v5: 추천 배치 아이콘(이모지) 제거 */ +const BOARD_VERSION = 5; + +/** 기본 추천 배치 (시계방향, 시작 포함 28칸 = 9×7) */ +const DEFAULT_BOARD_LAYOUT: ReadonlyArray<{ + type: string; + eventId?: string | null; +}> = [ + { type: '시작', eventId: null }, + { type: '한잔마셔' }, + { type: '~~체 3분' }, + { type: '안주 한입' }, + { type: '노래 1절' }, + { type: '다같이한잔' }, + { type: '영어금지 3분' }, + { type: '애교/매도 100자' }, + { type: '무인도', eventId: 'island' }, + { type: '안주 한입' }, + { type: '두잔마셔' }, + { type: '~~체 3분' }, + { type: '한잔마셔' }, + { type: '애교/매도 100자' }, + { type: '역방향', eventId: 'reverse' }, + { type: '영어금지 3분' }, + { type: '안주 한입' }, + { type: '다같이한잔' }, + { type: '노래 1절' }, + { type: '한잔마셔' }, + { type: '~~체 3분' }, + { type: '두잔마셔' }, + { type: '세계여행', eventId: 'airplane' }, + { type: '애교/매도 100자' }, + { type: '안주 한입' }, + { type: '영어금지 3분' }, + { type: '다같이한잔' }, + { type: '한잔마셔' }, +]; + +const SPECIAL_TYPE_NAMES = new Set(['무인도', '역방향', '비행기', '세계여행']); + export function clampPlaneSize(n: number) { return Math.min(MAX_PLANE_SIZE, Math.max(MIN_PLANE_SIZE, Math.round(n))); @@ -57,9 +98,7 @@ export function perimeterLength(width: number, height: number) { } function randomPresetType() { - const pool = CELL_TYPE_PRESETS.filter( - (t) => t !== '무인도' && t !== '역방향' && t !== '비행기', - ); + const pool = CELL_TYPE_PRESETS.filter((t) => !SPECIAL_TYPE_NAMES.has(t)); return pool[Math.floor(Math.random() * pool.length)]; } @@ -80,10 +119,37 @@ function defaultSpecials(width: number, height: number): Record cellFromLayout(entry, index)); + cells[0] = { type: '시작', eventId: null, imageSrc: null, contentVisible: true }; + return { version: BOARD_VERSION, width: w, height: h, cells }; +} + /** 시계방향 둘레 좌표만 만든다. */ export function buildPerimeterGeometry( width: number, @@ -116,6 +182,10 @@ export function createRandomBoardConfig( ): BoardConfig { const w = clampPlaneSize(width); const h = clampPlaneSize(height); + if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) { + return createDefaultBoardConfig(); + } + const geo = buildPerimeterGeometry(w, h); const specials = defaultSpecials(w, h); const cells: BoardCellConfig[] = geo.map((_, index) => { @@ -180,7 +250,10 @@ export function readStoredBoard(): BoardConfig | null { try { const raw = window.localStorage.getItem(BOARD_STORAGE_KEY); if (!raw) return null; - return normalizeBoardConfig(JSON.parse(raw) as BoardConfig); + const parsed = JSON.parse(raw) as BoardConfig; + // 구버전(랜덤 기본)은 추천 배치로 교체 + if ((parsed.version ?? 0) < BOARD_VERSION) return null; + return normalizeBoardConfig(parsed); } catch { return null; } @@ -195,7 +268,7 @@ export function writeStoredBoard(config: BoardConfig) { export function getOrCreateBoardConfig(): BoardConfig { const existing = readStoredBoard(); if (existing) return existing; - const created = createRandomBoardConfig(); + const created = createDefaultBoardConfig(); writeStoredBoard(created); return created; } @@ -209,15 +282,24 @@ export function clearBoardConfig() { window.localStorage.removeItem(BOARD_STORAGE_KEY); } +/** 추천 기본 배치로 되돌린다 (크기 포함 9×7 고정). */ +export function resetToDefaultBoard(): BoardConfig { + const created = createDefaultBoardConfig(); + writeStoredBoard(created); + return created; +} + export function randomizeAndSaveBoard( width?: number, height?: number, current = getOrCreateBoardConfig(), ): BoardConfig { - const created = createRandomBoardConfig( - width ?? current.width, - height ?? current.height, - ); + const w = width ?? current.width; + const h = height ?? current.height; + if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) { + return resetToDefaultBoard(); + } + const created = createRandomBoardConfig(w, h); writeStoredBoard(created); return created; } diff --git a/app/game/cell-event.ts b/app/game/cell-event.ts index 9a00f1d..51193ca 100644 --- a/app/game/cell-event.ts +++ b/app/game/cell-event.ts @@ -25,6 +25,8 @@ export type TurnResult = { message: string; /** 비행기 등: 즉시 이동할 칸 */ teleportTo?: number; + /** 비행기: 도착 칸을 플레이어가 직접 고름 */ + chooseDestination?: boolean; }; export interface CellEvent { @@ -54,20 +56,14 @@ export class ReverseEvent implements CellEvent { } } -/** 비행기: 지정한 칸으로 즉시 이동 */ +/** 비행기: 원하는 칸을 고른 뒤 그곳으로 이동 */ export class AirplaneEvent implements CellEvent { readonly id = 'airplane'; - constructor(private readonly targetIndex: number) {} - - apply(ctx: TurnContext): TurnResult { - const target = - ((this.targetIndex % ctx.board.length) + ctx.board.length) % ctx.board.length; - ctx.chessman.teleport(target); - const dest = ctx.board[target]; + apply(_ctx: TurnContext): TurnResult { return { - message: `비행기! → ${dest?.type ?? target}`, - teleportTo: target, + message: '원하는 곳을 클릭해주세요', + chooseDestination: true, }; } } @@ -75,8 +71,7 @@ export class AirplaneEvent implements CellEvent { export const CELL_EVENTS: Record = { island: new IslandEvent(), reverse: new ReverseEvent(), - // 기본 도착지는 시작(0). 판 생성 시 덮어쓸 수 있다. - airplane: new AirplaneEvent(0), + airplane: new AirplaneEvent(), }; export function getCellEvent(eventId: string | null | undefined): CellEvent | null { diff --git a/app/game/diceplane.tsx b/app/game/diceplane.tsx index 751e1e2..d82af86 100644 --- a/app/game/diceplane.tsx +++ b/app/game/diceplane.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type ReactNode } 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'; import { getAssets } from './asset-storage'; +import { SHOW_DEBUG_UI } from '@/lib/ui/debug'; import { buildPlaneCells, cellColorFor, @@ -29,12 +30,15 @@ export default function DicePlane({ onConsumeDice, chatFeed = [], showChatFeed = false, + settingsButton = null, }: { requireDiceQueue?: boolean; diceQueue?: number; onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>; chatFeed?: ChatFeedItem[]; showChatFeed?: boolean; + /** 판 안쪽 왼쪽 아래(둘레 칸 밖)에 배치할 설정 버튼 */ + settingsButton?: ReactNode; }) { const [boardReady, setBoardReady] = useState(false); const [planeSize, setPlaneSize] = useState<{ width: number; height: number }>({ @@ -57,7 +61,9 @@ export default function DicePlane({ const [landedKey, setLandedKey] = useState(0); const [statusText, setStatusText] = useState('시계방향'); const [islandTurnsLeft, setIslandTurnsLeft] = useState(0); + const [pickingDestination, setPickingDestination] = useState(false); const moveTimerRef = useRef | null>(null); + const pendingRollRef = useRef(0); // SSR/초기 렌더에서 랜덤 판을 쓰지 않고, 브라우저에 저장된 설정을 로드 useEffect(() => { @@ -72,6 +78,7 @@ export default function DicePlane({ setLandedLabel(null); setStatusText('시계방향'); setIslandTurnsLeft(0); + setPickingDestination(false); setDicePlaneArray(cells); setBoardReady(true); }, []); @@ -104,6 +111,15 @@ export default function DicePlane({ setStatusText(man.facing === 1 ? '시계방향' : '반시계방향'); setIslandTurnsLeft(man.islandTurnsLeft); + if (result?.chooseDestination) { + pendingRollRef.current = roll; + setPickingDestination(true); + showBanner(result.message); + return; + } + + setPickingDestination(false); + if (result?.teleportTo != null) { setChessmanIndex(result.teleportTo); setHopKey((k) => k + 1); @@ -116,6 +132,19 @@ export default function DicePlane({ [showBanner], ); + const onCellPick = useCallback( + (index: number) => { + if (!pickingDestination || moving) return; + const man = chessmanRef.current; + man.teleport(index); + setChessmanIndex(index); + setHopKey((k) => k + 1); + setPickingDestination(false); + applyLanding(index, pendingRollRef.current); + }, + [pickingDestination, moving, applyLanding], + ); + const animateMove = useCallback( (steps: number, roll: number) => { const amount = Math.max(0, Math.trunc(steps)); @@ -153,6 +182,7 @@ export default function DicePlane({ if (roll === 0) return; setLandedLabel(null); + setPickingDestination(false); const man = chessmanRef.current; if (man.isOnIsland) { @@ -217,6 +247,16 @@ export default function DicePlane({ return (
onCellPick(index)} + onKeyDown={(e) => { + if (!pickingDestination) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCellPick(index); + } + }} style={{ gridColumn: plane.x + 1, gridRow: plane.y + 1, @@ -238,12 +278,16 @@ export default function DicePlane({ padding: 4, boxShadow: isLanded ? '0 0 0 3px #fde68a, 0 0 18px rgba(253, 224, 71, 0.75)' - : isStart - ? 'inset 0 0 0 2px #a7f3d0' - : undefined, + : pickingDestination + ? '0 0 0 2px rgba(125, 211, 252, 0.85)' + : isStart + ? 'inset 0 0 0 2px #a7f3d0' + : undefined, transform: isLanded ? 'scale(1.04)' : undefined, transition: 'box-shadow 180ms ease, transform 180ms ease', - zIndex: isLanded ? 4 : undefined, + zIndex: pickingDestination ? 8 : isLanded ? 4 : undefined, + cursor: pickingDestination ? 'pointer' : undefined, + pointerEvents: pickingDestination ? 'auto' : undefined, textShadow: showContent && plane.imageSrc ? '0 1px 3px rgba(0,0,0,0.85)' @@ -276,13 +320,34 @@ export default function DicePlane({ >
+ {settingsButton && ( +
+
{settingsButton}
+
+ )} + {showChatFeed && (
-
- {statusText} - {islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''} -
+ {SHOW_DEBUG_UI && ( +
+ {statusText} + {islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''} +
+ )} {landedLabel && (
(null); + const { room, error, consumeDice, simulateDonation, addDice } = useRoom(roomId); const chzzkOn = Boolean(room?.settings.chzzkEnabled); const manualMode = Boolean(roomId) && room != null && !chzzkOn; @@ -22,6 +26,21 @@ function GamePageInner() { setReady(true); }, []); + useEffect(() => { + if (!settingsOpen) setAddDiceHint(null); + }, [settingsOpen]); + + const onAddDice = async () => { + if (!roomId || !chzzkOn) return; + const count = Math.max(1, Math.trunc(addDiceCount) || 1); + const result = await addDice(count); + if (!result.ok) { + setAddDiceHint(result.error || '추가 실패'); + return; + } + setAddDiceHint(`주사위 +${count} 추가했습니다.`); + }; + return (
{ready && ( @@ -36,67 +55,63 @@ function GamePageInner() { )}
-
- - 홈 - - {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" - /> - + {SHOW_DEBUG_UI && ( +
+ {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}

} -
- )} -
+ )} + {manualMode && ( +
+

수동 모드

+

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

+ {error &&

{error}

} +
+ )} +
+ )}
setSettingsOpen((v) => !v)} + className="rounded-md bg-black/70 px-3 py-1.5 text-sm font-semibold text-white shadow-md hover:bg-black/85" + > + 설정 + + } />
+ + {settingsOpen && ( +
setSettingsOpen(false)} + > +
e.stopPropagation()} + > +
+

설정

+ +
+ +
+ + 홈으로 + + + {roomId && chzzkOn ? ( +
+

주사위 큐 수동 추가

+

+ 현재 큐: {room?.diceQueue ?? 0} +

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

{addDiceHint}

+ )} +
+ ) : ( +

+ {manualMode + ? '수동 모드에서는 주사위 큐 없이 바로 굴릴 수 있습니다.' + : '치지직 연동 방에 입장하면 여기서 주사위 카운트를 추가할 수 있습니다.'} +

+ )} +
+
+
+ )}
); } diff --git a/app/page.tsx b/app/page.tsx index 4a0e4a6..ed159c4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -38,7 +38,7 @@ import { clearBoardConfig, getOrCreateBoardConfig, perimeterLength, - randomizeAndSaveBoard, + resetToDefaultBoard, resizeBoardConfig, setAllCellsContentVisible, setBoardConfig, @@ -169,9 +169,9 @@ function BoardEditor({
@@ -379,15 +379,57 @@ export default function Home() { const [joinChannelInput, setJoinChannelInput] = useState(''); const [roomBusy, setRoomBusy] = useState(false); const [roomError, setRoomError] = useState(null); + const [authMode, setAuthMode] = useState<{ + allowChannelIdJoin: boolean; + requireChzzkLogin: boolean; + oauthConfigured: boolean; + } | null>(null); + const [authSession, setAuthSession] = useState<{ + channelId: string; + channelName: string | null; + } | null>(null); const reload = () => { setAssets(getAssets()); setBoard(getOrCreateBoardConfig()); }; + const refreshAuth = async () => { + try { + const res = await fetch('/api/auth/session', { cache: 'no-store' }); + const json = await res.json(); + setAuthMode({ + allowChannelIdJoin: Boolean(json.allowChannelIdJoin), + requireChzzkLogin: Boolean(json.requireChzzkLogin), + oauthConfigured: Boolean(json.oauthConfigured), + }); + setAuthSession( + json.session + ? { + channelId: String(json.session.channelId), + channelName: json.session.channelName ?? null, + } + : null, + ); + } catch { + setAuthMode({ + allowChannelIdJoin: true, + requireChzzkLogin: false, + oauthConfigured: false, + }); + setAuthSession(null); + } + }; + useEffect(() => { reload(); setReady(true); + void refreshAuth(); + if (typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const authError = params.get('authError'); + if (authError) setRoomError(authError); + } }, []); const roomCheeseOptions = () => ({ @@ -396,6 +438,45 @@ export default function Home() { maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0, }); + const startChzzkLogin = () => { + const opts = roomCheeseOptions(); + const q = new URLSearchParams({ + cheesePerDice: String(opts.cheesePerDice), + multiplyByCheese: opts.multiplyByCheese ? '1' : '0', + maxMultiplier: String(opts.maxMultiplier), + }); + window.location.href = `/api/chzzk/login?${q.toString()}`; + }; + + const joinWithSession = async () => { + try { + setRoomBusy(true); + setRoomError(null); + const res = await fetch('/api/rooms/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...roomCheeseOptions(), + }), + }); + 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 logoutAuth = async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + setAuthSession(null); + }; + const joinByChzzkId = async () => { const value = joinChannelInput.trim(); if (!value) { @@ -510,10 +591,10 @@ export default function Home() {
- {/*

온라인 주루마블

*/} + {/*

알코올 마블

*/}

- 온라인 주루마블! + 알코올 마블! by kimyu. @@ -521,9 +602,10 @@ export default function Home() {



+

치지직 온라인 주루마블!

- 이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장됩니다. - 처음이면 칸은 랜덤으로 채워집니다. + 이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장되요. + 처음이면 추천 기본 배치(9×7)로 채워져요.

@@ -532,7 +614,9 @@ export default function Home() {

룸 입장

- 치즈 규칙을 정한 뒤, 치지직 채널로 입장하세요. 룸이 없으면 자동으로 만들어집니다. + {authMode?.requireChzzkLogin + ? '릴리즈 모드: 치지직 로그인으로 본인 채널만 입장할 수 있습니다.' + : '개발 모드: 채널 ID 직접 입장과 치지직 로그인을 모두 사용할 수 있습니다.'}

@@ -576,27 +660,72 @@ export default function Home() { )}
-
- - +
+

치지직 로그인으로 입장

+ {authSession ? ( +
+

+ 로그인됨: {authSession.channelName || authSession.channelId} +

+ + +
+ ) : ( + + )} + {authMode && !authMode.oauthConfigured && ( +

+ OAuth 미설정: `.env`에 CHZZK_CLIENT_ID / SECRET / REDIRECT_URI 를 넣으면 활성화됩니다. +

+ )}
+ {authMode?.allowChannelIdJoin !== false && ( +
+ + +
+ )} +