7e9acad658
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
244 lines
6.6 KiB
JavaScript
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;
|