Collect chunk voxel data from the bot world and stream snapshot, chunk, and unwatch messages through the forward protocol.
294 lines
17 KiB
JavaScript
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'));
|
|
}
|
|
});
|