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:
+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');
|
||||
|
||||
Reference in New Issue
Block a user