'use strict'; /** * world-state.js — WorldState: aggregates OracleBot perception + gateway traces * into a unified, versioned state object for the oracle-bot WebSocket layer. * * All outbound messages carry `v: 1` for protocol versioning. */ const ACTIVE_TIMEOUT = 10000; // ms before reverting to idle const MAX_TRACES = 50; const SCAN_RADIUS = 16; const ENTITY_RADIUS = 30; class WorldState { /** * @param {import('./bot.js')} bot - Connected OracleBot instance */ constructor(bot) { this.bot = bot; // Public constants (readable by consumers) this.ACTIVE_TIMEOUT = ACTIVE_TIMEOUT; this.MAX_TRACES = MAX_TRACES; // Mode tracking this.mode = 'idle'; // 'idle' | 'god' | 'sudo' this.activePlayer = null; // player name or null this.activeSessionId = null; // current session ID or null // Trace ring buffer this.traces = []; this.lastActivity = null; // Date.now() timestamp or null } // ────────────────────────────────────────────── // Trace ingestion // ────────────────────────────────────────────── /** * Called when the gateway POSTs a trace event. * Updates mode, activePlayer, sessionId and appends to ring buffer. * * @param {Object} traceData - Arbitrary trace payload from the gateway */ onTrace(traceData) { // Update mode if provided and valid if (traceData.mode && ['idle', 'god', 'sudo'].includes(traceData.mode)) { this.mode = traceData.mode; } // Update active player if provided if (traceData.player !== undefined) { this.activePlayer = traceData.player || null; } // Update session ID if provided if (traceData.session_id !== undefined) { this.activeSessionId = traceData.session_id || null; } this.lastActivity = Date.now(); // Append to ring buffer, evict oldest if at capacity this.traces.push({ ...traceData, ts: this.lastActivity }); if (this.traces.length > MAX_TRACES) { this.traces.shift(); } } // ────────────────────────────────────────────── // Idle detection // ────────────────────────────────────────────── /** * Check if session has gone idle due to inactivity. * If mode != idle and lastActivity is older than ACTIVE_TIMEOUT, revert to idle. * * @returns {boolean} true if mode changed to idle */ checkIdle() { if (this.mode === 'idle') return false; if (this.lastActivity === null) { this.mode = 'idle'; this.activePlayer = null; this.activeSessionId = null; return true; } if (Date.now() - this.lastActivity > ACTIVE_TIMEOUT) { this.mode = 'idle'; this.activePlayer = null; this.activeSessionId = null; return true; } return false; } // ────────────────────────────────────────────── // Message builders // ────────────────────────────────────────────── /** * Heartbeat: lightweight pulse with player list and world time. * Safe to call even when bot is not spawned — returns empty players on error. * * @returns {{v:number, type:string, mode:string, activePlayer:string|null, players:Array, world:Object, ts:number}} */ buildHeartbeat() { let players = []; let world = { time: null, isRaining: null }; try { players = this.bot.getPlayers(); } catch (_) { // Bot not yet spawned — return empty array } try { const info = this.bot.getWorldInfo(); world = { time: info.timeOfDay, isRaining: info.raining, }; } catch (_) { // Bot not yet spawned — leave defaults } return { v: 1, type: 'heartbeat', mode: this.mode, activePlayer: this.activePlayer, players, world, ts: Date.now(), }; } /** * World snapshot: full spatial state centered on the active player. * Returns null if no activePlayer or player position is unknown. * * @returns {Object|null} */ buildWorldSnapshot() { if (!this.activePlayer) return null; // Find the active player's position from the bot's player list let center = null; try { const players = this.bot.getPlayers(); const match = players.find((p) => p.name === this.activePlayer); if (!match || match.x === null || match.y === null || match.z === null) { // Player entity not loaded yet return null; } center = { x: match.x, y: match.y, z: match.z }; } catch (_) { return null; } let blocks = []; let entities = []; let allPlayers = []; try { blocks = this.bot.scanArea(center.x, center.y, center.z, SCAN_RADIUS); } catch (_) { // Bot not spawned or other error — leave empty } try { entities = this.bot.getNearbyEntities(center.x, center.y, center.z, ENTITY_RADIUS); } catch (_) { // Bot not spawned or other error — leave empty } try { allPlayers = this.bot.getPlayers(); } catch (_) { // Bot not spawned or other error — leave empty } return { v: 1, type: 'world', mode: this.mode, activePlayer: this.activePlayer, center, blocks, entities, players: allPlayers, ts: Date.now(), }; } /** * Wrap a raw trace payload into a versioned trace event message. * * @param {Object} traceData * @returns {{v:number, type:string, ts:number, ...traceData}} */ buildTraceEvent(traceData) { return { v: 1, type: 'trace', ...traceData, ts: Date.now(), }; } /** * Mode change notification. Emit after onTrace() updates mode. * * @returns {{v:number, type:string, mode:string, player:string|null, ts:number}} */ buildModeChange() { return { v: 1, type: 'mode', mode: this.mode, player: this.activePlayer, ts: Date.now(), }; } /** * Bot connection status message. * * @param {boolean} connected * @returns {{v:number, type:string, connected:boolean, ts:number}} */ buildStatus(connected) { return { v: 1, type: 'status', connected, ts: Date.now(), }; } } module.exports = WorldState;