Files
drinkmarble/lib/rooms/store.ts
kimyu 347dfc1f4a 게임·입장 UX와 물리/고지 개선을 반영한다.
비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화,
개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 12:50:22 +09:00

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 };
}