feat(oracle): world state abstraction layer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user