Files
Mortdecai/oracle-bot/world-state.js
T
Seth 7e9acad658 feat(oracle): world state abstraction layer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 04:08:45 -04:00

244 lines
6.6 KiB
JavaScript

'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;