class NetNaviAPI { constructor(baseUrl = window.location.origin) { if(window.netNaviAPI) return window.netNaviAPI; this.baseUrl = baseUrl.replace(/\/$/, ''); // State properties this.petInfo = null; this.spriteSheet = null; this.isConnected = false; this.lastSync = null; this.currentWorld = null; this.currentWorldHost = null; this.currentPlayers = new Map(); // Socket this.worldSocket = null; this.llmSocket = io(`${this.baseUrl}/llm`); this._setupLLMListeners(); this.listeners = new Map(); } _setupLLMListeners() { this.llmSocket.on('stream', (chunk) => { this.emit('llm:stream', chunk); if (this.currentStreamCallback) this.currentStreamCallback(chunk); }); this.llmSocket.on('response', (data) => { this.emit('llm:response', data); if (this.currentResolve) this.currentResolve(data); this.currentStreamCallback = null; this.currentResolve = null; this.currentReject = null; }); this.llmSocket.on('error', (error) => { console.error('❌ LLM socket error:', error); this.emit('llm:error', error); if (this.currentReject) this.currentReject(error); this.currentStreamCallback = null; this.currentResolve = null; this.currentReject = null; }); } // ============================================ // EVENT SYSTEM // ============================================ on(event, callback) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); return () => this.off(event, callback); } off(event, callback) { const callbacks = this.listeners.get(event); if (callbacks) { const index = callbacks.indexOf(callback); if (index > -1) callbacks.splice(index, 1); } } emit(event, data) { const callbacks = this.listeners.get(event) || []; callbacks.forEach(cb => cb(data)); } // ============================================ // REST API // ============================================ async getPetInfo(petId, forceRefresh = false) { if (this.petInfo && !forceRefresh) { return this.petInfo; } const response = await fetch(`${this.baseUrl}/api/info`); if (!response.ok) throw new Error('Failed to fetch PET info fr fr'); this.petInfo = await response.json(); this.lastSync = Date.now(); this.emit('petInfo:updated', this.petInfo); return this.petInfo; } async getSpriteSheet(petId, forceRefresh = false) { if (this.spriteSheet && !forceRefresh) { return this.spriteSheet; } const response = await fetch(`${this.baseUrl}/api/sprite`); if (!response.ok) throw new Error('Sprite sheet is cooked 💀'); this.spriteSheet = await response.json(); this.emit('sprite:updated', this.spriteSheet); return this.spriteSheet; } async sendUserMessage(userId, message) { const response = await fetch(`${this.baseUrl}/api/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) }); if (!response.ok) throw new Error('Message failed to send bestie'); const result = await response.json(); this.emit('message:sent', { userId, message, result }); return result; } async linkPet(petId, targetApiUrl) { const response = await fetch(`${this.baseUrl}/api/link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetApiUrl }) }); if (!response.ok) throw new Error('Link connection is bussin (negatively)'); const result = await response.json(); this.emit('pet:linked', { petId, targetApiUrl, result }); return result; } // ============================================ // WORLD SOCKET // ============================================ joinWorld(worldId, callbacks = {}) { // Parse the worldId to check if it includes a host let targetHost = this.baseUrl; let actualWorldId = worldId; // Check if worldId is a URL (like "http://other-server.com/worldName") if(worldId?.startsWith('http://') || worldId?.startsWith('https://')) { const url = new URL(worldId); targetHost = url.origin; actualWorldId = url.pathname.replace(/^\//, '') || 'default'; } // Leave current world first if we're switching 🚪 if (this.worldSocket && this.currentWorld) { this.worldSocket.emit('player-leave'); // Let server know we're bouncing if (this.currentWorldHost !== targetHost) { this.worldSocket.disconnect(); this.worldSocket = null; } } // Initialize world socket for the target host 🔌 if (!this.worldSocket) { this.worldSocket = io(targetHost); this.currentWorldHost = targetHost; this.isConnected = true; this.emit('connection:changed', true); } this.currentWorld = actualWorldId; // Auto-build playerInfo from state 💪 const playerInfo = { name: this.petInfo?.name || 'Guest', apiUrl: this.baseUrl }; // Setup socket listeners with provided callbacks 📡 this.worldSocket.on('world-data', (data) => { this.emit('world:loaded', data); if (callbacks.onWorldLoaded) callbacks.onWorldLoaded(data); }); this.worldSocket.on('current-players', (players) => { this.currentPlayers.clear(); players.forEach(p => this.currentPlayers.set(p.socketId, p)); this.emit('world:currentPlayers', players); if (callbacks.onCurrentPlayers) callbacks.onCurrentPlayers(players); }); this.worldSocket.on('player-joined', (player) => { this.currentPlayers.set(player.socketId, player); this.emit('world:playerJoined', player); if (callbacks.onPlayerJoined) callbacks.onPlayerJoined(player); }); this.worldSocket.on('player-moved', (data) => { const player = this.currentPlayers.get(data.socketId); if (player) { player.x = data.x; player.y = data.y; } this.emit('world:playerMoved', data); if (callbacks.onPlayerMoved) callbacks.onPlayerMoved(data); }); this.worldSocket.on('player-left', (data) => { this.currentPlayers.delete(data.socketId); this.emit('world:playerLeft', data); if (callbacks.onPlayerLeft) callbacks.onPlayerLeft(data); }); this.worldSocket.on('error', (error) => { console.error('❌ World socket error:', error); this.emit('world:error', error); if (callbacks.onError) callbacks.onError(error); }); // Join the world 🌍 this.worldSocket.emit('join-world', { worldId: actualWorldId, playerInfo }); // Return actions dictionary for sending updates 📤 return { move: (x, y) => { this.worldSocket.emit('player-move', { x, y }); }, leave: () => { this.worldSocket.disconnect(); this.worldSocket = null; this.currentWorld = null; this.currentWorldHost = null; this.currentPlayers.clear(); this.isConnected = false; this.emit('connection:changed', false); }, reconnect: () => { if (!this.worldSocket || !this.worldSocket.connected) { this.worldSocket = io(this.currentWorldHost || targetHost); this.worldSocket.emit('join-world', { worldId: actualWorldId, playerInfo }); this.isConnected = true; this.emit('connection:changed', true); } } }; } // ============================================ // LLM SOCKET // ============================================ sendPetMessage(message, onStreamCallback) { this.currentStreamCallback = onStreamCallback; const promise = new Promise((resolve, reject) => { this.currentResolve = resolve; this.currentReject = reject; this.llmSocket.emit('message', {message, apiUrl: this.baseUrl}); }); promise.abort = () => { this.llmSocket.emit('abort'); if(this.currentReject) this.currentReject(new Error('Aborted by user')); this.currentStreamCallback = null; this.currentResolve = null; this.currentReject = null; }; return promise; } clearLLMHistory() { if(this.llmSocket) { this.llmSocket.emit('clear'); this.emit('llm:cleared'); } } disconnectLLM() { if(this.llmSocket) { this.llmSocket.disconnect(); this.llmSocket = null; } } // ============================================ // UTILITY // ============================================ getState() { return { petInfo: this.petInfo, spriteSheet: this.spriteSheet, isConnected: this.isConnected, lastSync: this.lastSync, currentWorld: this.currentWorld, currentPlayers: Array.from(this.currentPlayers.values()) }; } disconnect() { if (this.worldSocket) { this.worldSocket.disconnect(); this.worldSocket = null; } if (this.llmSocket) { this.llmSocket.disconnect(); this.llmSocket = null; } this.isConnected = false; this.emit('connection:changed', false); } }