UNPKG

@aituber-onair/kizuna

Version:

A sophisticated bond system (絆 - Kizuna) for managing relationships between users and AI characters in AITuber OnAir.

447 lines 14.6 kB
/** * KizunaManager - Main class for the Kizuna system * * Manages relationships with users and controls the point system */ import { PointCalculator } from "./PointCalculator"; /** * Basic implementation of event emitter */ class EventEmitter { constructor() { this.listeners = new Map(); } on(event, listener) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event)?.push(listener); } off(event, listener) { const eventListeners = this.listeners.get(event); if (eventListeners) { const index = eventListeners.indexOf(listener); if (index !== -1) { eventListeners.splice(index, 1); } } } emit(event, data) { const eventListeners = this.listeners.get(event); if (eventListeners) { for (const listener of eventListeners) { try { listener(data); } catch (error) { console.error(`Error in event listener for ${event}:`, error); } } } } removeAllListeners() { this.listeners.clear(); } } /** * Main manager class for the Kizuna system */ export class KizunaManager extends EventEmitter { constructor(config, storageProvider, storageKey) { super(); this.users = new Map(); this.storageProvider = null; this.isInitialized = false; this.config = { ...config }; this.storageProvider = storageProvider || null; this.pointCalculator = new PointCalculator(this.config); if (!storageKey) { throw new Error("storageKey is required for KizunaManager. Please provide a complete localStorage key."); } this.storageKey = storageKey; if (this.config.dev.debugMode) { console.log("[Kizuna] KizunaManager initialized with config:", this.config); } } /** * Initialization process */ async initialize() { if (this.isInitialized) { return; } try { // Load data from storage if (this.storageProvider) { await this.loadFromStorage(); } // Set up automatic cleanup this.setupAutoCleanup(); this.isInitialized = true; this.log("info", "KizunaManager initialized successfully"); } catch (error) { this.log("error", "Failed to initialize KizunaManager:", error); throw error; } } /** * Interaction processing - Main point calculation logic */ async processInteraction(context) { if (!this.isInitialized) { await this.initialize(); } try { if (this.config.dev.debugMode) { this.log("debug", `Processing interaction for ${context.userId} with emotion: ${context.emotion}`); } // Get or create user const user = this.getOrCreateUser(context); // Calculate points const calculationResult = this.calculatePoints(context, user); // Add points const result = await this.addPoints(context.userId, calculationResult.points, context, calculationResult.appliedRules); // Add interaction record this.addInteractionRecord(user, context, result); // Save to storage if (this.storageProvider) { await this.saveToStorage(); } if (this.config.dev.debugMode) { this.log("debug", `Interaction processed: ${result.pointsAdded} points added (${result.appliedRules.length} rules applied)`); if (result.appliedRules.length > 0) { this.log("debug", `Applied rules: ${result.appliedRules.map((r) => r.name).join(", ")}`); } } return result; } catch (error) { this.log("error", "Error processing interaction:", error); this.emitEvent("error", { error, context }); throw error; } } /** * Get user */ getUser(userId) { return this.users.get(userId) || null; } /** * Get all users */ getAllUsers() { return Array.from(this.users.values()); } /** * Add points */ async addPoints(userId, points, context, appliedRules) { const user = this.users.get(userId); if (!user) { throw new Error(`User not found: ${userId}`); } const oldPoints = user.points; const oldLevel = user.level; user.points += points; user.lastSeen = new Date(); user.stats.totalPointsEarned += Math.max(0, points); // Don't include negative points in statistics // Calculate level const newLevel = this.calculateLevel(user.points); const leveledUp = newLevel > oldLevel; if (leveledUp) { user.level = newLevel; } // Threshold check (temporary implementation) const triggeredActions = this.checkThresholds(user, oldPoints); const result = { pointsAdded: points, totalPoints: user.points, appliedRules: appliedRules || [], triggeredActions, leveledUp, ...(leveledUp && { newLevel }), }; // Emit event this.emitEvent("points_updated", { userId, oldPoints, newPoints: user.points, pointsAdded: points, }); if (leveledUp) { this.emitEvent("level_up", { userId, oldLevel, newLevel, }); } return result; } /** * Calculate level */ calculateLevel(points) { // Simple level calculation (1 level per 100 points, max 10 levels) return Math.min(Math.floor(points / 100) + 1, 10); } /** * Get statistics */ getStats() { const users = this.getAllUsers(); return { totalUsers: users.length, totalPoints: users.reduce((sum, user) => sum + user.points, 0), averageLevel: users.reduce((sum, user) => sum + user.level, 0) / users.length || 0, ownerUsers: users.filter((user) => user.type === "owner").length, activeToday: users.filter((user) => { const today = new Date(); const lastSeen = new Date(user.lastSeen); return lastSeen.toDateString() === today.toDateString(); }).length, }; } // ============================================================================ // Private methods // ============================================================================ /** * Get or create user */ getOrCreateUser(context) { let user = this.users.get(context.userId); if (!user) { // Create new user user = this.createUser(context); this.users.set(context.userId, user); this.emitEvent("user_created", { userId: context.userId, user }); this.log("info", `New user created: ${context.userId}`); } else { // Update existing user user.lastSeen = new Date(); user.stats.totalMessages++; // Count today's messages const today = new Date().toDateString(); const lastSeenDate = new Date(user.lastSeen).toDateString(); if (today !== lastSeenDate) { user.stats.todayMessages = 1; } else { user.stats.todayMessages++; } } return user; } /** * Create new user */ createUser(context) { const userType = this.determineUserType(context); const displayName = this.extractDisplayName(context.userId); const user = { id: context.userId, displayName, type: userType, points: userType === "owner" ? this.config.owner.initialPoints : 0, level: 1, achievements: [], stats: { totalMessages: 1, totalPointsEarned: 0, dailyStreak: 1, favoriteEmotions: {}, todayMessages: 1, }, firstSeen: new Date(), lastSeen: new Date(), }; return user; } /** * Determine user type */ determineUserType(context) { if (context.isOwner || context.platform === "chatForm") { return "owner"; } switch (context.platform) { case "youtube": return "youtube"; case "twitch": return "twitch"; case "websocket": return "websocket"; default: return "websocket"; // Default } } /** * Extract display name from user ID */ extractDisplayName(userId) { const parts = userId.split(":"); return parts.length > 1 ? parts[1] || "unknown" : userId; } /** * Calculate points */ calculatePoints(context, user) { const calculationResult = this.pointCalculator.calculatePoints(context, user); return { points: calculationResult.points, appliedRules: calculationResult.appliedRules, }; } /** * Check thresholds (temporary implementation) */ checkThresholds(user, oldPoints) { const triggeredActions = []; for (const threshold of this.config.thresholds) { if (user.points >= threshold.points && oldPoints < threshold.points) { triggeredActions.push({ ...threshold.action, executedAt: new Date(), }); this.emitEvent("threshold_reached", { userId: user.id, threshold, user, }); } } return triggeredActions; } /** * Add interaction record */ addInteractionRecord(user, context, result) { // TODO: Implement interaction history // Add record to user.stats.interactionHistory } /** * Load data from storage */ async loadFromStorage() { if (!this.storageProvider) return; try { const userData = await this.storageProvider.load(this.storageKey); if (userData) { // Restore Date objects for (const [userId, user] of Object.entries(userData)) { user.firstSeen = new Date(user.firstSeen); user.lastSeen = new Date(user.lastSeen); this.users.set(userId, user); } this.log("info", `Loaded ${this.users.size} users from storage`); } } catch (error) { this.log("error", "Failed to load from storage:", error); } } /** * Save data to storage */ async saveToStorage() { if (!this.storageProvider) return; try { const userData = Object.fromEntries(this.users); await this.storageProvider.save(this.storageKey, userData); this.log("debug", "Data saved to storage"); } catch (error) { this.log("error", "Failed to save to storage:", error); } } /** * Set up automatic cleanup */ setupAutoCleanup() { const intervalMs = this.config.storage.cleanupIntervalHours * 60 * 60 * 1000; setInterval(() => { this.performCleanup(); }, intervalMs); } /** * Perform data cleanup */ performCleanup() { const now = Date.now(); const retentionMs = this.config.storage.dataRetentionDays * 24 * 60 * 60 * 1000; let cleanedCount = 0; for (const [userId, user] of this.users) { // Don't delete owners if (user.type === "owner") continue; // Delete users who exceed retention period if (now - new Date(user.lastSeen).getTime() > retentionMs) { this.users.delete(userId); cleanedCount++; } } // If max users exceeded, delete oldest users first if (this.users.size > this.config.storage.maxUsers) { const sortedUsers = Array.from(this.users.entries()) .filter(([, user]) => user.type !== "owner") .sort(([, a], [, b]) => new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime()); const toDelete = sortedUsers.slice(0, this.users.size - this.config.storage.maxUsers); for (const [userId] of toDelete) { this.users.delete(userId); cleanedCount++; } } if (cleanedCount > 0) { this.log("info", `Cleaned up ${cleanedCount} users`); } } /** * Emit event */ emitEvent(type, data) { const eventData = { type, userId: data?.userId || "", data, timestamp: new Date(), }; this.emit(type, eventData); } /** * Log output */ log(level, message, ...args) { if (!this.shouldLog(level)) return; const timestamp = new Date().toISOString(); const prefix = `[Kizuna ${timestamp}]`; switch (level) { case "debug": console.debug(prefix, message, ...args); break; case "info": console.info(prefix, message, ...args); break; case "warn": console.warn(prefix, message, ...args); break; case "error": console.error(prefix, message, ...args); break; } } /** * Check log level */ shouldLog(level) { const levels = ["debug", "info", "warn", "error"]; const currentLevel = levels.indexOf(this.config.dev.logLevel); const messageLevel = levels.indexOf(level); return messageLevel >= currentLevel; } } //# sourceMappingURL=KizunaManager.js.map