import { randomUUID } from 'crypto'; export type RoomSettings = { /** 치지직 채팅 연동 사용 */ chzzkEnabled: boolean; /** 라이브/채널 URL 원문 */ liveUrl: string; channelId: string | null; /** 이 원(치즈)당 주사위 1개 */ cheesePerDice: number; /** true면 floor(금액/기준), false면 기준 이상 시 1개 */ multiplyByCheese: boolean; /** * 배수 누적 시 한 번 후원으로 쌓을 수 있는 최대 주사위 수. * 0 이하면 제한 없음. */ maxMultiplier: number; }; export type DonationLogItem = { id: string; at: number; payAmount: number; diceAdded: number; donatorNickname: string; donationText?: string; simulated?: boolean; }; export type ChatFeedItem = { id: string; at: number; nickname: string; message: string; kind: 'chat' | 'donation'; /** 후원일 때 치즈(원) */ payAmount?: number; }; export type RoomRecord = { id: string; createdAt: number; settings: RoomSettings; diceQueue: number; channelName: string | null; chatChannelId: string | null; chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected'; chatError: string | null; lastDonations: DonationLogItem[]; chatFeed: ChatFeedItem[]; /** 이미 반영한 치지직 후원 메시지 키 (재연결 시 중복 방지) */ seenDonationKeys: Set; }; export type PublicRoomState = { id: string; settings: RoomSettings; diceQueue: number; channelName: string | null; chatChannelId: string | null; chatStatus: RoomRecord['chatStatus']; chatError: string | null; lastDonations: DonationLogItem[]; chatFeed: ChatFeedItem[]; }; type RoomStore = { rooms: Map; /** 룸별 접속 클라이언트 ID 집합 */ viewers: Map>; }; const globalForRooms = globalThis as typeof globalThis & { __drinkmarbleChatRooms?: RoomStore; }; function getStore(): RoomStore { if (!globalForRooms.__drinkmarbleChatRooms) { globalForRooms.__drinkmarbleChatRooms = { rooms: new Map(), viewers: new Map(), }; } if (!globalForRooms.__drinkmarbleChatRooms.viewers) { globalForRooms.__drinkmarbleChatRooms.viewers = new Map(); } return globalForRooms.__drinkmarbleChatRooms; } export function createRoom(input: { chzzkEnabled: boolean; liveUrl?: string; channelId?: string | null; cheesePerDice?: number; multiplyByCheese?: boolean; maxMultiplier?: number; }): RoomRecord { const room: RoomRecord = { id: randomUUID(), createdAt: Date.now(), settings: { chzzkEnabled: Boolean(input.chzzkEnabled), liveUrl: input.liveUrl?.trim() || '', channelId: input.channelId ?? null, cheesePerDice: Math.max(1, Math.trunc(input.cheesePerDice ?? 1000)), multiplyByCheese: input.multiplyByCheese !== false, maxMultiplier: Math.max(0, Math.trunc(input.maxMultiplier ?? 0)), }, diceQueue: 0, channelName: null, chatChannelId: null, chatStatus: 'idle', chatError: null, lastDonations: [], chatFeed: [], seenDonationKeys: new Set(), }; getStore().rooms.set(room.id, room); return room; } export function getRoom(roomId: string) { return getStore().rooms.get(roomId) ?? null; } /** 치지직 channelId로 기존 룸 찾기 (가장 최근 생성된 것) */ export function findRoomByChannelId(channelId: string): RoomRecord | null { const id = channelId.trim().toLowerCase(); if (!id) return null; let latest: RoomRecord | null = null; for (const room of getStore().rooms.values()) { if (room.settings.channelId?.toLowerCase() !== id) continue; if (!latest || room.createdAt > latest.createdAt) latest = room; } return latest; } export function setRoomMeta( roomId: string, patch: Partial< Pick >, ) { const room = getRoom(roomId); if (!room) return null; Object.assign(room, patch); return room; } export function calcDiceFromDonation(payAmount: number, settings: RoomSettings) { const amount = Math.max(0, Math.trunc(payAmount)); const unit = Math.max(1, settings.cheesePerDice); if (amount < unit) return 0; if (!settings.multiplyByCheese) return 1; let dice = Math.floor(amount / unit); const max = Math.max(0, Math.trunc(settings.maxMultiplier ?? 0)); if (max > 0) dice = Math.min(dice, max); return dice; } export function hasSeenDonationKey(roomId: string, key: string) { const room = getRoom(roomId); if (!room || !key) return false; if (!room.seenDonationKeys) room.seenDonationKeys = new Set(); return room.seenDonationKeys.has(key); } export function markSeenDonationKey(roomId: string, key: string) { const room = getRoom(roomId); if (!room || !key) return; if (!room.seenDonationKeys) room.seenDonationKeys = new Set(); room.seenDonationKeys.add(key); // 삽입 순서 기준 오래된 키부터 정리 while (room.seenDonationKeys.size > 800) { const oldest = room.seenDonationKeys.values().next().value; if (oldest == null) break; room.seenDonationKeys.delete(oldest); } } export function clearChatFeed(roomId: string) { const room = getRoom(roomId); if (!room) return null; room.chatFeed = []; return room; } export function addDiceFromDonation( roomId: string, input: { payAmount: number; donatorNickname?: string; donationText?: string; simulated?: boolean; /** false면 피드/로그만 남기고 주사위 큐에는 넣지 않음 (최근 채팅 히스토리용) */ applyDice?: boolean; }, ) { const room = getRoom(roomId); if (!room) return null; const applyDice = input.applyDice !== false; const diceAdded = applyDice ? calcDiceFromDonation(input.payAmount, room.settings) : 0; if (diceAdded > 0) room.diceQueue += diceAdded; const nickname = input.donatorNickname?.trim() || '익명'; const payAmount = Math.max(0, Math.trunc(input.payAmount)); const log: DonationLogItem = { id: randomUUID(), at: Date.now(), payAmount, diceAdded, donatorNickname: nickname, donationText: input.donationText, simulated: input.simulated, }; room.lastDonations = [log, ...room.lastDonations].slice(0, 20); appendChatFeed(room, { nickname, message: input.donationText?.trim() || '치즈 후원', kind: 'donation', payAmount, }); return { room, diceAdded }; } export function addChatMessage( roomId: string, input: { nickname?: string; message: string }, ) { const room = getRoom(roomId); if (!room) return null; const message = input.message?.trim(); if (!message) return room; appendChatFeed(room, { nickname: input.nickname?.trim() || '익명', message, kind: 'chat', }); return room; } function appendChatFeed( room: RoomRecord, item: Omit, ) { const entry: ChatFeedItem = { id: randomUUID(), at: Date.now(), ...item, }; room.chatFeed = [...room.chatFeed, entry].slice(-60); } export function consumeDice(roomId: string, count = 1) { const room = getRoom(roomId); if (!room) return null; const n = Math.max(1, Math.trunc(count)); if (room.diceQueue < n) return null; room.diceQueue -= n; return room; } /** 후원 없이 주사위 큐만 수동으로 추가 */ export function 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 { return { id: room.id, settings: { ...room.settings }, diceQueue: room.diceQueue, channelName: room.channelName, chatChannelId: room.chatChannelId, chatStatus: room.chatStatus, chatError: room.chatError, lastDonations: room.lastDonations, chatFeed: room.chatFeed, }; } type RoomListener = (room: PublicRoomState) => void; const globalForListeners = globalThis as typeof globalThis & { __drinkmarbleChatRoomListeners?: Map>; }; function getListeners() { if (!globalForListeners.__drinkmarbleChatRoomListeners) { globalForListeners.__drinkmarbleChatRoomListeners = new Map(); } return globalForListeners.__drinkmarbleChatRoomListeners; } export function subscribeRoom(roomId: string, listener: RoomListener) { const map = getListeners(); let set = map.get(roomId); if (!set) { set = new Set(); map.set(roomId, set); } set.add(listener); return () => { set!.delete(listener); if (set!.size === 0) map.delete(roomId); }; } export function notifyRoom(roomId: string) { const room = getRoom(roomId); if (!room) return; const state = toPublicRoom(room); const set = getListeners().get(roomId); if (!set) return; for (const listener of set) { try { listener(state); } catch { // ignore } } } export function getRoomViewerCount(roomId: string) { return getStore().viewers.get(roomId)?.size ?? 0; } /** 클라이언트 입장. 같은 clientId면 한 번만 집계 */ export function addRoomViewer(roomId: string, clientId: string) { const store = getStore(); if (!store.rooms.has(roomId) || !clientId) return 0; let set = store.viewers.get(roomId); if (!set) { set = new Set(); store.viewers.set(roomId, set); } set.add(clientId); return set.size; } /** * 클라이언트 퇴장. 이미 빠진 clientId면 no-op. * viewer가 0이 되면 emptied=true (채팅 끊어도 됨). */ export function removeRoomViewer(roomId: string, clientId: string) { const store = getStore(); const set = store.viewers.get(roomId); if (!set || !clientId) { return { viewers: set?.size ?? 0, emptied: !set || set.size === 0 }; } set.delete(clientId); if (set.size === 0) { store.viewers.delete(roomId); return { viewers: 0, emptied: true }; } return { viewers: set.size, emptied: false }; }