Files
node_server/tests/world_voxels.test.mjs
T
2508932142 6c69afb38a 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.
2026-10-23 10:44:37 +08:00

294 lines
17 KiB
JavaScript

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'));
}
});