Files
navi/public/netnavi-api.js
ztimson 4735968612
All checks were successful
Build and publish / Build Container (push) Successful in 1m29s
Nav init
2026-02-28 23:27:35 -05:00

305 lines
10 KiB
JavaScript

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