치지직 앱으로 연동하고, 연동 확인된 ID만 접속할 수 있도록 수정, 개발용으로는 채널ID를 수동으로 접속할 수 있도록 변경.
최초 추천 고정 배치 추가.
This commit is contained in:
16
app/api/auth/logout/route.ts
Normal file
16
app/api/auth/logout/route.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
19
app/api/auth/session/route.ts
Normal file
19
app/api/auth/session/route.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
112
app/api/chzzk/callback/route.ts
Normal file
112
app/api/chzzk/callback/route.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/api/chzzk/login/route.ts
Normal file
44
app/api/chzzk/login/route.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
31
app/api/rooms/[roomId]/add-dice/route.ts
Normal file
31
app/api/rooms/[roomId]/add-dice/route.ts
Normal file
@@ -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) });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||||
import {
|
import {
|
||||||
consumeDice,
|
consumeDice,
|
||||||
getRoom,
|
getRoom,
|
||||||
@@ -17,6 +18,9 @@ export async function POST(request: Request, { params }: Params) {
|
|||||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
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 body = (await request.json().catch(() => ({}))) as { count?: number };
|
||||||
const count = Math.max(1, Math.trunc(body.count ?? 1));
|
const count = Math.max(1, Math.trunc(body.count ?? 1));
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
cancelScheduledDisconnectRoomChat,
|
cancelScheduledDisconnectRoomChat,
|
||||||
scheduleDisconnectRoomChat,
|
scheduleDisconnectRoomChat,
|
||||||
} from '@/lib/chzzk/chat-session';
|
} from '@/lib/chzzk/chat-session';
|
||||||
|
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||||
import {
|
import {
|
||||||
addRoomViewer,
|
addRoomViewer,
|
||||||
getRoom,
|
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 =
|
const clientId =
|
||||||
new URL(request.url).searchParams.get('clientId')?.trim() ||
|
new URL(request.url).searchParams.get('clientId')?.trim() ||
|
||||||
crypto.randomUUID();
|
crypto.randomUUID();
|
||||||
@@ -40,14 +44,12 @@ export async function GET(request: Request, { params }: Params) {
|
|||||||
presenceHeld = false;
|
presenceHeld = false;
|
||||||
const { emptied } = removeRoomViewer(roomId, clientId);
|
const { emptied } = removeRoomViewer(roomId, clientId);
|
||||||
if (emptied) {
|
if (emptied) {
|
||||||
// 즉시 끊지 않음 — 빠른 재입장과 경쟁하지 않도록 유예
|
|
||||||
scheduleDisconnectRoomChat(roomId);
|
scheduleDisconnectRoomChat(roomId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
// 재입장 시 예약된 disconnect 취소
|
|
||||||
cancelScheduledDisconnectRoomChat(roomId);
|
cancelScheduledDisconnectRoomChat(roomId);
|
||||||
addRoomViewer(roomId, clientId);
|
addRoomViewer(roomId, clientId);
|
||||||
presenceHeld = true;
|
presenceHeld = true;
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||||
import { getRoom, toPublicRoom } from '@/lib/rooms/store';
|
import { getRoom, toPublicRoom } from '@/lib/rooms/store';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
type Params = { params: Promise<{ roomId: string }> };
|
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 { roomId } = await params;
|
||||||
const room = getRoom(roomId);
|
const room = getRoom(roomId);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
const access = await assertChannelRoomAccess(room, request);
|
||||||
|
if (!access.ok) return access.response;
|
||||||
return NextResponse.json({ room: toPublicRoom(room) });
|
return NextResponse.json({ room: toPublicRoom(room) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { assertChannelRoomAccess } from '@/lib/auth/room-access';
|
||||||
import {
|
import {
|
||||||
addDiceFromDonation,
|
addDiceFromDonation,
|
||||||
getRoom,
|
getRoom,
|
||||||
@@ -17,6 +18,9 @@ export async function POST(request: Request, { params }: Params) {
|
|||||||
return NextResponse.json({ error: '방을 찾을 수 없습니다.' }, { status: 404 });
|
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 {
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
payAmount?: number;
|
payAmount?: number;
|
||||||
donatorNickname?: string;
|
donatorNickname?: string;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { parseChzzkChannelId } from '@/lib/chzzk/channel';
|
import { parseChzzkChannelId } from '@/lib/chzzk/channel';
|
||||||
|
import {
|
||||||
|
allowChannelIdJoin,
|
||||||
|
isChzzkOAuthConfigured,
|
||||||
|
requireChzzkLoginForChannelRooms,
|
||||||
|
} from '@/lib/auth/mode';
|
||||||
|
import { getRequestAuthSession } from '@/lib/auth/room-access';
|
||||||
import {
|
import {
|
||||||
cancelScheduledDisconnectRoomChat,
|
cancelScheduledDisconnectRoomChat,
|
||||||
ensureRoomChat,
|
ensureRoomChat,
|
||||||
@@ -15,7 +21,8 @@ export const runtime = 'nodejs';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 치지직 채널 ID(또는 라이브 URL)로 룸 입장.
|
* 치지직 채널 ID(또는 라이브 URL)로 룸 입장.
|
||||||
* 없으면 전달된 치즈 설정으로 새 룸을 만들고 채팅 연결을 시도한다.
|
* - 개발: 채널 ID 직접 입장 가능 (+ 로그인 세션 있으면 본인 채널도 가능)
|
||||||
|
* - 릴리즈: 로그인 세션의 채널 ID와 일치할 때만 입장
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = (await request.json().catch(() => ({}))) as {
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
@@ -25,8 +32,39 @@ export async function POST(request: Request) {
|
|||||||
maxMultiplier?: number;
|
maxMultiplier?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const session = await getRequestAuthSession(request);
|
||||||
const raw = body.channelIdOrUrl || '';
|
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) {
|
if (!channelId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -44,7 +82,7 @@ export async function POST(request: Request) {
|
|||||||
created = true;
|
created = true;
|
||||||
room = createRoom({
|
room = createRoom({
|
||||||
chzzkEnabled: true,
|
chzzkEnabled: true,
|
||||||
liveUrl: raw.trim(),
|
liveUrl: raw.trim() || `https://chzzk.naver.com/live/${channelId}`,
|
||||||
channelId,
|
channelId,
|
||||||
cheesePerDice: body.cheesePerDice,
|
cheesePerDice: body.cheesePerDice,
|
||||||
multiplyByCheese: body.multiplyByCheese,
|
multiplyByCheese: body.multiplyByCheese,
|
||||||
@@ -52,7 +90,6 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이전 세션 leave가 늦게 와도 끊기지 않게 예약 해제 + 실제 WS 생존 여부 확인
|
|
||||||
cancelScheduledDisconnectRoomChat(room.id);
|
cancelScheduledDisconnectRoomChat(room.id);
|
||||||
|
|
||||||
if (room.settings.chzzkEnabled) {
|
if (room.settings.chzzkEnabled) {
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ export const MIN_PLANE_SIZE = 4;
|
|||||||
export const MAX_PLANE_SIZE = 16;
|
export const MAX_PLANE_SIZE = 16;
|
||||||
|
|
||||||
export const CELL_TYPE_PRESETS = [
|
export const CELL_TYPE_PRESETS = [
|
||||||
'한잔',
|
'한잔마셔',
|
||||||
'안주',
|
'~~체 3분',
|
||||||
'모두한잔',
|
'안주 한입',
|
||||||
'두잔',
|
'노래 1절',
|
||||||
'벌칙1',
|
'다같이한잔',
|
||||||
'벌칙2',
|
'영어금지 3분',
|
||||||
|
'두잔마셔',
|
||||||
|
'애교/매도 100자',
|
||||||
'무인도',
|
'무인도',
|
||||||
'역방향',
|
'역방향',
|
||||||
'비행기',
|
'세계여행',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type CellEventOption = {
|
export type CellEventOption = {
|
||||||
@@ -25,7 +27,7 @@ export const CELL_EVENT_OPTIONS: CellEventOption[] = [
|
|||||||
{ id: null, label: '없음' },
|
{ id: null, label: '없음' },
|
||||||
{ id: 'island', label: '무인도' },
|
{ id: 'island', label: '무인도' },
|
||||||
{ id: 'reverse', 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_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) {
|
export function clampPlaneSize(n: number) {
|
||||||
return Math.min(MAX_PLANE_SIZE, Math.max(MIN_PLANE_SIZE, Math.round(n)));
|
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() {
|
function randomPresetType() {
|
||||||
const pool = CELL_TYPE_PRESETS.filter(
|
const pool = CELL_TYPE_PRESETS.filter((t) => !SPECIAL_TYPE_NAMES.has(t));
|
||||||
(t) => t !== '무인도' && t !== '역방향' && t !== '비행기',
|
|
||||||
);
|
|
||||||
return pool[Math.floor(Math.random() * pool.length)];
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +119,37 @@ function defaultSpecials(width: number, height: number): Record<number, BoardCel
|
|||||||
return {
|
return {
|
||||||
[topRight]: { type: '무인도', eventId: 'island' },
|
[topRight]: { type: '무인도', eventId: 'island' },
|
||||||
[bottomRight]: { type: '역방향', eventId: 'reverse' },
|
[bottomRight]: { type: '역방향', eventId: 'reverse' },
|
||||||
[bottomLeft]: { type: '비행기', eventId: 'airplane' },
|
[bottomLeft]: { type: '세계여행', eventId: 'airplane' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cellFromLayout(
|
||||||
|
entry: { type: string; eventId?: string | null },
|
||||||
|
index: number,
|
||||||
|
): BoardCellConfig {
|
||||||
|
return {
|
||||||
|
type: index === 0 ? '시작' : entry.type,
|
||||||
|
eventId: index === 0 ? null : (entry.eventId ?? null),
|
||||||
|
imageSrc: null,
|
||||||
|
contentVisible: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 추천 기본 배치 (9×7) */
|
||||||
|
export function createDefaultBoardConfig(): BoardConfig {
|
||||||
|
const w = DEFAULT_PLANE_SIZE.width;
|
||||||
|
const h = DEFAULT_PLANE_SIZE.height;
|
||||||
|
const len = perimeterLength(w, h);
|
||||||
|
if (DEFAULT_BOARD_LAYOUT.length !== len) {
|
||||||
|
throw new Error(
|
||||||
|
`DEFAULT_BOARD_LAYOUT length ${DEFAULT_BOARD_LAYOUT.length} != perimeter ${len}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cells = DEFAULT_BOARD_LAYOUT.map((entry, index) => cellFromLayout(entry, index));
|
||||||
|
cells[0] = { type: '시작', eventId: null, imageSrc: null, contentVisible: true };
|
||||||
|
return { version: BOARD_VERSION, width: w, height: h, cells };
|
||||||
|
}
|
||||||
|
|
||||||
/** 시계방향 둘레 좌표만 만든다. */
|
/** 시계방향 둘레 좌표만 만든다. */
|
||||||
export function buildPerimeterGeometry(
|
export function buildPerimeterGeometry(
|
||||||
width: number,
|
width: number,
|
||||||
@@ -116,6 +182,10 @@ export function createRandomBoardConfig(
|
|||||||
): BoardConfig {
|
): BoardConfig {
|
||||||
const w = clampPlaneSize(width);
|
const w = clampPlaneSize(width);
|
||||||
const h = clampPlaneSize(height);
|
const h = clampPlaneSize(height);
|
||||||
|
if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) {
|
||||||
|
return createDefaultBoardConfig();
|
||||||
|
}
|
||||||
|
|
||||||
const geo = buildPerimeterGeometry(w, h);
|
const geo = buildPerimeterGeometry(w, h);
|
||||||
const specials = defaultSpecials(w, h);
|
const specials = defaultSpecials(w, h);
|
||||||
const cells: BoardCellConfig[] = geo.map((_, index) => {
|
const cells: BoardCellConfig[] = geo.map((_, index) => {
|
||||||
@@ -180,7 +250,10 @@ export function readStoredBoard(): BoardConfig | null {
|
|||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(BOARD_STORAGE_KEY);
|
const raw = window.localStorage.getItem(BOARD_STORAGE_KEY);
|
||||||
if (!raw) return null;
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -195,7 +268,7 @@ export function writeStoredBoard(config: BoardConfig) {
|
|||||||
export function getOrCreateBoardConfig(): BoardConfig {
|
export function getOrCreateBoardConfig(): BoardConfig {
|
||||||
const existing = readStoredBoard();
|
const existing = readStoredBoard();
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
const created = createRandomBoardConfig();
|
const created = createDefaultBoardConfig();
|
||||||
writeStoredBoard(created);
|
writeStoredBoard(created);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
@@ -209,15 +282,24 @@ export function clearBoardConfig() {
|
|||||||
window.localStorage.removeItem(BOARD_STORAGE_KEY);
|
window.localStorage.removeItem(BOARD_STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 추천 기본 배치로 되돌린다 (크기 포함 9×7 고정). */
|
||||||
|
export function resetToDefaultBoard(): BoardConfig {
|
||||||
|
const created = createDefaultBoardConfig();
|
||||||
|
writeStoredBoard(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
export function randomizeAndSaveBoard(
|
export function randomizeAndSaveBoard(
|
||||||
width?: number,
|
width?: number,
|
||||||
height?: number,
|
height?: number,
|
||||||
current = getOrCreateBoardConfig(),
|
current = getOrCreateBoardConfig(),
|
||||||
): BoardConfig {
|
): BoardConfig {
|
||||||
const created = createRandomBoardConfig(
|
const w = width ?? current.width;
|
||||||
width ?? current.width,
|
const h = height ?? current.height;
|
||||||
height ?? current.height,
|
if (w === DEFAULT_PLANE_SIZE.width && h === DEFAULT_PLANE_SIZE.height) {
|
||||||
);
|
return resetToDefaultBoard();
|
||||||
|
}
|
||||||
|
const created = createRandomBoardConfig(w, h);
|
||||||
writeStoredBoard(created);
|
writeStoredBoard(created);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export type TurnResult = {
|
|||||||
message: string;
|
message: string;
|
||||||
/** 비행기 등: 즉시 이동할 칸 */
|
/** 비행기 등: 즉시 이동할 칸 */
|
||||||
teleportTo?: number;
|
teleportTo?: number;
|
||||||
|
/** 비행기: 도착 칸을 플레이어가 직접 고름 */
|
||||||
|
chooseDestination?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CellEvent {
|
export interface CellEvent {
|
||||||
@@ -54,20 +56,14 @@ export class ReverseEvent implements CellEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 비행기: 지정한 칸으로 즉시 이동 */
|
/** 비행기: 원하는 칸을 고른 뒤 그곳으로 이동 */
|
||||||
export class AirplaneEvent implements CellEvent {
|
export class AirplaneEvent implements CellEvent {
|
||||||
readonly id = 'airplane';
|
readonly id = 'airplane';
|
||||||
|
|
||||||
constructor(private readonly targetIndex: number) {}
|
apply(_ctx: TurnContext): TurnResult {
|
||||||
|
|
||||||
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];
|
|
||||||
return {
|
return {
|
||||||
message: `비행기! → ${dest?.type ?? target}`,
|
message: '원하는 곳을 클릭해주세요',
|
||||||
teleportTo: target,
|
chooseDestination: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,8 +71,7 @@ export class AirplaneEvent implements CellEvent {
|
|||||||
export const CELL_EVENTS: Record<string, CellEvent> = {
|
export const CELL_EVENTS: Record<string, CellEvent> = {
|
||||||
island: new IslandEvent(),
|
island: new IslandEvent(),
|
||||||
reverse: new ReverseEvent(),
|
reverse: new ReverseEvent(),
|
||||||
// 기본 도착지는 시작(0). 판 생성 시 덮어쓸 수 있다.
|
airplane: new AirplaneEvent(),
|
||||||
airplane: new AirplaneEvent(0),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getCellEvent(eventId: string | null | undefined): CellEvent | null {
|
export function getCellEvent(eventId: string | null | undefined): CellEvent | null {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import Dice from './dice';
|
import Dice from './dice';
|
||||||
import ChatFeedPanel, { type ChatFeedItem } from './chat-feed';
|
import ChatFeedPanel, { type ChatFeedItem } from './chat-feed';
|
||||||
import { Chessman } from './chessman';
|
import { Chessman } from './chessman';
|
||||||
import ChessmanPiece from './chessman-piece';
|
import ChessmanPiece from './chessman-piece';
|
||||||
import { applyLandEvent, type PlaneCell } from './cell-event';
|
import { applyLandEvent, type PlaneCell } from './cell-event';
|
||||||
import { getAssets } from './asset-storage';
|
import { getAssets } from './asset-storage';
|
||||||
|
import { SHOW_DEBUG_UI } from '@/lib/ui/debug';
|
||||||
import {
|
import {
|
||||||
buildPlaneCells,
|
buildPlaneCells,
|
||||||
cellColorFor,
|
cellColorFor,
|
||||||
@@ -29,12 +30,15 @@ export default function DicePlane({
|
|||||||
onConsumeDice,
|
onConsumeDice,
|
||||||
chatFeed = [],
|
chatFeed = [],
|
||||||
showChatFeed = false,
|
showChatFeed = false,
|
||||||
|
settingsButton = null,
|
||||||
}: {
|
}: {
|
||||||
requireDiceQueue?: boolean;
|
requireDiceQueue?: boolean;
|
||||||
diceQueue?: number;
|
diceQueue?: number;
|
||||||
onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>;
|
onConsumeDice?: (count?: number) => Promise<{ ok: boolean; error?: string }>;
|
||||||
chatFeed?: ChatFeedItem[];
|
chatFeed?: ChatFeedItem[];
|
||||||
showChatFeed?: boolean;
|
showChatFeed?: boolean;
|
||||||
|
/** 판 안쪽 왼쪽 아래(둘레 칸 밖)에 배치할 설정 버튼 */
|
||||||
|
settingsButton?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [boardReady, setBoardReady] = useState(false);
|
const [boardReady, setBoardReady] = useState(false);
|
||||||
const [planeSize, setPlaneSize] = useState<{ width: number; height: number }>({
|
const [planeSize, setPlaneSize] = useState<{ width: number; height: number }>({
|
||||||
@@ -57,7 +61,9 @@ export default function DicePlane({
|
|||||||
const [landedKey, setLandedKey] = useState(0);
|
const [landedKey, setLandedKey] = useState(0);
|
||||||
const [statusText, setStatusText] = useState('시계방향');
|
const [statusText, setStatusText] = useState('시계방향');
|
||||||
const [islandTurnsLeft, setIslandTurnsLeft] = useState(0);
|
const [islandTurnsLeft, setIslandTurnsLeft] = useState(0);
|
||||||
|
const [pickingDestination, setPickingDestination] = useState(false);
|
||||||
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pendingRollRef = useRef(0);
|
||||||
|
|
||||||
// SSR/초기 렌더에서 랜덤 판을 쓰지 않고, 브라우저에 저장된 설정을 로드
|
// SSR/초기 렌더에서 랜덤 판을 쓰지 않고, 브라우저에 저장된 설정을 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,6 +78,7 @@ export default function DicePlane({
|
|||||||
setLandedLabel(null);
|
setLandedLabel(null);
|
||||||
setStatusText('시계방향');
|
setStatusText('시계방향');
|
||||||
setIslandTurnsLeft(0);
|
setIslandTurnsLeft(0);
|
||||||
|
setPickingDestination(false);
|
||||||
setDicePlaneArray(cells);
|
setDicePlaneArray(cells);
|
||||||
setBoardReady(true);
|
setBoardReady(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -104,6 +111,15 @@ export default function DicePlane({
|
|||||||
setStatusText(man.facing === 1 ? '시계방향' : '반시계방향');
|
setStatusText(man.facing === 1 ? '시계방향' : '반시계방향');
|
||||||
setIslandTurnsLeft(man.islandTurnsLeft);
|
setIslandTurnsLeft(man.islandTurnsLeft);
|
||||||
|
|
||||||
|
if (result?.chooseDestination) {
|
||||||
|
pendingRollRef.current = roll;
|
||||||
|
setPickingDestination(true);
|
||||||
|
showBanner(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPickingDestination(false);
|
||||||
|
|
||||||
if (result?.teleportTo != null) {
|
if (result?.teleportTo != null) {
|
||||||
setChessmanIndex(result.teleportTo);
|
setChessmanIndex(result.teleportTo);
|
||||||
setHopKey((k) => k + 1);
|
setHopKey((k) => k + 1);
|
||||||
@@ -116,6 +132,19 @@ export default function DicePlane({
|
|||||||
[showBanner],
|
[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(
|
const animateMove = useCallback(
|
||||||
(steps: number, roll: number) => {
|
(steps: number, roll: number) => {
|
||||||
const amount = Math.max(0, Math.trunc(steps));
|
const amount = Math.max(0, Math.trunc(steps));
|
||||||
@@ -153,6 +182,7 @@ export default function DicePlane({
|
|||||||
if (roll === 0) return;
|
if (roll === 0) return;
|
||||||
|
|
||||||
setLandedLabel(null);
|
setLandedLabel(null);
|
||||||
|
setPickingDestination(false);
|
||||||
const man = chessmanRef.current;
|
const man = chessmanRef.current;
|
||||||
|
|
||||||
if (man.isOnIsland) {
|
if (man.isOnIsland) {
|
||||||
@@ -217,6 +247,16 @@ export default function DicePlane({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={plane.id}
|
key={plane.id}
|
||||||
|
role={pickingDestination ? 'button' : undefined}
|
||||||
|
tabIndex={pickingDestination ? 0 : undefined}
|
||||||
|
onClick={() => onCellPick(index)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!pickingDestination) return;
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onCellPick(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
gridColumn: plane.x + 1,
|
gridColumn: plane.x + 1,
|
||||||
gridRow: plane.y + 1,
|
gridRow: plane.y + 1,
|
||||||
@@ -238,12 +278,16 @@ export default function DicePlane({
|
|||||||
padding: 4,
|
padding: 4,
|
||||||
boxShadow: isLanded
|
boxShadow: isLanded
|
||||||
? '0 0 0 3px #fde68a, 0 0 18px rgba(253, 224, 71, 0.75)'
|
? '0 0 0 3px #fde68a, 0 0 18px rgba(253, 224, 71, 0.75)'
|
||||||
|
: pickingDestination
|
||||||
|
? '0 0 0 2px rgba(125, 211, 252, 0.85)'
|
||||||
: isStart
|
: isStart
|
||||||
? 'inset 0 0 0 2px #a7f3d0'
|
? 'inset 0 0 0 2px #a7f3d0'
|
||||||
: undefined,
|
: undefined,
|
||||||
transform: isLanded ? 'scale(1.04)' : undefined,
|
transform: isLanded ? 'scale(1.04)' : undefined,
|
||||||
transition: 'box-shadow 180ms ease, transform 180ms ease',
|
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:
|
textShadow:
|
||||||
showContent && plane.imageSrc
|
showContent && plane.imageSrc
|
||||||
? '0 1px 3px rgba(0,0,0,0.85)'
|
? '0 1px 3px rgba(0,0,0,0.85)'
|
||||||
@@ -276,13 +320,34 @@ export default function DicePlane({
|
|||||||
>
|
>
|
||||||
<Dice
|
<Dice
|
||||||
onRolled={onDiceRolled}
|
onRolled={onDiceRolled}
|
||||||
disabled={moving}
|
disabled={moving || pickingDestination}
|
||||||
requireDiceQueue={requireDiceQueue}
|
requireDiceQueue={requireDiceQueue}
|
||||||
diceQueue={diceQueue}
|
diceQueue={diceQueue}
|
||||||
onConsumeDice={onConsumeDice}
|
onConsumeDice={onConsumeDice}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{settingsButton && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
// 둘레 칸 밖: 안쪽 영역 왼쪽 아래
|
||||||
|
gridColumn: 2,
|
||||||
|
gridRow: Math.max(3, planeSize.height - 1),
|
||||||
|
zIndex: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
padding: 4,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ pointerEvents: 'auto' }}>{settingsButton}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showChatFeed && (
|
{showChatFeed && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -336,10 +401,12 @@ export default function DicePlane({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{SHOW_DEBUG_UI && (
|
||||||
<div className="pointer-events-none absolute bottom-3 left-3 z-20 rounded bg-black/65 px-3 py-1 text-sm text-white">
|
<div className="pointer-events-none absolute bottom-3 left-3 z-20 rounded bg-black/65 px-3 py-1 text-sm text-white">
|
||||||
{statusText}
|
{statusText}
|
||||||
{islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''}
|
{islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{landedLabel && (
|
{landedLabel && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import DicePlane from './diceplane';
|
import DicePlane from './diceplane';
|
||||||
import { DEFAULT_ASSETS, getAssets } from './asset-storage';
|
import { DEFAULT_ASSETS, getAssets } from './asset-storage';
|
||||||
import { useRoom } from '@/lib/rooms/client';
|
import { useRoom } from '@/lib/rooms/client';
|
||||||
|
import { SHOW_DEBUG_UI } from '@/lib/ui/debug';
|
||||||
|
|
||||||
function GamePageInner() {
|
function GamePageInner() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -13,7 +14,10 @@ function GamePageInner() {
|
|||||||
const [backgroundSrc, setBackgroundSrc] = useState(DEFAULT_ASSETS.background);
|
const [backgroundSrc, setBackgroundSrc] = useState(DEFAULT_ASSETS.background);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [simAmount, setSimAmount] = useState(1000);
|
const [simAmount, setSimAmount] = useState(1000);
|
||||||
const { room, error, consumeDice, simulateDonation } = useRoom(roomId);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [addDiceCount, setAddDiceCount] = useState(1);
|
||||||
|
const [addDiceHint, setAddDiceHint] = useState<string | null>(null);
|
||||||
|
const { room, error, consumeDice, simulateDonation, addDice } = useRoom(roomId);
|
||||||
const chzzkOn = Boolean(room?.settings.chzzkEnabled);
|
const chzzkOn = Boolean(room?.settings.chzzkEnabled);
|
||||||
const manualMode = Boolean(roomId) && room != null && !chzzkOn;
|
const manualMode = Boolean(roomId) && room != null && !chzzkOn;
|
||||||
|
|
||||||
@@ -22,6 +26,21 @@ function GamePageInner() {
|
|||||||
setReady(true);
|
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 (
|
return (
|
||||||
<div className="relative h-screen w-screen overflow-hidden">
|
<div className="relative h-screen w-screen overflow-hidden">
|
||||||
{ready && (
|
{ready && (
|
||||||
@@ -36,13 +55,8 @@ function GamePageInner() {
|
|||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 z-0 bg-black/25" aria-hidden />
|
<div className="absolute inset-0 z-0 bg-black/25" aria-hidden />
|
||||||
|
|
||||||
|
{SHOW_DEBUG_UI && (
|
||||||
<div className="absolute top-3 left-3 z-40 flex flex-col items-start gap-2">
|
<div className="absolute top-3 left-3 z-40 flex flex-col items-start gap-2">
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="rounded bg-black/70 px-3 py-1.5 text-sm text-white hover:bg-black/85"
|
|
||||||
>
|
|
||||||
홈
|
|
||||||
</Link>
|
|
||||||
{roomId && chzzkOn && (
|
{roomId && chzzkOn && (
|
||||||
<div className="max-w-[min(100vw-2rem,20rem)] rounded-xl bg-black/75 px-3 py-2 text-xs text-white">
|
<div className="max-w-[min(100vw-2rem,20rem)] rounded-xl bg-black/75 px-3 py-2 text-xs text-white">
|
||||||
<p className="font-semibold">주사위 큐: {room?.diceQueue ?? '…'}</p>
|
<p className="font-semibold">주사위 큐: {room?.diceQueue ?? '…'}</p>
|
||||||
@@ -97,6 +111,7 @@ function GamePageInner() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative z-10 h-full w-full">
|
<div className="relative z-10 h-full w-full">
|
||||||
<DicePlane
|
<DicePlane
|
||||||
@@ -105,8 +120,88 @@ function GamePageInner() {
|
|||||||
onConsumeDice={consumeDice}
|
onConsumeDice={consumeDice}
|
||||||
showChatFeed={chzzkOn}
|
showChatFeed={chzzkOn}
|
||||||
chatFeed={chzzkOn ? room?.chatFeed ?? [] : []}
|
chatFeed={chzzkOn ? room?.chatFeed ?? [] : []}
|
||||||
|
settingsButton={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="설정"
|
||||||
|
aria-expanded={settingsOpen}
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
설정
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{settingsOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="게임 설정"
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm rounded-2xl border border-white/15 bg-slate-950/95 px-5 py-4 text-white shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-base font-semibold">설정</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded px-2 py-1 text-sm text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="rounded-lg bg-white/10 px-4 py-2.5 text-center text-sm font-medium hover:bg-white/15"
|
||||||
|
>
|
||||||
|
홈으로
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{roomId && chzzkOn ? (
|
||||||
|
<div className="rounded-lg border border-white/10 bg-black/30 p-3">
|
||||||
|
<p className="text-xs text-white/60">주사위 큐 수동 추가</p>
|
||||||
|
<p className="mt-1 text-sm text-white/80">
|
||||||
|
현재 큐: <span className="font-semibold">{room?.diceQueue ?? 0}</span>
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={addDiceCount}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-lg bg-sky-700 px-3 py-1.5 text-sm font-medium hover:bg-sky-600"
|
||||||
|
onClick={() => void onAddDice()}
|
||||||
|
>
|
||||||
|
카운트 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{addDiceHint && (
|
||||||
|
<p className="mt-2 text-xs text-amber-200">{addDiceHint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-xs text-white/65">
|
||||||
|
{manualMode
|
||||||
|
? '수동 모드에서는 주사위 큐 없이 바로 굴릴 수 있습니다.'
|
||||||
|
: '치지직 연동 방에 입장하면 여기서 주사위 카운트를 추가할 수 있습니다.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
147
app/page.tsx
147
app/page.tsx
@@ -38,7 +38,7 @@ import {
|
|||||||
clearBoardConfig,
|
clearBoardConfig,
|
||||||
getOrCreateBoardConfig,
|
getOrCreateBoardConfig,
|
||||||
perimeterLength,
|
perimeterLength,
|
||||||
randomizeAndSaveBoard,
|
resetToDefaultBoard,
|
||||||
resizeBoardConfig,
|
resizeBoardConfig,
|
||||||
setAllCellsContentVisible,
|
setAllCellsContentVisible,
|
||||||
setBoardConfig,
|
setBoardConfig,
|
||||||
@@ -169,9 +169,9 @@ function BoardEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
|
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
|
||||||
onClick={() => onChange(randomizeAndSaveBoard(board.width, board.height, board))}
|
onClick={() => onChange(resetToDefaultBoard())}
|
||||||
>
|
>
|
||||||
랜덤 다시 배치
|
추천 배치로 되돌리기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,15 +379,57 @@ export default function Home() {
|
|||||||
const [joinChannelInput, setJoinChannelInput] = useState('');
|
const [joinChannelInput, setJoinChannelInput] = useState('');
|
||||||
const [roomBusy, setRoomBusy] = useState(false);
|
const [roomBusy, setRoomBusy] = useState(false);
|
||||||
const [roomError, setRoomError] = useState<string | null>(null);
|
const [roomError, setRoomError] = useState<string | null>(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 = () => {
|
const reload = () => {
|
||||||
setAssets(getAssets());
|
setAssets(getAssets());
|
||||||
setBoard(getOrCreateBoardConfig());
|
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(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
setReady(true);
|
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 = () => ({
|
const roomCheeseOptions = () => ({
|
||||||
@@ -396,6 +438,45 @@ export default function Home() {
|
|||||||
maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0,
|
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 joinByChzzkId = async () => {
|
||||||
const value = joinChannelInput.trim();
|
const value = joinChannelInput.trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -510,10 +591,10 @@ export default function Home() {
|
|||||||
<div className="mx-auto flex min-h-screen max-w-3xl flex-col gap-8 px-6 py-10">
|
<div className="mx-auto flex min-h-screen max-w-3xl flex-col gap-8 px-6 py-10">
|
||||||
<header className="flex w-full flex-col items-stretch gap-5">
|
<header className="flex w-full flex-col items-stretch gap-5">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/*<p className="text-sm text-white/60">온라인 주루마블</p>*/}
|
{/*<p className="text-sm text-white/60">알코올 마블</p>*/}
|
||||||
<h1 className="relative mt-1 inline-block w-full text-center text-4xl font-bold tracking-tight">
|
<h1 className="relative mt-1 inline-block w-full text-center text-4xl font-bold tracking-tight">
|
||||||
<span className="relative inline-block pr-12">
|
<span className="relative inline-block pr-12">
|
||||||
온라인 주루마블!
|
알코올 마블!
|
||||||
<span className="pointer-events-none absolute bottom-1 left-full -ml-0.5 whitespace-nowrap text-[11px] font-normal tracking-normal text-white/35">
|
<span className="pointer-events-none absolute bottom-1 left-full -ml-0.5 whitespace-nowrap text-[11px] font-normal tracking-normal text-white/35">
|
||||||
by kimyu.
|
by kimyu.
|
||||||
</span>
|
</span>
|
||||||
@@ -521,9 +602,10 @@ export default function Home() {
|
|||||||
</h1>
|
</h1>
|
||||||
<br></br>
|
<br></br>
|
||||||
<p className="border-b border-white/10 w-full"></p>
|
<p className="border-b border-white/10 w-full"></p>
|
||||||
|
<p className="mt-2 w-full text-sm text-white/65">치지직 온라인 주루마블!</p>
|
||||||
<p className="mt-2 w-full text-sm text-white/65">
|
<p className="mt-2 w-full text-sm text-white/65">
|
||||||
이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장됩니다.
|
이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장되요.
|
||||||
처음이면 칸은 랜덤으로 채워집니다.
|
처음이면 추천 기본 배치(9×7)로 채워져요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -532,7 +614,9 @@ export default function Home() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">룸 입장</h2>
|
<h2 className="text-lg font-semibold">룸 입장</h2>
|
||||||
<p className="mt-1 text-[11px] text-white/50">
|
<p className="mt-1 text-[11px] text-white/50">
|
||||||
치즈 규칙을 정한 뒤, 치지직 채널로 입장하세요. 룸이 없으면 자동으로 만들어집니다.
|
{authMode?.requireChzzkLogin
|
||||||
|
? '릴리즈 모드: 치지직 로그인으로 본인 채널만 입장할 수 있습니다.'
|
||||||
|
: '개발 모드: 채널 ID 직접 입장과 치지직 로그인을 모두 사용할 수 있습니다.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -576,9 +660,53 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-white/10 pt-3">
|
||||||
|
<p className="text-xs font-medium text-white/70">치지직 로그인으로 입장</p>
|
||||||
|
{authSession ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm text-emerald-200/90">
|
||||||
|
로그인됨: {authSession.channelName || authSession.channelId}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={roomBusy}
|
||||||
|
onClick={() => void joinWithSession()}
|
||||||
|
className="rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
내 채널로 입장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void logoutAuth()}
|
||||||
|
className="rounded-full bg-white/10 px-3 py-2 text-xs text-white/80 hover:bg-white/20"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={roomBusy || authMode?.oauthConfigured === false}
|
||||||
|
onClick={startChzzkLogin}
|
||||||
|
className="self-start rounded-full bg-sky-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-sky-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
치지직으로 로그인 후 입장
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{authMode && !authMode.oauthConfigured && (
|
||||||
|
<p className="text-[11px] text-amber-200/80">
|
||||||
|
OAuth 미설정: `.env`에 CHZZK_CLIENT_ID / SECRET / REDIRECT_URI 를 넣으면 활성화됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authMode?.allowChannelIdJoin !== false && (
|
||||||
<div className="flex flex-wrap items-end gap-2 border-t border-white/10 pt-3">
|
<div className="flex flex-wrap items-end gap-2 border-t border-white/10 pt-3">
|
||||||
<label className="flex min-w-[220px] flex-1 flex-col gap-1 text-sm">
|
<label className="flex min-w-[220px] flex-1 flex-col gap-1 text-sm">
|
||||||
<span className="text-white/70">치지직 채널 ID로 입장</span>
|
<span className="text-white/70">
|
||||||
|
치지직 채널 ID로 입장
|
||||||
|
<span className="ml-1 text-[10px] text-amber-200/70">(개발용)</span>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="채널ID 또는 https://chzzk.naver.com/live/…"
|
placeholder="채널ID 또는 https://chzzk.naver.com/live/…"
|
||||||
@@ -596,6 +724,7 @@ export default function Home() {
|
|||||||
{roomBusy ? '처리 중…' : '입장'}
|
{roomBusy ? '처리 중…' : '입장'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
46
lib/auth/mode.ts
Normal file
46
lib/auth/mode.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 개발: 채널 ID 입장 + 치지직 로그인 모두 허용
|
||||||
|
* 릴리즈(production): 치지직 로그인(세션)으로만 채널 룸 입장
|
||||||
|
*
|
||||||
|
* 강제 오버라이드:
|
||||||
|
* - DRINKMARBLE_ALLOW_CHANNEL_ID_JOIN=true → production에서도 채널 ID 입장 허용
|
||||||
|
* - DRINKMARBLE_REQUIRE_CHZZK_LOGIN=true → development에서도 로그인 필수
|
||||||
|
*/
|
||||||
|
export function allowChannelIdJoin(): boolean {
|
||||||
|
if (process.env.DRINKMARBLE_REQUIRE_CHZZK_LOGIN === 'true') return false;
|
||||||
|
if (process.env.DRINKMARBLE_ALLOW_CHANNEL_ID_JOIN === 'true') return true;
|
||||||
|
return process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireChzzkLoginForChannelRooms(): boolean {
|
||||||
|
return !allowChannelIdJoin();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChzzkOAuthConfigured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
process.env.CHZZK_CLIENT_ID?.trim() &&
|
||||||
|
process.env.CHZZK_CLIENT_SECRET?.trim() &&
|
||||||
|
process.env.CHZZK_REDIRECT_URI?.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChzzkOAuthConfig() {
|
||||||
|
const clientId = process.env.CHZZK_CLIENT_ID?.trim() || '';
|
||||||
|
const clientSecret = process.env.CHZZK_CLIENT_SECRET?.trim() || '';
|
||||||
|
const redirectUri = process.env.CHZZK_REDIRECT_URI?.trim() || '';
|
||||||
|
if (!clientId || !clientSecret || !redirectUri) {
|
||||||
|
throw new Error(
|
||||||
|
'치지직 OAuth 환경변수(CHZZK_CLIENT_ID, CHZZK_CLIENT_SECRET, CHZZK_REDIRECT_URI)가 필요합니다.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { clientId, clientSecret, redirectUri };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthSessionSecret(): string {
|
||||||
|
const explicit = process.env.AUTH_SESSION_SECRET?.trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const fallback = process.env.CHZZK_CLIENT_SECRET?.trim();
|
||||||
|
if (fallback) return fallback;
|
||||||
|
// 개발 전용 폴백 (production에서는 OAuth 설정이 있어야 함)
|
||||||
|
return 'drinkmarble-dev-session-secret';
|
||||||
|
}
|
||||||
59
lib/auth/public-origin.ts
Normal file
59
lib/auth/public-origin.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Caddy 등 리버스 프록시 뒤에서 request.url 이
|
||||||
|
* http://0.0.0.0:13000 처럼 잡히는 경우가 있어, 공개 Origin 을 따로 계산한다.
|
||||||
|
*/
|
||||||
|
export function getPublicOrigin(request: Request): string {
|
||||||
|
const fromEnv =
|
||||||
|
process.env.APP_ORIGIN?.trim() ||
|
||||||
|
process.env.NEXT_PUBLIC_APP_ORIGIN?.trim();
|
||||||
|
if (fromEnv) {
|
||||||
|
try {
|
||||||
|
return new URL(fromEnv).origin;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = process.env.CHZZK_REDIRECT_URI?.trim();
|
||||||
|
if (redirectUri) {
|
||||||
|
try {
|
||||||
|
return new URL(redirectUri).origin;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const xfHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();
|
||||||
|
const xfProto =
|
||||||
|
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() || 'https';
|
||||||
|
if (xfHost && !isBadHost(xfHost)) {
|
||||||
|
return `${xfProto}://${xfHost}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = request.headers.get('host')?.trim();
|
||||||
|
if (host && !isBadHost(host)) {
|
||||||
|
const proto =
|
||||||
|
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ||
|
||||||
|
(host.includes('localhost') || host.startsWith('127.') ? 'http' : 'https');
|
||||||
|
return `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
if (!isBadHost(new URL(origin).host)) return origin;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'https://drink.kimyu.xyz';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBadHost(host: string) {
|
||||||
|
const h = host.toLowerCase().split(':')[0];
|
||||||
|
return (
|
||||||
|
h === '0.0.0.0' ||
|
||||||
|
h === '::' ||
|
||||||
|
h === '[::]' ||
|
||||||
|
h === 'host.docker.internal'
|
||||||
|
);
|
||||||
|
}
|
||||||
68
lib/auth/room-access.ts
Normal file
68
lib/auth/room-access.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import {
|
||||||
|
allowChannelIdJoin,
|
||||||
|
isChzzkOAuthConfigured,
|
||||||
|
requireChzzkLoginForChannelRooms,
|
||||||
|
} from '@/lib/auth/mode';
|
||||||
|
import { readAuthSession, type AuthSession } from '@/lib/auth/session';
|
||||||
|
import type { RoomRecord } from '@/lib/rooms/store';
|
||||||
|
|
||||||
|
export async function getRequestAuthSession(
|
||||||
|
request?: Request,
|
||||||
|
): Promise<AuthSession | null> {
|
||||||
|
// Route Handler에서는 cookies()가 기본. request Cookie 헤더도 보조 파싱.
|
||||||
|
const fromCookies = await readAuthSession();
|
||||||
|
if (fromCookies) return fromCookies;
|
||||||
|
if (!request) return null;
|
||||||
|
const header = request.headers.get('cookie') || '';
|
||||||
|
const match = /(?:^|;\s*)drinkmarble_auth=([^;]+)/.exec(header);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
const { parseAuthSession } = await import('@/lib/auth/session');
|
||||||
|
return parseAuthSession(decodeURIComponent(match[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 치지직 연동 룸 접근 검사.
|
||||||
|
* - 개발(채널 ID 허용): 세션 없어도 OK
|
||||||
|
* - 릴리즈: 세션의 channelId가 룸 채널과 일치해야 함
|
||||||
|
*/
|
||||||
|
export async function assertChannelRoomAccess(
|
||||||
|
room: RoomRecord,
|
||||||
|
request?: Request,
|
||||||
|
): Promise<{ ok: true; session: AuthSession | null } | { ok: false; response: NextResponse }> {
|
||||||
|
if (!room.settings.chzzkEnabled) {
|
||||||
|
return { ok: true, session: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requireChzzkLoginForChannelRooms()) {
|
||||||
|
return { ok: true, session: await getRequestAuthSession(request) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getRequestAuthSession(request);
|
||||||
|
const roomChannel = room.settings.channelId?.toLowerCase() || '';
|
||||||
|
if (!session || !roomChannel || session.channelId !== roomChannel) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'릴리즈 환경에서는 치지직 로그인으로 본인 채널만 입장할 수 있습니다. 홈에서 다시 로그인해 주세요.',
|
||||||
|
code: 'CHZZK_AUTH_REQUIRED',
|
||||||
|
allowChannelIdJoin: allowChannelIdJoin(),
|
||||||
|
oauthConfigured: isChzzkOAuthConfigured(),
|
||||||
|
},
|
||||||
|
{ status: 401 },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authModePublic() {
|
||||||
|
return {
|
||||||
|
allowChannelIdJoin: allowChannelIdJoin(),
|
||||||
|
requireChzzkLogin: requireChzzkLoginForChannelRooms(),
|
||||||
|
oauthConfigured: isChzzkOAuthConfigured(),
|
||||||
|
nodeEnv: process.env.NODE_ENV,
|
||||||
|
};
|
||||||
|
}
|
||||||
137
lib/auth/session.ts
Normal file
137
lib/auth/session.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { getAuthSessionSecret } from './mode';
|
||||||
|
|
||||||
|
export const AUTH_COOKIE = 'drinkmarble_auth';
|
||||||
|
export const OAUTH_STATE_COOKIE = 'drinkmarble_oauth_state';
|
||||||
|
|
||||||
|
export type AuthSession = {
|
||||||
|
channelId: string;
|
||||||
|
channelName: string | null;
|
||||||
|
/** unix ms */
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OAuthStatePayload = {
|
||||||
|
state: string;
|
||||||
|
cheesePerDice?: number;
|
||||||
|
multiplyByCheese?: boolean;
|
||||||
|
maxMultiplier?: number;
|
||||||
|
/** unix ms */
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function b64urlEncode(buf: Buffer | string) {
|
||||||
|
const b = typeof buf === 'string' ? Buffer.from(buf, 'utf8') : buf;
|
||||||
|
return b
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlDecode(s: string) {
|
||||||
|
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
||||||
|
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
||||||
|
return Buffer.from(b64, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(payloadB64: string) {
|
||||||
|
return createHmac('sha256', getAuthSessionSecret())
|
||||||
|
.update(payloadB64)
|
||||||
|
.digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function seal<T extends object>(data: T): string {
|
||||||
|
const payloadB64 = b64urlEncode(JSON.stringify(data));
|
||||||
|
return `${payloadB64}.${sign(payloadB64)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unseal<T extends object>(token: string): T | null {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const [payloadB64, sig] = parts;
|
||||||
|
if (!payloadB64 || !sig) return null;
|
||||||
|
const expected = sign(payloadB64);
|
||||||
|
try {
|
||||||
|
const a = Buffer.from(sig);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(b64urlDecode(payloadB64).toString('utf8')) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 브라우저 종료 시까지 + 최대 12시간 (치지직 토큰은 저장하지 않음) */
|
||||||
|
const SESSION_MAX_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function createAuthSession(
|
||||||
|
channelId: string,
|
||||||
|
channelName: string | null,
|
||||||
|
): AuthSession {
|
||||||
|
return {
|
||||||
|
channelId: channelId.trim().toLowerCase(),
|
||||||
|
channelName,
|
||||||
|
exp: Date.now() + SESSION_MAX_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAuthSession(session: AuthSession) {
|
||||||
|
return seal(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAuthSession(token: string | undefined | null): AuthSession | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const data = unseal<AuthSession>(token);
|
||||||
|
if (!data?.channelId || !data.exp) return null;
|
||||||
|
if (Date.now() > data.exp) return null;
|
||||||
|
return {
|
||||||
|
channelId: String(data.channelId).toLowerCase(),
|
||||||
|
channelName: data.channelName ?? null,
|
||||||
|
exp: data.exp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readAuthSession(): Promise<AuthSession | null> {
|
||||||
|
const jar = await cookies();
|
||||||
|
return parseAuthSession(jar.get(AUTH_COOKIE)?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authCookieOptions(maxAgeSec = Math.floor(SESSION_MAX_MS / 1000)) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/',
|
||||||
|
maxAge: maxAgeSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOAuthState(input: {
|
||||||
|
cheesePerDice?: number;
|
||||||
|
multiplyByCheese?: boolean;
|
||||||
|
maxMultiplier?: number;
|
||||||
|
}) {
|
||||||
|
const state = b64urlEncode(randomBytes(24));
|
||||||
|
const payload: OAuthStatePayload = {
|
||||||
|
state,
|
||||||
|
cheesePerDice: input.cheesePerDice,
|
||||||
|
multiplyByCheese: input.multiplyByCheese,
|
||||||
|
maxMultiplier: input.maxMultiplier,
|
||||||
|
exp: Date.now() + 10 * 60 * 1000,
|
||||||
|
};
|
||||||
|
return { state, sealed: seal(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOAuthStateCookie(token: string | undefined | null): OAuthStatePayload | null {
|
||||||
|
if (!token) return null;
|
||||||
|
const data = unseal<OAuthStatePayload>(token);
|
||||||
|
if (!data?.state || !data.exp) return null;
|
||||||
|
if (Date.now() > data.exp) return null;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
89
lib/chzzk/oauth.ts
Normal file
89
lib/chzzk/oauth.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { getChzzkOAuthConfig } from '@/lib/auth/mode';
|
||||||
|
|
||||||
|
const OPENAPI = 'https://openapi.chzzk.naver.com';
|
||||||
|
|
||||||
|
export function buildChzzkLoginUrl(state: string) {
|
||||||
|
const { clientId, redirectUri } = getChzzkOAuthConfig();
|
||||||
|
const url = new URL('https://chzzk.naver.com/account-interlock');
|
||||||
|
url.searchParams.set('clientId', clientId);
|
||||||
|
url.searchParams.set('redirectUri', redirectUri);
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeAuthorizationCode(code: string, state: string) {
|
||||||
|
const { clientId, clientSecret } = getChzzkOAuthConfig();
|
||||||
|
const res = await fetch(`${OPENAPI}/auth/v1/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grantType: 'authorization_code',
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
code,
|
||||||
|
state,
|
||||||
|
}),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const json = (await res.json().catch(() => ({}))) as {
|
||||||
|
content?: {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
expiresIn?: string | number;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
code?: number;
|
||||||
|
};
|
||||||
|
if (!res.ok || !json.content?.accessToken) {
|
||||||
|
throw new Error(json.message || `토큰 발급 실패 (${res.status})`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken: json.content.accessToken,
|
||||||
|
refreshToken: json.content.refreshToken ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthedUser(accessToken: string) {
|
||||||
|
const res = await fetch(`${OPENAPI}/open/v1/users/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const json = (await res.json().catch(() => ({}))) as {
|
||||||
|
content?: { channelId?: string; channelName?: string };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
if (!res.ok || !json.content?.channelId) {
|
||||||
|
throw new Error(json.message || `유저 정보 조회 실패 (${res.status})`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channelId: json.content.channelId.toLowerCase(),
|
||||||
|
channelName: json.content.channelName ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확인 후 치지직 토큰 폐기 (저장하지 않음) */
|
||||||
|
export async function revokeToken(
|
||||||
|
token: string,
|
||||||
|
tokenTypeHint: 'access_token' | 'refresh_token' = 'access_token',
|
||||||
|
) {
|
||||||
|
const { clientId, clientSecret } = getChzzkOAuthConfig();
|
||||||
|
try {
|
||||||
|
await fetch(`${OPENAPI}/auth/v1/token/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
token,
|
||||||
|
tokenTypeHint,
|
||||||
|
}),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 폐기는 best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,15 +48,21 @@ export function useRoom(roomId: string | null) {
|
|||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: `c-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
: `c-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
void fetch(`/api/rooms/${roomId}`)
|
void fetch(`/api/rooms/${roomId}`)
|
||||||
.then((r) => r.json())
|
.then(async (r) => {
|
||||||
.then((j) => {
|
const j = await r.json();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!r.ok) {
|
||||||
|
setError(j.error || '방을 불러오지 못했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (j.room) setRoom(j.room as PublicRoomState);
|
if (j.room) setRoom(j.room as PublicRoomState);
|
||||||
else setError(j.error || '방을 불러오지 못했습니다.');
|
else setError(j.error || '방을 불러오지 못했습니다.');
|
||||||
})
|
|
||||||
.catch(() => setError('방을 불러오지 못했습니다.'));
|
|
||||||
|
|
||||||
const es = new EventSource(
|
es = new EventSource(
|
||||||
`/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`,
|
`/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`,
|
||||||
);
|
);
|
||||||
es.addEventListener('room', (ev) => {
|
es.addEventListener('room', (ev) => {
|
||||||
@@ -70,6 +76,10 @@ export function useRoom(roomId: string | null) {
|
|||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
// keep last state; browser will retry SSE
|
// keep last state; browser will retry SSE
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError('방을 불러오지 못했습니다.');
|
||||||
|
});
|
||||||
|
|
||||||
const leave = () => {
|
const leave = () => {
|
||||||
try {
|
try {
|
||||||
@@ -94,8 +104,9 @@ export function useRoom(roomId: string | null) {
|
|||||||
|
|
||||||
window.addEventListener('pagehide', leave);
|
window.addEventListener('pagehide', leave);
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
window.removeEventListener('pagehide', leave);
|
window.removeEventListener('pagehide', leave);
|
||||||
es.close();
|
es?.close();
|
||||||
leave();
|
leave();
|
||||||
};
|
};
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
@@ -136,5 +147,26 @@ export function useRoom(roomId: string | null) {
|
|||||||
[roomId],
|
[roomId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { room, error, consumeDice, simulateDonation };
|
const addDice = useCallback(
|
||||||
|
async (count = 1) => {
|
||||||
|
if (!roomId) return { ok: false as const, error: '방이 없습니다.' };
|
||||||
|
const res = await fetch(`/api/rooms/${roomId}/add-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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { room, error, consumeDice, simulateDonation, addDice };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,15 @@ export function consumeDice(roomId: string, count = 1) {
|
|||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 후원 없이 주사위 큐만 수동으로 추가 */
|
||||||
|
export function addDice(roomId: string, count = 1) {
|
||||||
|
const room = getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
const n = Math.max(1, Math.trunc(count));
|
||||||
|
room.diceQueue += n;
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
export function toPublicRoom(room: RoomRecord): PublicRoomState {
|
export function toPublicRoom(room: RoomRecord): PublicRoomState {
|
||||||
return {
|
return {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
|
|||||||
9
lib/ui/debug.ts
Normal file
9
lib/ui/debug.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 디버그용 오버레이(룸 상태, 후원 시뮬, 진행 방향 등) 표시 여부.
|
||||||
|
* 릴리즈(production)에서는 기본으로 숨기고, 주사위 Roll·결과만 남긴다.
|
||||||
|
*
|
||||||
|
* NEXT_PUBLIC_DRINKMARBLE_SHOW_DEBUG=true 로 릴리즈에서도 강제로 켤 수 있다.
|
||||||
|
*/
|
||||||
|
export const SHOW_DEBUG_UI =
|
||||||
|
process.env.NEXT_PUBLIC_DRINKMARBLE_SHOW_DEBUG === 'true' ||
|
||||||
|
process.env.NODE_ENV !== 'production';
|
||||||
9
logs/release.log
Normal file
9
logs/release.log
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
> drinkmarble@0.1.0 start
|
||||||
|
> next start -H 0.0.0.0 -p 13000
|
||||||
|
|
||||||
|
▲ Next.js 16.3.3
|
||||||
|
- Local: http://localhost:13000
|
||||||
|
- Network: http://0.0.0.0:13000
|
||||||
|
✓ Ready in 153ms
|
||||||
|
✓ Running next.config.ts took 31ms
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// localhost가 아닌 IP로 개발 서버에 붙을 때 /_next/* (HMR, Rapier, Three 청크 등)가
|
// 홈 디렉터리 package-lock.json 오인 방지 (외부 도메인/IP 접속 시에도 안정)
|
||||||
// 기본으로 막힌다. 허용하지 않으면 Canvas/물리 스크립트가 로드되지 않아 주사위가 안 보인다.
|
turbopack: {
|
||||||
|
root: projectRoot,
|
||||||
|
},
|
||||||
|
// Caddy(https://drink.kimyu.xyz) → localhost:13000 으로 붙을 때
|
||||||
|
// /_next/* · HMR 이 cross-origin 으로 막히지 않게 허용
|
||||||
allowedDevOrigins: [
|
allowedDevOrigins: [
|
||||||
"172.30.1.31", // LAN
|
"localhost",
|
||||||
"175.214.193.201", // 외부 접속 IP
|
"127.0.0.1",
|
||||||
|
"172.30.1.31",
|
||||||
|
"175.214.193.201",
|
||||||
|
"drink.kimyu.xyz",
|
||||||
|
"*.kimyu.xyz",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 13000",
|
"dev": "next dev -H 0.0.0.0 -p 13000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 13000",
|
"start": "next start -H 0.0.0.0 -p 13000",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user