generated from ztimson/template
All checks were successful
Build and publish / Build Container (push) Successful in 1m29s
305 lines
10 KiB
JavaScript
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);
|
|
}
|
|
}
|