feat: add world voxel snapshot forwarding
Collect chunk voxel data from the bot world and stream snapshot, chunk, and unwatch messages through the forward protocol.
This commit is contained in:
+19
-1
@@ -252,6 +252,9 @@ export class NodeForwardManager {
|
||||
case 'get_data_file_list':
|
||||
case 'get_file_data':
|
||||
case 'execute_function':
|
||||
case 'world.snapshot':
|
||||
case 'world.chunk':
|
||||
case 'world.unwatch':
|
||||
case 'workflow.start':
|
||||
case 'workflow.stop':
|
||||
case 'workflow.snapshot':
|
||||
@@ -277,6 +280,14 @@ export class NodeForwardManager {
|
||||
const listing = data as {expires_at: number};
|
||||
listing.expires_at = Math.min(listing.expires_at, entry.lease || entry.command.expires_at);
|
||||
}
|
||||
} else if (request.method === 'world.snapshot') {
|
||||
data = await this.until_deadline(entry.stream.world_snapshot(request.payload, request.deadline_at, check_transport),
|
||||
request.deadline_at, () => {});
|
||||
} else if (request.method === 'world.chunk') {
|
||||
data = await this.until_deadline(entry.stream.world_chunk(request.payload, request.deadline_at, check_transport),
|
||||
request.deadline_at, () => {});
|
||||
} else if (request.method === 'world.unwatch') {
|
||||
data = entry.stream.world_unwatch(request.payload, request.deadline_at, check_transport);
|
||||
} else if (request.method === 'workflow.start') {
|
||||
data = entry.stream.workflow_start(request.payload, request.deadline_at, check_transport);
|
||||
} else if (request.method === 'workflow.stop') {
|
||||
@@ -341,8 +352,15 @@ export class NodeForwardManager {
|
||||
let window = Date.now();
|
||||
let count = 0;
|
||||
entry.unsubscribe = entry.stream!.subscribe(event => {
|
||||
if ((event.name !== 'log' && event.name !== 'info_update') || entry.socket !== socket || !socket.connected
|
||||
if ((event.name !== 'log' && event.name !== 'info_update' && event.name !== 'status_update') || entry.socket !== socket || !socket.connected
|
||||
|| !entry.ready || entry.terminal || this.stopped) return;
|
||||
if (event.name === 'status_update') {
|
||||
// Lifecycle state must survive a busy log stream; reconnect rather
|
||||
// than silently retaining an online snapshot when the queue is full.
|
||||
if ((socket.io.engine?.writeBuffer.length ?? 0) >= 64) this.close_transport(entry);
|
||||
else socket.emit('bot_event', {...event, forward_session_id: entry.command.forward_session_id});
|
||||
return;
|
||||
}
|
||||
if (Date.now() - window >= 1000) { window = Date.now(); count = 0; }
|
||||
if (++count > 150 || (socket.io.engine?.writeBuffer.length ?? 0) >= 64) return;
|
||||
if (event.name === 'log') {
|
||||
|
||||
+79
-3
@@ -12,11 +12,13 @@ import type {RunSnapshot, StartResult, WorkflowManager, WorkflowCommand, Workflo
|
||||
import type {WorkflowCatalog} from '../../types/bot/workflow/metadata.ts';
|
||||
import {get_workflow_catalog} from '../workflow/catalog.ts';
|
||||
import {summarize_run, type RunSummary} from '../workflow/runtime/summary.ts';
|
||||
import {object, validate_workflow_payload} from '../../utils/forward_protocol.ts';
|
||||
import {object, validate_workflow_payload, validate_world_payload} from '../../utils/forward_protocol.ts';
|
||||
import {WorldPreview, type WorldBot} from './world.ts';
|
||||
import type {WorldSnapshot, WorldVoxelChunk} from '../../types/forward.ts';
|
||||
|
||||
export interface StreamContext {
|
||||
readonly bot_status: bot_status;
|
||||
readonly bot_instance: {
|
||||
readonly bot_instance: ({
|
||||
chat(message: string): unknown;
|
||||
players?: Record<string, unknown>;
|
||||
tabComplete?(text: string, assumeCommand?: boolean, sendBlockInSight?: boolean, timeout?: number): Promise<unknown[]>;
|
||||
@@ -28,7 +30,7 @@ export interface StreamContext {
|
||||
on(name: string, listener: (packet: any) => void): unknown;
|
||||
off(name: string, listener: (packet: any) => void): unknown;
|
||||
};
|
||||
} | null;
|
||||
} & Partial<Omit<WorldBot, 'game'>> & {game?: Partial<WorldBot['game']>}) | null;
|
||||
readonly logger?: {read_recent_log(count: number): Promise<LogHistoryRecord[]>};
|
||||
readonly info_manager?: Pick<InfoManager, 'get_all_info'> | null;
|
||||
readonly function_manager?: Pick<FunctionManager, 'run'> | null;
|
||||
@@ -326,6 +328,7 @@ export class BotStream {
|
||||
private context_listeners: Array<[string, (...args: any[]) => void]> = [];
|
||||
private unobserve: (() => void) | undefined;
|
||||
private disposed = false;
|
||||
private world_preview?: WorldPreview;
|
||||
|
||||
constructor(readonly bot_id: number, options: BotStreamOptions = {}) {
|
||||
if (!Number.isSafeInteger(bot_id) || bot_id < 1) throw new BotStreamError('INVALID_REQUEST', 'Invalid bot ID');
|
||||
@@ -402,6 +405,8 @@ export class BotStream {
|
||||
}
|
||||
|
||||
private detach() {
|
||||
this.world_preview?.dispose();
|
||||
this.world_preview = undefined;
|
||||
for (const [event, listener] of this.context_listeners) this.context?.off(event, listener);
|
||||
this.context_listeners = [];
|
||||
}
|
||||
@@ -414,6 +419,10 @@ export class BotStream {
|
||||
}
|
||||
|
||||
private status_event() {
|
||||
if (this.context?.bot_status !== 'online' || this.world_preview?.bot !== this.context?.bot_instance) {
|
||||
this.world_preview?.dispose();
|
||||
this.world_preview = undefined;
|
||||
}
|
||||
this.publish('status_update', this.state());
|
||||
}
|
||||
|
||||
@@ -463,6 +472,73 @@ export class BotStream {
|
||||
};
|
||||
}
|
||||
|
||||
private world_context(deadline_at: number, check_transport: () => void, existing = false) {
|
||||
this.active();
|
||||
check_transport();
|
||||
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
|
||||
throw new BotStreamError('TIMEOUT', 'World request deadline expired');
|
||||
const context = this.context;
|
||||
if (!context) throw new BotStreamError('NO_CONTEXT', 'Bot context does not exist');
|
||||
const instance = context.bot_instance;
|
||||
if (context.bot_status !== 'online' || !instance) {
|
||||
this.world_preview?.dispose();
|
||||
this.world_preview = undefined;
|
||||
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
|
||||
}
|
||||
if (!instance.world || !instance.game || !instance.entity || !instance.registry || !instance.version
|
||||
|| typeof instance.on !== 'function' || typeof instance.off !== 'function')
|
||||
throw new BotStreamError('INTERNAL_ERROR', 'Bot world is unavailable');
|
||||
if (this.world_preview?.bot !== instance || this.world_preview?.is_disposed) {
|
||||
this.world_preview?.dispose();
|
||||
this.world_preview = undefined;
|
||||
}
|
||||
if (existing && !this.world_preview) throw new BotStreamError('CANCELLED', 'World watch is unavailable');
|
||||
const service = this.world_preview ??= new WorldPreview(instance as WorldBot, generation => {
|
||||
if (!this.disposed && this.source.get(this.bot_id) === context && context.bot_instance === instance
|
||||
&& context.bot_status === 'online') this.publish('info_update', {name: 'world_preview', value: {world_generation: generation}});
|
||||
}, this.now);
|
||||
const check = () => {
|
||||
check_transport();
|
||||
if (deadline_at <= this.now()) throw new BotStreamError('TIMEOUT', 'World request deadline expired');
|
||||
if (this.disposed || this.source.get(this.bot_id) !== context || context.bot_instance !== instance
|
||||
|| this.world_preview !== service) {
|
||||
service.dispose();
|
||||
throw new BotStreamError('CANCELLED', 'Bot context changed');
|
||||
}
|
||||
if (context.bot_status !== 'online') {
|
||||
service.dispose();
|
||||
throw new BotStreamError('BOT_NOT_ONLINE', 'Bot is not online');
|
||||
}
|
||||
};
|
||||
check();
|
||||
return {service, check};
|
||||
}
|
||||
|
||||
async world_snapshot(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): Promise<WorldSnapshot> {
|
||||
const input = structuredClone(validate_world_payload('world.snapshot', payload));
|
||||
const {service, check} = this.world_context(deadline_at, check_transport);
|
||||
const result = await service.snapshot(input, check);
|
||||
check();
|
||||
return {...result, context_generation: this.generation!, stream_id: this.stream_id};
|
||||
}
|
||||
|
||||
async world_chunk(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): Promise<WorldVoxelChunk> {
|
||||
const input = structuredClone(validate_world_payload('world.chunk', payload));
|
||||
const {service, check} = this.world_context(deadline_at, check_transport, true);
|
||||
const result = await service.chunk(input, check);
|
||||
check();
|
||||
return result;
|
||||
}
|
||||
|
||||
world_unwatch(payload: unknown, deadline_at: number, check_transport: () => void = () => {}): {released: true} {
|
||||
const input = validate_world_payload('world.unwatch', payload);
|
||||
this.active();
|
||||
check_transport();
|
||||
if (!Number.isSafeInteger(deadline_at) || deadline_at <= this.now())
|
||||
throw new BotStreamError('TIMEOUT', 'World request deadline expired');
|
||||
return this.world_preview?.unwatch(input.watch_id) ?? {released: true};
|
||||
}
|
||||
|
||||
get_context_info(payload: unknown, deadline_at: number): InfoSnapshot[] {
|
||||
this.active();
|
||||
if (!exact(payload, [])) throw new BotStreamError('INVALID_REQUEST', 'Invalid info request');
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {setImmediate as yield_turn} from 'node:timers/promises';
|
||||
import type {WorldChunk, WorldPayloads, WorldSnapshot, WorldVoxelChunk} from '../../types/forward.ts';
|
||||
import {ForwardFailure} from '../../utils/forward_protocol.ts';
|
||||
|
||||
type Position = {x: number; y: number; z: number};
|
||||
export interface WorldColumn {getBlockStateId(position: Position): number}
|
||||
type LoadedWorld = {getColumn(x: number, z: number): WorldColumn | null | undefined};
|
||||
export interface WorldBot {
|
||||
world: Partial<LoadedWorld> & {sync?: LoadedWorld};
|
||||
game: {minY: number; height: number; dimension: string};
|
||||
entity: {position: Position; yaw: number};
|
||||
registry: {blocksByStateId: Record<number, {name: string; minStateId?: number; maxStateId?: number;
|
||||
boundingBox?: string; transparent?: boolean; filterLight?: number;
|
||||
states?: Array<{name: string; type?: string; num_values: number; values?: unknown[]}>}>;
|
||||
blocksByName?: Record<string, {minStateId?: number; defaultState?: number}>};
|
||||
version: string;
|
||||
on(event: any, listener: (...args: any[]) => void): unknown;
|
||||
off(event: any, listener: (...args: any[]) => void): unknown;
|
||||
}
|
||||
|
||||
type Watch = {radius: number; expires_at: number; world_generation?: string};
|
||||
type ColumnCache = {column: WorldColumn; revision: string; chunk?: WorldChunk};
|
||||
const air = new Set(['air', 'cave_air', 'void_air']);
|
||||
const water_blocks = new Set(['water', 'bubble_column', 'kelp', 'kelp_plant', 'seagrass', 'tall_seagrass']);
|
||||
const colors: Record<string, number> = {
|
||||
air: 0, grass_block: 0x78a54a, short_grass: 0x76a644, tall_grass: 0x76a644,
|
||||
fern: 0x548c3b, large_fern: 0x548c3b, moss_block: 0x637e36, dirt: 0x866043,
|
||||
coarse_dirt: 0x79553a, podzol: 0x674b2a, mud: 0x49403b, mycelium: 0x766376,
|
||||
water: 0x3f76e4, bubble_column: 0x3f76e4, lava: 0xf36b19,
|
||||
stone: 0x858585, cobblestone: 0x787878, deepslate: 0x505054, bedrock: 0x555555,
|
||||
granite: 0x956856, diorite: 0xbcbcbc, andesite: 0x888a88, tuff: 0x6c716a,
|
||||
sand: 0xdbd29d, red_sand: 0xb96621, sandstone: 0xd5c58b, gravel: 0x8a817d,
|
||||
clay: 0x9ba4b1, snow: 0xf3fafa, snow_block: 0xf3fafa, ice: 0x91b8ef,
|
||||
packed_ice: 0x8db0e7, blue_ice: 0x6da3ed, glass: 0xb4d6d9,
|
||||
netherrack: 0x713232, soul_sand: 0x514035, soul_soil: 0x4b392f,
|
||||
end_stone: 0xd9dda1, obsidian: 0x30243f, bricks: 0x965f51,
|
||||
terracotta: 0x9a5e44, oak_leaves: 0x4e7930, birch_leaves: 0x76a64c,
|
||||
spruce_leaves: 0x3e633c, jungle_leaves: 0x4d822c, cherry_leaves: 0xe5a6bf,
|
||||
};
|
||||
|
||||
function block_color(name: string): number {
|
||||
if (Object.hasOwn(colors, name)) return colors[name]!;
|
||||
if (name.endsWith('_leaves')) return 0x50843c;
|
||||
if (name.endsWith('_log') || name.endsWith('_wood')) return 0x715334;
|
||||
if (name.endsWith('_planks')) return 0xb38b56;
|
||||
if (name.includes('stone') || name.endsWith('_ore')) return 0x858585;
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < name.length; index++) hash = Math.imul(hash ^ name.charCodeAt(index), 16777619);
|
||||
return (64 + ((hash >>> 16) & 127)) << 16 | (64 + ((hash >>> 8) & 127)) << 8 | (64 + (hash & 127));
|
||||
}
|
||||
|
||||
function fluid_level(state: number, block: WorldBot['registry']['blocksByStateId'][number]): number {
|
||||
if (!Number.isSafeInteger(block.minStateId) || state < block.minStateId! || !block.states) return 0;
|
||||
let offset = state - block.minStateId!;
|
||||
// Registry state IDs are mixed-radix indices; the last property varies fastest.
|
||||
for (let index = block.states.length - 1; index >= 0; index--) {
|
||||
const property = block.states[index]!;
|
||||
if (!Number.isSafeInteger(property.num_values) || property.num_values < 1) return 0;
|
||||
const value_index = offset % property.num_values;
|
||||
offset = Math.floor(offset / property.num_values);
|
||||
if (property.name === 'level') {
|
||||
const level = Number(property.values?.[value_index] ?? value_index);
|
||||
return Number.isInteger(level) && level >= 0 && level <= 15 ? level : 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
type SpikeSegment = NonNullable<WorldChunk['spikes']>['segments'][number];
|
||||
|
||||
function spike_properties(state: number, block: WorldBot['registry']['blocksByStateId'][number]) {
|
||||
const result: Pick<SpikeSegment, 'direction' | 'thickness'> = {direction: 'up', thickness: 'tip'};
|
||||
if (!Number.isSafeInteger(block.minStateId) || state < block.minStateId! || !block.states) return result;
|
||||
let offset = state - block.minStateId!;
|
||||
for (let index = block.states.length - 1; index >= 0; index--) {
|
||||
const property = block.states[index]!;
|
||||
if (!Number.isSafeInteger(property.num_values) || property.num_values < 1) return {direction: 'up', thickness: 'tip'} as const;
|
||||
const value = property.values?.[offset % property.num_values];
|
||||
offset = Math.floor(offset / property.num_values);
|
||||
if (property.name === 'vertical_direction' && (value === 'up' || value === 'down')) result.direction = value;
|
||||
if (property.name === 'thickness' && (value === 'tip' || value === 'tip_merge'
|
||||
|| value === 'frustum' || value === 'middle' || value === 'base')) result.thickness = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const feature_properties = new Set(['facing', 'rotation', 'half', 'type', 'shape', 'open', 'attached', 'lit', 'waterlogged',
|
||||
'east', 'west', 'north', 'south', 'axis', 'up', 'vertical_direction', 'thickness']);
|
||||
function surface_properties(state: number, block: WorldBot['registry']['blocksByStateId'][number]): Record<string, string | number | boolean> {
|
||||
const result: Record<string, string | number | boolean> = {};
|
||||
if (!Number.isSafeInteger(block.minStateId) || state < block.minStateId! || !block.states) return result;
|
||||
let offset = state - block.minStateId!;
|
||||
for (let i = block.states.length - 1; i >= 0; i--) {
|
||||
const property = block.states[i]!;
|
||||
if (!Number.isSafeInteger(property.num_values) || property.num_values < 1) return {};
|
||||
const index = offset % property.num_values;
|
||||
offset = Math.floor(offset / property.num_values);
|
||||
if (!feature_properties.has(property.name)) continue;
|
||||
const value = property.values?.[index] ?? (property.num_values === 2 ? index === 0 : index);
|
||||
if (property.name === 'rotation') result.rotation = Number(value);
|
||||
else if (value === 'true' || value === 'false') result[property.name] = value === 'true';
|
||||
else if (typeof value === 'string' || typeof value === 'boolean') result[property.name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function surface_feature(name: string, properties: Record<string, string | number | boolean>): boolean {
|
||||
return properties.waterlogged === true || /(?:^|_)(?:wall_)?torch$|_(?:sign|slab|stairs|fence|pane|trapdoor|wall)$/.test(name);
|
||||
}
|
||||
|
||||
function voxel_properties(state: number, block: WorldBot['registry']['blocksByStateId'][number]): Record<string, string | number | boolean> {
|
||||
const result: Record<string, string | number | boolean> = {};
|
||||
if (!block.states?.length) return result;
|
||||
if (block.states.length > 32 || !Number.isSafeInteger(block.minStateId) || state < block.minStateId!)
|
||||
throw new ForwardFailure('INTERNAL_ERROR');
|
||||
let offset = state - block.minStateId!;
|
||||
for (let i = block.states.length - 1; i >= 0; i--) {
|
||||
const property = block.states[i]!;
|
||||
if (typeof property.name !== 'string' || !property.name.length || property.name.length > 32
|
||||
|| ['__proto__', 'prototype', 'constructor'].includes(property.name) || Object.hasOwn(result, property.name)
|
||||
|| !Number.isSafeInteger(property.num_values) || property.num_values < 1)
|
||||
throw new ForwardFailure('INTERNAL_ERROR');
|
||||
const index = offset % property.num_values;
|
||||
offset = Math.floor(offset / property.num_values);
|
||||
const value = property.type === 'bool' ? index === 0
|
||||
: property.type === 'int' ? Number(property.values?.[index] ?? index)
|
||||
: property.values?.[index] ?? index;
|
||||
if (typeof value === 'string' ? value.length > 64
|
||||
: typeof value === 'number' ? !Number.isFinite(value) : typeof value !== 'boolean')
|
||||
throw new ForwardFailure('INTERNAL_ERROR');
|
||||
result[property.name] = value as string | number | boolean;
|
||||
}
|
||||
if (offset) throw new ForwardFailure('INTERNAL_ERROR');
|
||||
return result;
|
||||
}
|
||||
|
||||
function voxel_occluding(name: string, block: WorldBot['registry']['blocksByStateId'][number]): boolean {
|
||||
// Registry boundingBox describes collision presence, not whether it fills the cube.
|
||||
return block.boundingBox === 'block' && block.transparent !== true && block.filterLight !== 0 && !air.has(name)
|
||||
&& !/(?:^|_)(?:slab|stairs|fence|gate|wall|pane|door|trapdoor|torch|leaves|glass|water|lava|snow|cake|candle|carpet|bed|button|pressure_plate|sign|banner|head|skull|coral|fan|flower|sapling|plant|roots|rail)$/.test(name)
|
||||
&& !['farmland', 'dirt_path', 'soul_sand', 'enchanting_table', 'brewing_stand', 'cauldron', 'hopper',
|
||||
'anvil', 'chipped_anvil', 'damaged_anvil', 'cactus', 'dragon_egg', 'end_portal_frame', 'piston_head',
|
||||
'moving_piston', 'chest', 'trapped_chest', 'ender_chest', 'bell', 'stonecutter', 'grindstone', 'lectern',
|
||||
'composter', 'daylight_detector', 'sculk_sensor', 'calibrated_sculk_sensor'].includes(name);
|
||||
}
|
||||
|
||||
export class WorldPreview {
|
||||
private generation = randomUUID();
|
||||
private identity?: {world: WorldBot['world']; version: string; dimension: string; minY: number; height: number};
|
||||
private readonly watches = new Map<string, Watch>();
|
||||
private readonly cache = new Map<string, ColumnCache>();
|
||||
private readonly voxels = new Map<string, {entry: ColumnCache; chunk: WorldVoxelChunk; bytes: number}>();
|
||||
private listeners: Array<[string, (...args: any[]) => void]> = [];
|
||||
private expiry?: ReturnType<typeof setTimeout>;
|
||||
private invalidation?: ReturnType<typeof setTimeout>;
|
||||
private center?: {x: number; z: number};
|
||||
private disposed = false;
|
||||
private pending = 0;
|
||||
private pending_voxels = 0;
|
||||
|
||||
constructor(readonly bot: WorldBot, private readonly hint: (generation: string) => void,
|
||||
private readonly now: () => number = Date.now) {}
|
||||
|
||||
private metadata() {
|
||||
const {game, world, version, registry, entity} = this.bot;
|
||||
// Mineflayer exposes WorldSync directly; adapters may expose the parent World's .sync.
|
||||
const sync = world?.sync ?? world;
|
||||
if (!sync || typeof sync.getColumn !== 'function' || !registry?.blocksByStateId
|
||||
|| !game || !Number.isSafeInteger(game.minY) || game.minY < -4096 || game.minY > 4096
|
||||
|| !Number.isSafeInteger(game.height) || game.height < 1 || game.height > 4096
|
||||
|| typeof game.dimension !== 'string' || !game.dimension.length || game.dimension.length > 256
|
||||
|| typeof version !== 'string' || !version.length || version.length > 64
|
||||
|| !entity?.position || ![entity.position.x, entity.position.y, entity.position.z, entity.yaw].every(Number.isFinite)) {
|
||||
throw new ForwardFailure('INTERNAL_ERROR');
|
||||
}
|
||||
const center = {x: Math.floor(entity.position.x / 16), z: Math.floor(entity.position.z / 16)};
|
||||
if (!Number.isSafeInteger(center.x) || !Number.isSafeInteger(center.z)
|
||||
|| Math.abs(center.x) > Number.MAX_SAFE_INTEGER - 3 || Math.abs(center.z) > Number.MAX_SAFE_INTEGER - 3)
|
||||
throw new ForwardFailure('INTERNAL_ERROR');
|
||||
return {world, sync: sync as LoadedWorld, version, dimension: game.dimension, minY: game.minY, height: game.height, center,
|
||||
position: {x: entity.position.x, y: entity.position.y, z: entity.position.z, yaw: entity.yaw}};
|
||||
}
|
||||
|
||||
private refresh() {
|
||||
const metadata = this.metadata();
|
||||
const previous = this.identity;
|
||||
if (!previous || previous.world !== metadata.world || previous.version !== metadata.version
|
||||
|| previous.dimension !== metadata.dimension || previous.minY !== metadata.minY || previous.height !== metadata.height) {
|
||||
this.generation = randomUUID();
|
||||
this.cache.clear();
|
||||
this.voxels.clear();
|
||||
this.identity = metadata;
|
||||
if (previous) this.invalidate();
|
||||
}
|
||||
if (this.center && (this.center.x !== metadata.center.x || this.center.z !== metadata.center.z)) this.invalidate();
|
||||
this.center = metadata.center;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private invalidate() {
|
||||
if (this.disposed || !this.watches.size || this.invalidation) return;
|
||||
this.invalidation = setTimeout(() => {
|
||||
this.invalidation = undefined;
|
||||
this.prune();
|
||||
if (!this.disposed && this.watches.size) this.hint(this.generation);
|
||||
}, 200);
|
||||
this.invalidation.unref();
|
||||
}
|
||||
|
||||
private change(position?: {x: number; z: number}) {
|
||||
this.prune();
|
||||
if (!this.watches.size || this.disposed) return;
|
||||
const chunk = position && Number.isFinite(position.x) && Number.isFinite(position.z)
|
||||
? {x: Math.floor(position.x / 16), z: Math.floor(position.z / 16)} : undefined;
|
||||
// Invalidate immediately even if position/game metadata is transient during respawn.
|
||||
if (chunk) this.forget(`${chunk.x},${chunk.z}`);
|
||||
try {
|
||||
const metadata = this.refresh();
|
||||
if (chunk && [...this.watches.values()].some(watch => Math.abs(chunk.x - metadata.center.x) <= watch.radius
|
||||
&& Math.abs(chunk.z - metadata.center.z) <= watch.radius)) this.invalidate();
|
||||
} catch { /* Metadata can be transiently unavailable during respawn; the next snapshot rechecks it. */ }
|
||||
}
|
||||
|
||||
private attach() {
|
||||
if (this.listeners.length) return;
|
||||
const listen = (name: string, callback: (...args: any[]) => void) => {
|
||||
this.listeners.push([name, callback]);
|
||||
this.bot.on(name, callback);
|
||||
};
|
||||
listen('chunkColumnLoad', position => this.change(position));
|
||||
listen('chunkColumnUnload', position => this.change(position));
|
||||
listen('blockUpdate', (old_block, new_block) => this.change(new_block?.position ?? old_block?.position));
|
||||
listen('move', () => this.change());
|
||||
listen('respawn', () => {
|
||||
if (!this.watches.size || this.disposed) return;
|
||||
// Respawning in the same dimension can retain the same world object.
|
||||
this.identity = undefined;
|
||||
try { this.refresh(); } catch {
|
||||
this.generation = randomUUID();
|
||||
this.cache.clear();
|
||||
this.voxels.clear();
|
||||
}
|
||||
this.invalidate();
|
||||
});
|
||||
listen('end', () => this.dispose());
|
||||
}
|
||||
|
||||
private idle() {
|
||||
for (const [name, listener] of this.listeners) this.bot.off(name, listener);
|
||||
this.listeners = [];
|
||||
clearTimeout(this.expiry);
|
||||
clearTimeout(this.invalidation);
|
||||
this.expiry = this.invalidation = undefined;
|
||||
this.cache.clear();
|
||||
this.voxels.clear();
|
||||
this.identity = undefined;
|
||||
this.center = undefined;
|
||||
}
|
||||
|
||||
private prune() {
|
||||
for (const [id, watch] of this.watches) if (watch.expires_at <= this.now()) this.watches.delete(id);
|
||||
if (!this.watches.size) this.idle();
|
||||
}
|
||||
|
||||
private arm_expiry() {
|
||||
clearTimeout(this.expiry);
|
||||
if (!this.watches.size) return;
|
||||
const expires = Math.min(...[...this.watches.values()].map(watch => watch.expires_at));
|
||||
this.expiry = setTimeout(() => { this.prune(); this.arm_expiry(); }, Math.max(1, expires - this.now()));
|
||||
this.expiry.unref();
|
||||
}
|
||||
|
||||
unwatch(id: string): {released: true} {
|
||||
this.watches.delete(id);
|
||||
this.prune();
|
||||
this.arm_expiry();
|
||||
return {released: true};
|
||||
}
|
||||
|
||||
get is_disposed() { return this.disposed; }
|
||||
|
||||
private forget(key: string) {
|
||||
this.cache.delete(key);
|
||||
this.voxels.delete(key);
|
||||
}
|
||||
|
||||
private column_entry(key: string, column: WorldColumn): ColumnCache {
|
||||
let entry = this.cache.get(key);
|
||||
if (!entry || entry.column !== column) {
|
||||
entry = {column, revision: randomUUID()};
|
||||
this.forget(key);
|
||||
this.cache.set(key, entry);
|
||||
while (this.cache.size > 96) this.forget(this.cache.keys().next().value!);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
async snapshot(input: WorldPayloads['world.snapshot'], check: () => void): Promise<Omit<WorldSnapshot, 'stream_id' | 'context_generation'>> {
|
||||
check();
|
||||
if (this.disposed) throw new ForwardFailure('CANCELLED');
|
||||
this.prune();
|
||||
if (this.pending >= 4) throw new ForwardFailure('RATE_LIMITED');
|
||||
const existing = input.watch_id && this.watches.has(input.watch_id) ? input.watch_id : undefined;
|
||||
if (!existing && this.watches.size >= 64) throw new ForwardFailure('RATE_LIMITED');
|
||||
const watch_id = existing ?? randomUUID();
|
||||
const watch: Watch = {radius: input.radius, expires_at: this.now() + 45_000};
|
||||
this.watches.set(watch_id, watch);
|
||||
this.attach();
|
||||
this.arm_expiry();
|
||||
this.pending++;
|
||||
try {
|
||||
const metadata = this.refresh();
|
||||
const generation = this.generation;
|
||||
watch.world_generation = generation;
|
||||
const valid = () => {
|
||||
check();
|
||||
if (this.disposed || this.watches.get(watch_id) !== watch || watch.expires_at <= this.now())
|
||||
throw new ForwardFailure('CANCELLED');
|
||||
const current = this.refresh();
|
||||
if (generation !== this.generation) throw new ForwardFailure('CANCELLED');
|
||||
if (current.center.x !== metadata.center.x || current.center.z !== metadata.center.z)
|
||||
throw new ForwardFailure('CANCELLED');
|
||||
};
|
||||
const known = new Map(input.world_generation === generation
|
||||
? input.known.map(item => [`${item.x},${item.z}`, item.revision]) : []);
|
||||
const chunks: WorldChunk[] = [], loaded: WorldSnapshot['loaded'] = [];
|
||||
const sampled: Array<{x: number; z: number; entry: ColumnCache}> = [];
|
||||
let partial = false;
|
||||
for (let z = metadata.center.z - input.radius; z <= metadata.center.z + input.radius; z++) {
|
||||
for (let x = metadata.center.x - input.radius; x <= metadata.center.x + input.radius; x++) {
|
||||
valid();
|
||||
const key = `${x},${z}`;
|
||||
const column = metadata.sync.getColumn(x, z);
|
||||
if (!column) { this.forget(key); partial = true; continue; }
|
||||
const entry = this.column_entry(key, column);
|
||||
if (known.get(key) !== entry.revision) {
|
||||
if (!entry.chunk) entry.chunk = await this.scan(x, z, entry, metadata, () => {
|
||||
valid();
|
||||
if (this.cache.get(key) !== entry || metadata.sync.getColumn(x, z) !== column)
|
||||
throw new ForwardFailure('CANCELLED');
|
||||
});
|
||||
valid();
|
||||
if (this.cache.get(key) !== entry || metadata.sync.getColumn(x, z) !== column) {
|
||||
throw new ForwardFailure('CANCELLED');
|
||||
}
|
||||
chunks.push(entry.chunk);
|
||||
}
|
||||
sampled.push({x, z, entry});
|
||||
loaded.push({x, z, revision: entry.revision});
|
||||
if (loaded.length % 2 === 0) { await yield_turn(); valid(); }
|
||||
}
|
||||
}
|
||||
valid();
|
||||
// A column may change during a later column's yield. Never label stale data as current.
|
||||
for (const {x, z, entry} of sampled) {
|
||||
if (this.cache.get(`${x},${z}`) === entry && metadata.sync.getColumn(x, z) === entry.column) continue;
|
||||
throw new ForwardFailure('CANCELLED');
|
||||
}
|
||||
return {world_generation: generation, watch_id, version: metadata.version, dimension: metadata.dimension,
|
||||
min_y: metadata.minY, height: metadata.height, center: metadata.center, position: metadata.position,
|
||||
chunks: structuredClone(chunks), loaded, partial};
|
||||
} catch (error) {
|
||||
if (!existing && this.watches.get(watch_id) === watch) this.unwatch(watch_id);
|
||||
throw error;
|
||||
} finally { this.pending--; }
|
||||
}
|
||||
|
||||
async chunk(input: WorldPayloads['world.chunk'], check: () => void): Promise<WorldVoxelChunk> {
|
||||
check();
|
||||
if (this.disposed) throw new ForwardFailure('CANCELLED');
|
||||
this.prune();
|
||||
const watch = this.watches.get(input.watch_id);
|
||||
if (!watch) throw new ForwardFailure('CANCELLED');
|
||||
const metadata = this.refresh(), generation = this.generation;
|
||||
if (input.world_generation !== generation || watch.world_generation !== generation)
|
||||
throw new ForwardFailure('CANCELLED');
|
||||
if (Math.abs(input.x - metadata.center.x) > watch.radius || Math.abs(input.z - metadata.center.z) > watch.radius)
|
||||
throw new ForwardFailure('FORBIDDEN');
|
||||
if (this.pending_voxels >= 2) throw new ForwardFailure('RATE_LIMITED');
|
||||
const key = `${input.x},${input.z}`, column = metadata.sync.getColumn(input.x, input.z);
|
||||
if (!column) { this.forget(key); throw new ForwardFailure('CANCELLED'); }
|
||||
const entry = this.column_entry(key, column);
|
||||
const valid = () => {
|
||||
check();
|
||||
if (this.disposed || this.watches.get(input.watch_id) !== watch || watch.expires_at <= this.now())
|
||||
throw new ForwardFailure('CANCELLED');
|
||||
const current = this.refresh();
|
||||
if (generation !== this.generation || this.cache.get(key) !== entry
|
||||
|| current.center.x !== metadata.center.x || current.center.z !== metadata.center.z
|
||||
|| current.sync.getColumn(input.x, input.z) !== column) throw new ForwardFailure('CANCELLED');
|
||||
};
|
||||
this.pending_voxels++;
|
||||
try {
|
||||
valid();
|
||||
const cached = this.voxels.get(key);
|
||||
if (cached?.entry === entry) {
|
||||
this.voxels.delete(key);
|
||||
this.voxels.set(key, cached);
|
||||
return structuredClone(cached.chunk);
|
||||
}
|
||||
const palette: WorldVoxelChunk['palette'] = [], sections: WorldVoxelChunk['sections'] = [];
|
||||
const indices = new Map<number, number>();
|
||||
const palette_index = (state: number) => {
|
||||
let index = indices.get(state);
|
||||
if (index !== undefined) return index;
|
||||
const block = this.bot.registry.blocksByStateId[state];
|
||||
if (!Number.isSafeInteger(state) || state < 0 || !block || typeof block.name !== 'string'
|
||||
|| !block.name.length || block.name.length > 128
|
||||
|| block.maxStateId !== undefined && state > block.maxStateId || palette.length >= 8192)
|
||||
throw new ForwardFailure('INTERNAL_ERROR');
|
||||
const name = block.name.replace(/^minecraft:/, '');
|
||||
index = palette.length;
|
||||
indices.set(state, index);
|
||||
palette.push({name: block.name, color: block_color(name), properties: voxel_properties(state, block),
|
||||
occluding: voxel_occluding(name, block)});
|
||||
return index;
|
||||
};
|
||||
const position = {x: 0, y: 0, z: 0}, reader = column.getBlockStateId;
|
||||
const read = reader.length >= 3
|
||||
? () => (reader as unknown as (x: number, y: number, z: number) => number).call(column, position.x, position.y, position.z)
|
||||
: () => reader.call(column, position);
|
||||
let reads = 0;
|
||||
for (let section = 0; section < Math.ceil(metadata.height / 16); section++) {
|
||||
const blocks = Buffer.alloc(8192);
|
||||
let non_air = false;
|
||||
for (let y = 0; y < 16; y++) {
|
||||
const offset_y = section * 16 + y;
|
||||
if (offset_y >= metadata.height) {
|
||||
// Padding is outside the world and must not duplicate the topmost solid block.
|
||||
if (non_air) {
|
||||
const registry = this.bot.registry;
|
||||
const air_state = registry.blocksByName?.air?.defaultState ?? registry.blocksByName?.air?.minStateId
|
||||
?? Object.entries(registry.blocksByStateId).find(([, block]) => block.name.replace(/^minecraft:/, '') === 'air')?.[0];
|
||||
if (air_state === undefined) throw new ForwardFailure('INTERNAL_ERROR');
|
||||
const index = palette_index(Number(air_state));
|
||||
for (let cell = y * 256; cell < 4096; cell++) blocks.writeUInt16LE(index, cell * 2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
position.y = metadata.minY + offset_y;
|
||||
for (let z = 0; z < 16; z++) for (let x = 0; x < 16; x++) {
|
||||
position.x = x; position.z = z;
|
||||
const index = palette_index(read());
|
||||
blocks.writeUInt16LE(index, (y * 256 + z * 16 + x) * 2);
|
||||
if (!air.has(palette[index]!.name.replace(/^minecraft:/, ''))) non_air = true;
|
||||
if (++reads % 2048 === 0) { await yield_turn(); valid(); }
|
||||
}
|
||||
}
|
||||
if (non_air) sections.push({y: section, blocks: blocks.toString('base64')});
|
||||
}
|
||||
valid();
|
||||
const chunk: WorldVoxelChunk = {x: input.x, z: input.z, revision: entry.revision, world_generation: generation,
|
||||
min_y: metadata.minY, height: metadata.height, palette, sections};
|
||||
this.voxels.delete(key);
|
||||
this.voxels.set(key, {entry, chunk, bytes: Buffer.byteLength(JSON.stringify(chunk))});
|
||||
let bytes = [...this.voxels.values()].reduce((sum, value) => sum + value.bytes, 0);
|
||||
while (this.voxels.size > 8 || bytes > 32 * 1024 * 1024) {
|
||||
const oldest = this.voxels.keys().next().value!;
|
||||
bytes -= this.voxels.get(oldest)!.bytes;
|
||||
this.voxels.delete(oldest);
|
||||
}
|
||||
return structuredClone(chunk);
|
||||
} finally { this.pending_voxels--; }
|
||||
}
|
||||
|
||||
private async scan(x: number, z: number, entry: ColumnCache,
|
||||
metadata: ReturnType<WorldPreview['metadata']>, check: () => void): Promise<WorldChunk> {
|
||||
const heights = Buffer.alloc(512), blocks = Buffer.alloc(512);
|
||||
const terrain_heights = Buffer.alloc(512), terrain_blocks = Buffer.alloc(512);
|
||||
const fluid_heights = Buffer.alloc(512), fluid_blocks = Buffer.alloc(512), fluid_levels = Buffer.alloc(512);
|
||||
const spike_heights = Buffer.alloc(512), spike_blocks = Buffer.alloc(512);
|
||||
const segments: SpikeSegment[] = [];
|
||||
const feature_heights = Buffer.alloc(512), feature_blocks = Buffer.alloc(512);
|
||||
const features: NonNullable<WorldChunk['features']>['entries'] = [];
|
||||
const palette: WorldChunk['palette'] = [];
|
||||
const indices = new Map<string, number>();
|
||||
const palette_index = (name: string) => {
|
||||
let index = indices.get(name);
|
||||
if (index === undefined) {
|
||||
index = palette.length;
|
||||
if (index >= 512) throw new ForwardFailure('INTERNAL_ERROR');
|
||||
indices.set(name, index);
|
||||
palette.push({name, color: block_color(name.replace(/^minecraft:/, ''))});
|
||||
}
|
||||
return index;
|
||||
};
|
||||
const position = {x: 0, y: 0, z: 0};
|
||||
const reader = entry.column.getBlockStateId;
|
||||
const read = reader.length >= 3
|
||||
? () => (reader as unknown as (x: number, y: number, z: number) => number).call(entry.column, position.x, position.y, position.z)
|
||||
: () => reader.call(entry.column, position);
|
||||
let reads = 0, has_fluid = false;
|
||||
for (let local_z = 0; local_z < 16; local_z++) {
|
||||
for (let local_x = 0; local_x < 16; local_x++) {
|
||||
let name = 'air', height = 0;
|
||||
let terrain_name = 'air', terrain_height = 0, fluid_name = '', level = 0;
|
||||
let terrain_state = -1;
|
||||
let spike_base_name = 'air', spike_base_height = 0;
|
||||
const column_segments: SpikeSegment[] = [];
|
||||
const index = local_z * 16 + local_x;
|
||||
position.x = local_x; position.z = local_z;
|
||||
for (let y = metadata.minY + metadata.height - 1; y >= metadata.minY; y--) {
|
||||
position.y = y;
|
||||
const state = read();
|
||||
const block = this.bot.registry.blocksByStateId[state];
|
||||
// Missing registry data is unknown, not evidence that this cell is air.
|
||||
if (!Number.isSafeInteger(state) || !block || typeof block.name !== 'string'
|
||||
|| !block.name.length || block.name.length > 128) throw new ForwardFailure('INTERNAL_ERROR');
|
||||
if (++reads % 4096 === 0) { await yield_turn(); check(); }
|
||||
const technical_name = block.name.replace(/^minecraft:/, '');
|
||||
const fluid = technical_name === 'lava' ? 'lava' : water_blocks.has(technical_name) ? 'water' : '';
|
||||
if (column_segments.length) {
|
||||
if (technical_name !== 'pointed_dripstone') {
|
||||
if (!air.has(technical_name) && !fluid) {
|
||||
spike_base_name = block.name;
|
||||
spike_base_height = y - metadata.minY + 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Commit only complete runs, so a capped column keeps its original surface.
|
||||
if (column_segments.length >= 16 || segments.length + column_segments.length >= 512) {
|
||||
column_segments.length = 0;
|
||||
break;
|
||||
}
|
||||
column_segments.push({index, height: y - metadata.minY + 1, ...spike_properties(state, block)});
|
||||
continue;
|
||||
}
|
||||
if (!height) {
|
||||
if (air.has(technical_name)) continue;
|
||||
name = fluid || block.name;
|
||||
height = y - metadata.minY + 1;
|
||||
if (fluid) {
|
||||
fluid_name = fluid;
|
||||
has_fluid = true;
|
||||
level = technical_name === 'water' || technical_name === 'lava' ? fluid_level(state, block) : 0;
|
||||
continue;
|
||||
}
|
||||
} else if (fluid) {
|
||||
if (fluid !== fluid_name) break;
|
||||
continue;
|
||||
}
|
||||
// Air ends a contiguous fluid column without inventing a floor beneath the gap.
|
||||
if (!air.has(technical_name)) {
|
||||
terrain_name = block.name;
|
||||
terrain_height = y - metadata.minY + 1;
|
||||
terrain_state = state;
|
||||
}
|
||||
if (technical_name === 'pointed_dripstone' && segments.length < 512) {
|
||||
column_segments.push({index, height: y - metadata.minY + 1, ...spike_properties(state, block)});
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const offset = index * 2;
|
||||
heights.writeUInt16LE(height, offset);
|
||||
blocks.writeUInt16LE(palette_index(name), offset);
|
||||
terrain_heights.writeUInt16LE(terrain_height, offset);
|
||||
terrain_blocks.writeUInt16LE(palette_index(terrain_name), offset);
|
||||
spike_heights.writeUInt16LE(column_segments.length ? spike_base_height : terrain_height, offset);
|
||||
spike_blocks.writeUInt16LE(palette_index(column_segments.length ? spike_base_name : terrain_name), offset);
|
||||
segments.push(...column_segments);
|
||||
// Retain small surface structures separately from the solid heightfield.
|
||||
let base_height = column_segments.length ? spike_base_height : terrain_height;
|
||||
let base_name = column_segments.length ? spike_base_name : terrain_name;
|
||||
const terrain_block = this.bot.registry.blocksByStateId[terrain_state];
|
||||
if (!column_segments.length && terrain_height && terrain_block && surface_feature(terrain_name.replace(/^minecraft:/, ''), surface_properties(terrain_state, terrain_block))) {
|
||||
const entries: typeof features = [];
|
||||
let remaining = 24, complete = false;
|
||||
for (let y = metadata.minY + terrain_height - 1; y >= metadata.minY && remaining-- > 0; y--) {
|
||||
position.y = y;
|
||||
const state = read(), block = this.bot.registry.blocksByStateId[state];
|
||||
if (!Number.isSafeInteger(state) || !block || typeof block.name !== 'string' || !block.name.length || block.name.length > 128)
|
||||
throw new ForwardFailure('INTERNAL_ERROR');
|
||||
if (++reads % 4096 === 0) { await yield_turn(); check(); }
|
||||
const technical = block.name.replace(/^minecraft:/, ''), properties = surface_properties(state, block);
|
||||
if (surface_feature(technical, properties)) {
|
||||
if (entries.length >= 16 || features.length + entries.length >= 512) break;
|
||||
entries.push({index, height: y - metadata.minY + 1, block: palette_index(block.name), properties});
|
||||
} else {
|
||||
if (!entries.length) break;
|
||||
base_height = air.has(technical) || water_blocks.has(technical) || technical === 'lava' ? 0 : y - metadata.minY + 1;
|
||||
base_name = base_height ? block.name : 'air';
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
if (y === metadata.minY) { base_height = 0; base_name = 'air'; complete = true; }
|
||||
}
|
||||
if (complete) features.push(...entries);
|
||||
else { base_height = terrain_height; base_name = terrain_name; }
|
||||
}
|
||||
feature_heights.writeUInt16LE(base_height, offset);
|
||||
feature_blocks.writeUInt16LE(palette_index(base_name), offset);
|
||||
if (fluid_name) {
|
||||
fluid_heights.writeUInt16LE(height, offset);
|
||||
fluid_blocks.writeUInt16LE(palette_index(fluid_name), offset);
|
||||
fluid_levels.writeUInt16LE(level, offset);
|
||||
}
|
||||
}
|
||||
if (local_z % 2 === 1) { await yield_turn(); check(); }
|
||||
}
|
||||
return {x, z, revision: entry.revision, palette, heights: heights.toString('base64'), blocks: blocks.toString('base64'),
|
||||
...(has_fluid ? {
|
||||
terrain: {heights: terrain_heights.toString('base64'), blocks: terrain_blocks.toString('base64')},
|
||||
fluids: {heights: fluid_heights.toString('base64'), blocks: fluid_blocks.toString('base64'), levels: fluid_levels.toString('base64')},
|
||||
} : {}),
|
||||
...(segments.length ? {spikes: {heights: spike_heights.toString('base64'), blocks: spike_blocks.toString('base64'), segments}} : {}),
|
||||
...(features.length ? {features: {heights: feature_heights.toString('base64'), blocks: feature_blocks.toString('base64'), entries: features}} : {})};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.watches.clear();
|
||||
this.idle();
|
||||
}
|
||||
}
|
||||
@@ -165,9 +165,9 @@ export class BotContext implements bot_instance_context<any> {
|
||||
this.plugin_manager = new PluginManager(this);
|
||||
}
|
||||
|
||||
public log(log: log[]) {
|
||||
this.logger.log(log);
|
||||
this.emit('log', log);
|
||||
public log(log: log[]) {
|
||||
this.logger.log(log);
|
||||
this.notify('log', log);
|
||||
}
|
||||
|
||||
public create_instance(): Promise<bot_operation_code> {
|
||||
|
||||
Reference in New Issue
Block a user