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:
@@ -0,0 +1,293 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {createRequire, isBuiltin} from 'node:module';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import test from 'node:test';
|
||||
import {build} from 'esbuild';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const installed = createRequire(require.resolve('@wp2508/mineflayer'));
|
||||
const registry = installed('@wp2508/minecraft-data')('1.21.4');
|
||||
const compiled = await build({
|
||||
stdin: {contents: `export * from './bot/forward/stream.ts'; export * from './utils/forward_protocol.ts';`,
|
||||
resolveDir: fileURLToPath(new URL('../', import.meta.url)), loader: 'ts'},
|
||||
bundle: true, platform: 'node', format: 'cjs', write: false,
|
||||
plugins: [{name: 'isolated-world', setup(build) {
|
||||
build.onResolve({filter: /(?:instance\/index\.ts|console_print\.ts|path_utils\.ts)$/}, args =>
|
||||
({path: args.path.includes('instance/') ? 'registry' : args.path.endsWith('path_utils.ts') ? 'path' : 'console', namespace: 'stub'}));
|
||||
build.onLoad({filter: /.*/, namespace: 'stub'}, ({path}) => ({loader: 'js', contents: {
|
||||
registry: 'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};',
|
||||
console: 'export const console_print = {success: x => x, log() {}};',
|
||||
path: 'export const get_project_root_path = () => {throw Error("Unexpected filesystem access")};',
|
||||
}[path]}));
|
||||
}}],
|
||||
});
|
||||
const module = {exports: {}};
|
||||
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => {
|
||||
assert.ok(isBuiltin(specifier));
|
||||
return require(specifier);
|
||||
}, module, module.exports);
|
||||
const {BotStream, validate_rpc} = module.exports;
|
||||
const code = expected => error => error.code === expected;
|
||||
const state = (name, properties = {}) => {
|
||||
const block = registry.blocksByName[name];
|
||||
return block.minStateId + block.states.reduce((offset, property) => {
|
||||
const value = properties[property.name];
|
||||
const index = property.type === 'bool' ? value === true ? 0 : 1 : property.values.indexOf(String(value));
|
||||
assert.ok(index >= 0, `${name}.${property.name}`);
|
||||
return offset * property.num_values + index;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
function fixture(t, {minY = -4, height = 32} = {}) {
|
||||
let now = 100, reads = 0;
|
||||
const columns = new Map();
|
||||
const bot = Object.assign(new EventEmitter(), {
|
||||
chat() {}, version: '1.21.4', game: {minY, height, dimension: 'overworld'}, registry,
|
||||
world: {getColumn(x, z) {return columns.get(`${x},${z}`);}},
|
||||
entity: {position: {x: -0.25, y: 4, z: -16.25}, yaw: 0},
|
||||
});
|
||||
const context = Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: bot});
|
||||
let current = context;
|
||||
const stream = new BotStream(1, {source: {get: () => current, observe: () => () => {}}, now: () => now});
|
||||
t.after(() => stream.dispose());
|
||||
const column = (get = () => registry.blocksByName.stone.defaultState) => ({getBlockStateId(p) {
|
||||
assert.ok(p.x >= 0 && p.x < 16 && p.z >= 0 && p.z < 16);
|
||||
assert.ok(p.y >= bot.game.minY && p.y < bot.game.minY + bot.game.height);
|
||||
reads++;
|
||||
return get(p.x, p.y, p.z);
|
||||
}});
|
||||
columns.set('-1,-2', column());
|
||||
return {bot, context, stream, columns, column,
|
||||
get reads() {return reads;},
|
||||
time(value) {now = value;}, set_context(value) {current = value;},
|
||||
snapshot(input = {}) {return stream.world_snapshot({radius: 1, known: [], ...input}, now + 2000);},
|
||||
chunk(snapshot, input = {}, check = () => {}) {
|
||||
return stream.world_chunk({x: -1, z: -2, world_generation: snapshot.world_generation,
|
||||
watch_id: snapshot.watch_id, ...input}, now + 2000, check);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decode(chunk) {
|
||||
assert.deepEqual(Object.keys(chunk).sort(), ['x', 'z', 'revision', 'world_generation', 'min_y', 'height', 'palette', 'sections'].sort());
|
||||
assert.ok(chunk.palette.length >= 1 && chunk.palette.length <= 8192);
|
||||
let previous = -1;
|
||||
const sections = new Map();
|
||||
for (const section of chunk.sections) {
|
||||
assert.ok(section.y > previous && section.y < Math.ceil(chunk.height / 16));
|
||||
previous = section.y;
|
||||
const bytes = Buffer.from(section.blocks, 'base64');
|
||||
assert.equal(bytes.length, 8192);
|
||||
assert.equal(bytes.toString('base64'), section.blocks);
|
||||
for (let i = 0; i < 4096; i++) assert.ok(bytes.readUInt16LE(i * 2) < chunk.palette.length);
|
||||
sections.set(section.y, bytes);
|
||||
}
|
||||
return (x, y, z) => {
|
||||
const offset = y - chunk.min_y, bytes = sections.get(Math.floor(offset / 16));
|
||||
return bytes ? chunk.palette[bytes.readUInt16LE(((offset % 16) * 256 + z * 16 + x) * 2)] : {name: 'air'};
|
||||
};
|
||||
}
|
||||
|
||||
test('world.chunk validates exact integer coordinates and UUIDs without invoking accessors', () => {
|
||||
const payload = {x: -1, z: 2, world_generation: randomUUID(), watch_id: randomUUID()};
|
||||
const request = value => ({method: 'world.chunk', payload: value, request_id: randomUUID(), correlation_id: randomUUID(),
|
||||
forward_session_id: randomUUID(), deadline_at: 1000});
|
||||
assert.equal(validate_rpc(request(payload)).payload, payload);
|
||||
for (const invalid of [{}, {...payload, x: 1.5}, {...payload, z: Infinity}, {...payload, x: '1'},
|
||||
{...payload, watch_id: undefined}, {...payload, world_generation: 'bad'}, {...payload, radius: 3}])
|
||||
assert.throws(() => validate_rpc(request(invalid)), code('INVALID_REQUEST'));
|
||||
for (const key of Object.keys(payload)) {
|
||||
let reads = 0;
|
||||
const invalid = Object.defineProperty({...payload}, key, {enumerable: true, get() {reads++; return payload[key];}});
|
||||
assert.throws(() => validate_rpc(request(invalid)), code('INVALID_REQUEST'));
|
||||
assert.equal(reads, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('real installed chunks retain cave floors, interiors, roofs and light below the surface', async t => {
|
||||
const app = fixture(t, {minY: -64, height: 384});
|
||||
const Chunk = installed('prismarine-chunk')('1.21.4');
|
||||
const World = installed('prismarine-world')('1.21.4');
|
||||
const column = new Chunk({minY: -64, worldHeight: 384});
|
||||
const world = new World(null, null, {savingInterval: 0});
|
||||
app.bot.world = world.sync;
|
||||
app.bot.world.setColumn(-1, -2, column, false);
|
||||
for (const [x, y, z, name] of [[0, -64, 0, 'bedrock'], [0, -40, 0, 'diamond_ore'], [0, 80, 0, 'stone'],
|
||||
[15, 79, 15, 'glass'], [1, -39, 2, 'cave_air'], [1, -38, 2, 'void_air']])
|
||||
column.setBlockStateId({x, y, z}, registry.blocksByName[name].defaultState);
|
||||
column.setBlockStateId({x: 2, y: -20, z: 3}, state('light', {level: 9, waterlogged: false}));
|
||||
const snapshot = await app.snapshot(), chunk = await app.chunk(snapshot), cell = decode(chunk);
|
||||
assert.equal(chunk.revision, snapshot.loaded[0].revision);
|
||||
assert.deepEqual(chunk.sections.map(s => s.y), [0, 1, 2, 8, 9]);
|
||||
assert.equal(cell(0, -64, 0).name, 'bedrock');
|
||||
assert.equal(cell(0, -40, 0).name, 'diamond_ore');
|
||||
assert.equal(cell(0, -39, 0).name, 'air');
|
||||
assert.equal(cell(0, 80, 0).name, 'stone');
|
||||
assert.equal(cell(15, 79, 15).name, 'glass');
|
||||
assert.equal(cell(1, -39, 2).name, 'cave_air');
|
||||
assert.equal(cell(1, -38, 2).name, 'void_air');
|
||||
assert.deepEqual(cell(2, -20, 3).properties, {waterlogged: false, level: 9});
|
||||
const unchanged = await app.snapshot({world_generation: snapshot.world_generation, known: snapshot.loaded, watch_id: snapshot.watch_id});
|
||||
assert.deepEqual(unchanged.chunks, []);
|
||||
});
|
||||
|
||||
test('palette preserves generic typed registry properties, deduplicates state IDs and excludes partial occluders', async t => {
|
||||
const app = fixture(t);
|
||||
const samples = [
|
||||
['wheat', {age: 7}], ['pink_petals', {facing: 'south', flower_amount: 4}],
|
||||
['snow', {layers: 6}], ['pointed_dripstone', {thickness: 'frustum', vertical_direction: 'down', waterlogged: true}],
|
||||
['lever', {face: 'ceiling', facing: 'east', powered: true}], ['cake', {bites: 5}],
|
||||
['beehive', {facing: 'west', honey_level: 4}], ['oak_slab', {type: 'bottom', waterlogged: false}],
|
||||
['oak_leaves', {distance: 3, persistent: true, waterlogged: false}], ['glass', {}], ['torch', {}],
|
||||
['water', {level: 8}], ['stone', {}], ['oak_slab', {type: 'top', waterlogged: false}],
|
||||
];
|
||||
const states = samples.map(([name, properties]) => state(name, properties));
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => y === -3 && z === 3 && x < states.length ? states[x]
|
||||
: y === 27 && z === 3 && x === 0 ? states[0] : registry.blocksByName.air.defaultState));
|
||||
const chunk = await app.chunk(await app.snapshot()), cell = decode(chunk);
|
||||
for (let x = 0; x < samples.length; x++) {
|
||||
const [name, properties] = samples[x], result = cell(x, -3, 3);
|
||||
assert.equal(result.name, name);
|
||||
assert.deepEqual(result.properties, properties);
|
||||
assert.equal(typeof result.color, 'number');
|
||||
assert.equal(result.occluding, name === 'stone' || name === 'beehive');
|
||||
}
|
||||
assert.equal(chunk.palette.filter(p => p.name === 'wheat').length, 1);
|
||||
assert.equal(chunk.palette.filter(p => p.name === 'oak_slab').length, 2);
|
||||
assert.equal(cell(0, 27, 3).properties.age, 7);
|
||||
});
|
||||
|
||||
test('sections use offsets from nonaligned minimum, pad with air and omit only air variants', async t => {
|
||||
const app = fixture(t, {minY: -3, height: 33});
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => y === 29 && x === 15 && z === 14 ? state('light', {level: 0, waterlogged: true})
|
||||
: [registry.blocksByName.air.defaultState, registry.blocksByName.cave_air.defaultState, registry.blocksByName.void_air.defaultState][(x + z) % 3]));
|
||||
const chunk = await app.chunk(await app.snapshot());
|
||||
assert.deepEqual(chunk.sections.map(s => s.y), [2]);
|
||||
assert.equal(decode(chunk)(15, 29, 14).name, 'light');
|
||||
const last = Buffer.from(chunk.sections[0].blocks, 'base64');
|
||||
for (let i = 256; i < 4096; i++) assert.equal(chunk.palette[last.readUInt16LE(i * 2)].name, 'air');
|
||||
app.bot.emit('blockUpdate', null, {position: {x: -1, z: -17}});
|
||||
app.columns.set('-1,-2', app.column(() => registry.blocksByName.cave_air.defaultState));
|
||||
const empty = await app.chunk(await app.snapshot());
|
||||
assert.equal(empty.palette.length, 1);
|
||||
assert.deepEqual(empty.sections, []);
|
||||
});
|
||||
|
||||
test('chunk requires a live watch in its generation and radius, and never renews its lease', async t => {
|
||||
const app = fixture(t), snapshot = await app.snapshot();
|
||||
await assert.rejects(app.chunk(snapshot, {watch_id: randomUUID()}), code('CANCELLED'));
|
||||
await assert.rejects(app.chunk(snapshot, {world_generation: randomUUID()}), code('CANCELLED'));
|
||||
const before = app.reads;
|
||||
await assert.rejects(app.chunk(snapshot, {x: 1}), code('FORBIDDEN'));
|
||||
assert.equal(app.reads, before);
|
||||
app.time(44_000);
|
||||
await app.chunk(snapshot);
|
||||
app.time(45_101);
|
||||
await assert.rejects(app.chunk(snapshot), code('CANCELLED'));
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
const fresh = await app.snapshot();
|
||||
app.bot.game.dimension = 'the_nether';
|
||||
await assert.rejects(app.chunk(fresh), code('CANCELLED'));
|
||||
});
|
||||
|
||||
test('voxel reads cancel after at most 2048 reads on deadline, transport and changing loaded entry', async t => {
|
||||
for (const kind of ['deadline', 'transport', 'unload', 'replace', 'block', 'respawn', 'move', 'context', 'offline', 'unwatch']) {
|
||||
await t.test(kind, async t => {
|
||||
const app = fixture(t, {height: 64}), snapshot = await app.snapshot();
|
||||
const before = app.reads;
|
||||
let disconnected = false;
|
||||
setImmediate(() => {
|
||||
if (kind === 'deadline') app.time(5000);
|
||||
if (kind === 'transport') disconnected = true;
|
||||
if (kind === 'unload') app.columns.delete('-1,-2');
|
||||
if (kind === 'replace') app.columns.set('-1,-2', app.column());
|
||||
if (kind === 'block') app.bot.emit('blockUpdate', null, {position: {x: -1, z: -17}});
|
||||
if (kind === 'respawn') app.bot.emit('respawn');
|
||||
if (kind === 'move') app.bot.entity.position.x = 32;
|
||||
if (kind === 'context') app.set_context(undefined);
|
||||
if (kind === 'offline') app.context.bot_status = 'offline';
|
||||
if (kind === 'unwatch') app.stream.world_unwatch({watch_id: snapshot.watch_id}, 2100);
|
||||
});
|
||||
await assert.rejects(app.chunk(snapshot, {}, () => {
|
||||
if (disconnected) throw Object.assign(new Error(), {code: 'SESSION_STALE'});
|
||||
}), code(kind === 'deadline' ? 'TIMEOUT' : kind === 'transport' ? 'SESSION_STALE' : kind === 'offline' ? 'BOT_NOT_ONLINE' : 'CANCELLED'));
|
||||
assert.equal(app.reads - before, 2048);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('two voxel requests may run independently of four surface requests', async t => {
|
||||
const app = fixture(t, {height: 64}), snapshot = await app.snapshot();
|
||||
const pending = [app.chunk(snapshot), app.chunk(snapshot)];
|
||||
await assert.rejects(app.chunk(snapshot), code('RATE_LIMITED'));
|
||||
assert.equal((await Promise.all(pending)).length, 2);
|
||||
await app.chunk(snapshot);
|
||||
});
|
||||
|
||||
test('cache shares surface revisions, returns detached data and invalidates on block events, unload and idle', async t => {
|
||||
const app = fixture(t), snapshot = await app.snapshot();
|
||||
const first = await app.chunk(snapshot), before = app.reads;
|
||||
first.palette[0].name = 'mutated'; first.sections.length = 0;
|
||||
const second = await app.chunk(snapshot);
|
||||
assert.equal(app.reads, before);
|
||||
assert.equal(second.palette[0].name, 'stone'); assert.equal(second.sections.length, 2);
|
||||
app.bot.emit('blockUpdate', null, {position: {x: -1, z: -17}});
|
||||
const third = await app.chunk(snapshot);
|
||||
assert.notEqual(third.revision, second.revision);
|
||||
const updated = await app.snapshot({world_generation: snapshot.world_generation, known: snapshot.loaded, watch_id: snapshot.watch_id});
|
||||
assert.equal(updated.loaded[0].revision, third.revision);
|
||||
app.columns.delete('-1,-2');
|
||||
await assert.rejects(app.chunk(updated), code('CANCELLED'));
|
||||
app.columns.set('-1,-2', app.column());
|
||||
const reloaded = await app.chunk(updated);
|
||||
assert.notEqual(reloaded.revision, third.revision);
|
||||
app.stream.world_unwatch({watch_id: snapshot.watch_id}, 2100);
|
||||
await assert.rejects(app.chunk(snapshot), code('CANCELLED'));
|
||||
});
|
||||
|
||||
test('voxel LRU retains at most eight columns', async t => {
|
||||
const app = fixture(t, {height: 1});
|
||||
for (let z = -3; z <= -1; z++) for (let x = -2; x <= 0; x++) app.columns.set(`${x},${z}`, app.column());
|
||||
const snapshot = await app.snapshot();
|
||||
const coordinates = snapshot.loaded;
|
||||
for (const {x, z} of coordinates) await app.chunk(snapshot, {x, z});
|
||||
const before_cached = app.reads;
|
||||
await app.chunk(snapshot, {x: coordinates[8].x, z: coordinates[8].z});
|
||||
assert.equal(app.reads, before_cached);
|
||||
await app.chunk(snapshot, {x: coordinates[0].x, z: coordinates[0].z});
|
||||
assert.equal(app.reads - before_cached, 256);
|
||||
});
|
||||
|
||||
test('unknown interior states and excessive palettes fail without caching partial data', async t => {
|
||||
const app = fixture(t, {minY: 0, height: 64});
|
||||
let unknown = true;
|
||||
app.columns.set('-1,-2', app.column((_x, y) => y === 0 && unknown ? 999_999 : registry.blocksByName.stone.defaultState));
|
||||
const snapshot = await app.snapshot();
|
||||
await assert.rejects(app.chunk(snapshot), code('INTERNAL_ERROR'));
|
||||
unknown = false;
|
||||
assert.equal((await app.chunk(snapshot)).sections.length, 4);
|
||||
app.bot.registry = {blocksByStateId: Object.fromEntries(Array.from({length: 8193}, (_, i) => [i, {name: `block_${i}`}]))};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => Math.min(8192, y * 256 + z * 16 + x)));
|
||||
await assert.rejects(app.chunk(snapshot), code('INTERNAL_ERROR'));
|
||||
app.bot.registry.blocksByStateId[8192] = app.bot.registry.blocksByStateId[0];
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => (y * 256 + z * 16 + x) % 8192));
|
||||
assert.equal((await app.chunk(snapshot)).palette.length, 8192);
|
||||
});
|
||||
|
||||
test('invalid generic registry properties are rejected and maximum height reads every cell', async t => {
|
||||
const app = fixture(t, {minY: -4096, height: 4096}), snapshot = await app.snapshot();
|
||||
const before = app.reads, chunk = await app.chunk(snapshot);
|
||||
assert.equal(app.reads - before, 4096 * 256);
|
||||
assert.equal(chunk.sections.length, 256);
|
||||
assert.equal(chunk.sections[255].y, 255);
|
||||
assert.equal(decode(chunk)(15, -1, 15).name, 'stone');
|
||||
for (const states of [Array.from({length: 33}, (_, i) => ({name: `p${i}`, type: 'bool', num_values: 2})),
|
||||
[{name: 'age', type: 'int', num_values: 1, values: ['NaN']}],
|
||||
[{name: 'shape', type: 'enum', num_values: 1, values: ['x'.repeat(65)]}]]) {
|
||||
app.bot.registry = {blocksByStateId: {0: {name: 'bad', minStateId: 0, states}}};
|
||||
app.columns.set('-1,-2', app.column(() => 0));
|
||||
await assert.rejects(app.chunk(snapshot), code('INTERNAL_ERROR'));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user