diff --git a/oracle-bot/world-state.js b/oracle-bot/world-state.js new file mode 100644 index 0000000..655b902 --- /dev/null +++ b/oracle-bot/world-state.js @@ -0,0 +1,243 @@ +'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;