치지직 채팅창 인식 추가 및 주사위 횟수 증가 추가.
This commit is contained in:
140
lib/rooms/client.ts
Normal file
140
lib/rooms/client.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export type PublicRoomState = {
|
||||
id: string;
|
||||
settings: {
|
||||
chzzkEnabled: boolean;
|
||||
liveUrl: string;
|
||||
channelId: string | null;
|
||||
cheesePerDice: number;
|
||||
multiplyByCheese: boolean;
|
||||
maxMultiplier: number;
|
||||
};
|
||||
diceQueue: number;
|
||||
channelName: string | null;
|
||||
chatChannelId: string | null;
|
||||
chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||
chatError: string | null;
|
||||
lastDonations: Array<{
|
||||
id: string;
|
||||
at: number;
|
||||
payAmount: number;
|
||||
diceAdded: number;
|
||||
donatorNickname: string;
|
||||
donationText?: string;
|
||||
simulated?: boolean;
|
||||
}>;
|
||||
chatFeed: Array<{
|
||||
id: string;
|
||||
at: number;
|
||||
nickname: string;
|
||||
message: string;
|
||||
kind: 'chat' | 'donation';
|
||||
payAmount?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function useRoom(roomId: string | null) {
|
||||
const [room, setRoom] = useState<PublicRoomState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
|
||||
const clientId =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `c-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
void fetch(`/api/rooms/${roomId}`)
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.room) setRoom(j.room as PublicRoomState);
|
||||
else setError(j.error || '방을 불러오지 못했습니다.');
|
||||
})
|
||||
.catch(() => setError('방을 불러오지 못했습니다.'));
|
||||
|
||||
const es = new EventSource(
|
||||
`/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`,
|
||||
);
|
||||
es.addEventListener('room', (ev) => {
|
||||
try {
|
||||
setRoom(JSON.parse((ev as MessageEvent).data) as PublicRoomState);
|
||||
setError(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
es.onerror = () => {
|
||||
// keep last state; browser will retry SSE
|
||||
};
|
||||
|
||||
const leave = () => {
|
||||
try {
|
||||
const payload = JSON.stringify({ clientId });
|
||||
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
||||
navigator.sendBeacon(
|
||||
`/api/rooms/${roomId}/leave`,
|
||||
new Blob([payload], { type: 'application/json' }),
|
||||
);
|
||||
} else {
|
||||
void fetch(`/api/rooms/${roomId}/leave`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pagehide', leave);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', leave);
|
||||
es.close();
|
||||
leave();
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
const consumeDice = useCallback(
|
||||
async (count = 1) => {
|
||||
if (!roomId) return { ok: true as const };
|
||||
const res = await fetch(`/api/rooms/${roomId}/consume-dice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: (json.error as string) || '주사위를 소모할 수 없습니다.',
|
||||
};
|
||||
}
|
||||
setRoom(json.room as PublicRoomState);
|
||||
return { ok: true as const };
|
||||
},
|
||||
[roomId],
|
||||
);
|
||||
|
||||
const simulateDonation = useCallback(
|
||||
async (payAmount: number) => {
|
||||
if (!roomId) return;
|
||||
const res = await fetch(`/api/rooms/${roomId}/simulate-donation`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ payAmount }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '시뮬레이션 실패');
|
||||
setRoom(json.room as PublicRoomState);
|
||||
},
|
||||
[roomId],
|
||||
);
|
||||
|
||||
return { room, error, consumeDice, simulateDonation };
|
||||
}
|
||||
353
lib/rooms/store.ts
Normal file
353
lib/rooms/store.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export type RoomSettings = {
|
||||
/** 치지직 채팅 연동 사용 */
|
||||
chzzkEnabled: boolean;
|
||||
/** 라이브/채널 URL 원문 */
|
||||
liveUrl: string;
|
||||
channelId: string | null;
|
||||
/** 이 원(치즈)당 주사위 1개 */
|
||||
cheesePerDice: number;
|
||||
/** true면 floor(금액/기준), false면 기준 이상 시 1개 */
|
||||
multiplyByCheese: boolean;
|
||||
/**
|
||||
* 배수 누적 시 한 번 후원으로 쌓을 수 있는 최대 주사위 수.
|
||||
* 0 이하면 제한 없음.
|
||||
*/
|
||||
maxMultiplier: number;
|
||||
};
|
||||
|
||||
export type DonationLogItem = {
|
||||
id: string;
|
||||
at: number;
|
||||
payAmount: number;
|
||||
diceAdded: number;
|
||||
donatorNickname: string;
|
||||
donationText?: string;
|
||||
simulated?: boolean;
|
||||
};
|
||||
|
||||
export type ChatFeedItem = {
|
||||
id: string;
|
||||
at: number;
|
||||
nickname: string;
|
||||
message: string;
|
||||
kind: 'chat' | 'donation';
|
||||
/** 후원일 때 치즈(원) */
|
||||
payAmount?: number;
|
||||
};
|
||||
|
||||
export type RoomRecord = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
settings: RoomSettings;
|
||||
diceQueue: number;
|
||||
channelName: string | null;
|
||||
chatChannelId: string | null;
|
||||
chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected';
|
||||
chatError: string | null;
|
||||
lastDonations: DonationLogItem[];
|
||||
chatFeed: ChatFeedItem[];
|
||||
/** 이미 반영한 치지직 후원 메시지 키 (재연결 시 중복 방지) */
|
||||
seenDonationKeys: Set<string>;
|
||||
};
|
||||
|
||||
export type PublicRoomState = {
|
||||
id: string;
|
||||
settings: RoomSettings;
|
||||
diceQueue: number;
|
||||
channelName: string | null;
|
||||
chatChannelId: string | null;
|
||||
chatStatus: RoomRecord['chatStatus'];
|
||||
chatError: string | null;
|
||||
lastDonations: DonationLogItem[];
|
||||
chatFeed: ChatFeedItem[];
|
||||
};
|
||||
|
||||
type RoomStore = {
|
||||
rooms: Map<string, RoomRecord>;
|
||||
/** 룸별 접속 클라이언트 ID 집합 */
|
||||
viewers: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
const globalForRooms = globalThis as typeof globalThis & {
|
||||
__drinkmarbleChatRooms?: RoomStore;
|
||||
};
|
||||
|
||||
function getStore(): RoomStore {
|
||||
if (!globalForRooms.__drinkmarbleChatRooms) {
|
||||
globalForRooms.__drinkmarbleChatRooms = {
|
||||
rooms: new Map(),
|
||||
viewers: new Map(),
|
||||
};
|
||||
}
|
||||
if (!globalForRooms.__drinkmarbleChatRooms.viewers) {
|
||||
globalForRooms.__drinkmarbleChatRooms.viewers = new Map();
|
||||
}
|
||||
return globalForRooms.__drinkmarbleChatRooms;
|
||||
}
|
||||
|
||||
export function createRoom(input: {
|
||||
chzzkEnabled: boolean;
|
||||
liveUrl?: string;
|
||||
channelId?: string | null;
|
||||
cheesePerDice?: number;
|
||||
multiplyByCheese?: boolean;
|
||||
maxMultiplier?: number;
|
||||
}): RoomRecord {
|
||||
const room: RoomRecord = {
|
||||
id: randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
settings: {
|
||||
chzzkEnabled: Boolean(input.chzzkEnabled),
|
||||
liveUrl: input.liveUrl?.trim() || '',
|
||||
channelId: input.channelId ?? null,
|
||||
cheesePerDice: Math.max(1, Math.trunc(input.cheesePerDice ?? 1000)),
|
||||
multiplyByCheese: input.multiplyByCheese !== false,
|
||||
maxMultiplier: Math.max(0, Math.trunc(input.maxMultiplier ?? 0)),
|
||||
},
|
||||
diceQueue: 0,
|
||||
channelName: null,
|
||||
chatChannelId: null,
|
||||
chatStatus: 'idle',
|
||||
chatError: null,
|
||||
lastDonations: [],
|
||||
chatFeed: [],
|
||||
seenDonationKeys: new Set(),
|
||||
};
|
||||
getStore().rooms.set(room.id, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function getRoom(roomId: string) {
|
||||
return getStore().rooms.get(roomId) ?? null;
|
||||
}
|
||||
|
||||
/** 치지직 channelId로 기존 룸 찾기 (가장 최근 생성된 것) */
|
||||
export function findRoomByChannelId(channelId: string): RoomRecord | null {
|
||||
const id = channelId.trim().toLowerCase();
|
||||
if (!id) return null;
|
||||
let latest: RoomRecord | null = null;
|
||||
for (const room of getStore().rooms.values()) {
|
||||
if (room.settings.channelId?.toLowerCase() !== id) continue;
|
||||
if (!latest || room.createdAt > latest.createdAt) latest = room;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function setRoomMeta(
|
||||
roomId: string,
|
||||
patch: Partial<
|
||||
Pick<RoomRecord, 'channelName' | 'chatChannelId' | 'chatStatus' | 'chatError'>
|
||||
>,
|
||||
) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
Object.assign(room, patch);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function calcDiceFromDonation(payAmount: number, settings: RoomSettings) {
|
||||
const amount = Math.max(0, Math.trunc(payAmount));
|
||||
const unit = Math.max(1, settings.cheesePerDice);
|
||||
if (amount < unit) return 0;
|
||||
if (!settings.multiplyByCheese) return 1;
|
||||
let dice = Math.floor(amount / unit);
|
||||
const max = Math.max(0, Math.trunc(settings.maxMultiplier ?? 0));
|
||||
if (max > 0) dice = Math.min(dice, max);
|
||||
return dice;
|
||||
}
|
||||
|
||||
export function hasSeenDonationKey(roomId: string, key: string) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room || !key) return false;
|
||||
if (!room.seenDonationKeys) room.seenDonationKeys = new Set();
|
||||
return room.seenDonationKeys.has(key);
|
||||
}
|
||||
|
||||
export function markSeenDonationKey(roomId: string, key: string) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room || !key) return;
|
||||
if (!room.seenDonationKeys) room.seenDonationKeys = new Set();
|
||||
room.seenDonationKeys.add(key);
|
||||
// 삽입 순서 기준 오래된 키부터 정리
|
||||
while (room.seenDonationKeys.size > 800) {
|
||||
const oldest = room.seenDonationKeys.values().next().value;
|
||||
if (oldest == null) break;
|
||||
room.seenDonationKeys.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearChatFeed(roomId: string) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
room.chatFeed = [];
|
||||
return room;
|
||||
}
|
||||
|
||||
export function addDiceFromDonation(
|
||||
roomId: string,
|
||||
input: {
|
||||
payAmount: number;
|
||||
donatorNickname?: string;
|
||||
donationText?: string;
|
||||
simulated?: boolean;
|
||||
/** false면 피드/로그만 남기고 주사위 큐에는 넣지 않음 (최근 채팅 히스토리용) */
|
||||
applyDice?: boolean;
|
||||
},
|
||||
) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const applyDice = input.applyDice !== false;
|
||||
const diceAdded = applyDice
|
||||
? calcDiceFromDonation(input.payAmount, room.settings)
|
||||
: 0;
|
||||
if (diceAdded > 0) room.diceQueue += diceAdded;
|
||||
const nickname = input.donatorNickname?.trim() || '익명';
|
||||
const payAmount = Math.max(0, Math.trunc(input.payAmount));
|
||||
const log: DonationLogItem = {
|
||||
id: randomUUID(),
|
||||
at: Date.now(),
|
||||
payAmount,
|
||||
diceAdded,
|
||||
donatorNickname: nickname,
|
||||
donationText: input.donationText,
|
||||
simulated: input.simulated,
|
||||
};
|
||||
room.lastDonations = [log, ...room.lastDonations].slice(0, 20);
|
||||
appendChatFeed(room, {
|
||||
nickname,
|
||||
message: input.donationText?.trim() || '치즈 후원',
|
||||
kind: 'donation',
|
||||
payAmount,
|
||||
});
|
||||
return { room, diceAdded };
|
||||
}
|
||||
|
||||
export function addChatMessage(
|
||||
roomId: string,
|
||||
input: { nickname?: string; message: string },
|
||||
) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const message = input.message?.trim();
|
||||
if (!message) return room;
|
||||
appendChatFeed(room, {
|
||||
nickname: input.nickname?.trim() || '익명',
|
||||
message,
|
||||
kind: 'chat',
|
||||
});
|
||||
return room;
|
||||
}
|
||||
|
||||
function appendChatFeed(
|
||||
room: RoomRecord,
|
||||
item: Omit<ChatFeedItem, 'id' | 'at'>,
|
||||
) {
|
||||
const entry: ChatFeedItem = {
|
||||
id: randomUUID(),
|
||||
at: Date.now(),
|
||||
...item,
|
||||
};
|
||||
room.chatFeed = [...room.chatFeed, entry].slice(-60);
|
||||
}
|
||||
|
||||
export function consumeDice(roomId: string, count = 1) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const n = Math.max(1, Math.trunc(count));
|
||||
if (room.diceQueue < n) return null;
|
||||
room.diceQueue -= n;
|
||||
return room;
|
||||
}
|
||||
|
||||
export function toPublicRoom(room: RoomRecord): PublicRoomState {
|
||||
return {
|
||||
id: room.id,
|
||||
settings: { ...room.settings },
|
||||
diceQueue: room.diceQueue,
|
||||
channelName: room.channelName,
|
||||
chatChannelId: room.chatChannelId,
|
||||
chatStatus: room.chatStatus,
|
||||
chatError: room.chatError,
|
||||
lastDonations: room.lastDonations,
|
||||
chatFeed: room.chatFeed,
|
||||
};
|
||||
}
|
||||
|
||||
type RoomListener = (room: PublicRoomState) => void;
|
||||
|
||||
const globalForListeners = globalThis as typeof globalThis & {
|
||||
__drinkmarbleChatRoomListeners?: Map<string, Set<RoomListener>>;
|
||||
};
|
||||
|
||||
function getListeners() {
|
||||
if (!globalForListeners.__drinkmarbleChatRoomListeners) {
|
||||
globalForListeners.__drinkmarbleChatRoomListeners = new Map();
|
||||
}
|
||||
return globalForListeners.__drinkmarbleChatRoomListeners;
|
||||
}
|
||||
|
||||
export function subscribeRoom(roomId: string, listener: RoomListener) {
|
||||
const map = getListeners();
|
||||
let set = map.get(roomId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(roomId, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set!.delete(listener);
|
||||
if (set!.size === 0) map.delete(roomId);
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyRoom(roomId: string) {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return;
|
||||
const state = toPublicRoom(room);
|
||||
const set = getListeners().get(roomId);
|
||||
if (!set) return;
|
||||
for (const listener of set) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getRoomViewerCount(roomId: string) {
|
||||
return getStore().viewers.get(roomId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/** 클라이언트 입장. 같은 clientId면 한 번만 집계 */
|
||||
export function addRoomViewer(roomId: string, clientId: string) {
|
||||
const store = getStore();
|
||||
if (!store.rooms.has(roomId) || !clientId) return 0;
|
||||
let set = store.viewers.get(roomId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
store.viewers.set(roomId, set);
|
||||
}
|
||||
set.add(clientId);
|
||||
return set.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트 퇴장. 이미 빠진 clientId면 no-op.
|
||||
* viewer가 0이 되면 emptied=true (채팅 끊어도 됨).
|
||||
*/
|
||||
export function removeRoomViewer(roomId: string, clientId: string) {
|
||||
const store = getStore();
|
||||
const set = store.viewers.get(roomId);
|
||||
if (!set || !clientId) {
|
||||
return { viewers: set?.size ?? 0, emptied: !set || set.size === 0 };
|
||||
}
|
||||
set.delete(clientId);
|
||||
if (set.size === 0) {
|
||||
store.viewers.delete(roomId);
|
||||
return { viewers: 0, emptied: true };
|
||||
}
|
||||
return { viewers: set.size, emptied: false };
|
||||
}
|
||||
Reference in New Issue
Block a user