비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화, 개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다. Co-authored-by: Cursor <cursoragent@cursor.com>
371 lines
9.9 KiB
TypeScript
371 lines
9.9 KiB
TypeScript
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 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;
|
|
}
|
|
|
|
/** 남은 주사위 큐를 0으로 초기화 */
|
|
export function clearDiceQueue(roomId: string) {
|
|
const room = getRoom(roomId);
|
|
if (!room) return null;
|
|
room.diceQueue = 0;
|
|
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 };
|
|
}
|