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,829 @@
|
||||
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 {setImmediate as tick, setTimeout as delay} from 'node:timers/promises';
|
||||
import test from 'node:test';
|
||||
import {build} from 'esbuild';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const compiled = await build({
|
||||
stdin: {contents: `export * from './bot/forward/stream.ts'; export * from './bot/forward/manager.ts';
|
||||
export * from './utils/forward_protocol.ts';`, resolveDir: root, loader: 'ts'},
|
||||
bundle: true, platform: 'node', format: 'cjs', write: false, metafile: true,
|
||||
plugins: [{name: 'isolated-node', setup(build) {
|
||||
build.onResolve({filter: /(?:instance\/index\.ts|^socket\.io-client$|console_print\.ts|path_utils\.ts)$/}, args =>
|
||||
({path: args.path.includes('instance/') ? 'registry' : args.path === 'socket.io-client' ? 'socket'
|
||||
: 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 = () => () => {};',
|
||||
socket: 'export const io = () => {throw Error("Unexpected real socket")};',
|
||||
console: 'export const console_print = {success: x => x, log() {}};',
|
||||
path: 'export const get_project_root_path = () => {throw Error("Unexpected filesystem access")};',
|
||||
}[path]}));
|
||||
}}],
|
||||
});
|
||||
assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server')));
|
||||
const require = createRequire(import.meta.url);
|
||||
const module = {exports: {}};
|
||||
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(specifier => {
|
||||
assert.ok(isBuiltin(specifier), `Unexpected external import: ${specifier}`);
|
||||
return require(specifier);
|
||||
}, module, module.exports);
|
||||
const {BotStream, NodeForwardManager, validate_rpc} = module.exports;
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const code = expected => error => error.code === expected;
|
||||
const request = (method, payload, forward_session_id = randomUUID(), deadline_at = Date.now() + 2000) => ({
|
||||
request_id: randomUUID(), correlation_id: randomUUID(), forward_session_id, deadline_at, method, payload,
|
||||
});
|
||||
|
||||
function fixture(t, {minY = -4, height = 16, direct = false} = {}) {
|
||||
let now = 100;
|
||||
let reads = 0;
|
||||
const columns = new Map();
|
||||
const bot = Object.assign(new EventEmitter(), {
|
||||
chat() {}, version: '1.21.4', game: {minY, height, dimension: 'overworld'},
|
||||
registry: {blocksByStateId: {7: {name: 'air'}, 8: {name: 'cave_air'}, 9: {name: 'void_air'},
|
||||
101: {name: 'grass_block'}, 202: {name: 'water'}, 303: {name: 'stone'}, 404: {name: 'oak_leaves'},
|
||||
505: {name: 'modded_marble'}}},
|
||||
entity: {position: {x: -0.25, y: 3.5, z: -16.25}, yaw: Math.PI / 3},
|
||||
});
|
||||
const sync = {getColumn(x, z) { return columns.get(`${x},${z}`); }};
|
||||
bot.world = direct ? sync : {sync};
|
||||
const context = Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: bot});
|
||||
let current = context;
|
||||
const observers = new Set();
|
||||
const source = {get: () => current, observe(_id, callback) { observers.add(callback); return () => observers.delete(callback); }};
|
||||
const stream = new BotStream(1, {source, now: () => now});
|
||||
t.after(() => stream.dispose());
|
||||
const column = (surface = () => 7) => ({getBlockStateId(position) {
|
||||
assert.equal(typeof position, 'object');
|
||||
assert.ok(position.x >= 0 && position.x < 16 && position.z >= 0 && position.z < 16);
|
||||
assert.ok(position.y >= minY && position.y < minY + height);
|
||||
reads++;
|
||||
return surface(position.x, position.y, position.z);
|
||||
}});
|
||||
return {bot, context, stream, columns, column, observers,
|
||||
get reads() { return reads; },
|
||||
snapshot(input = {}, check = () => {}) { return stream.world_snapshot({radius: 1, known: [], ...input}, now + 2000, check); },
|
||||
time(value) { now = value; },
|
||||
set(value) { current = value; for (const observer of observers) observer(value); },
|
||||
};
|
||||
}
|
||||
|
||||
function decoded(chunk, layer = chunk) {
|
||||
for (const name of ['heights', 'blocks', ...('levels' in layer ? ['levels'] : [])]) {
|
||||
assert.equal(layer[name].length, 684);
|
||||
assert.equal(Buffer.from(layer[name], 'base64').length, 512);
|
||||
assert.equal(Buffer.from(layer[name], 'base64').toString('base64'), layer[name]);
|
||||
}
|
||||
const heights = Buffer.from(layer.heights, 'base64'), blocks = Buffer.from(layer.blocks, 'base64');
|
||||
const levels = layer.levels && Buffer.from(layer.levels, 'base64');
|
||||
const result = [];
|
||||
for (let index = 0; index < 256; index++) {
|
||||
const palette_index = blocks.readUInt16LE(index * 2);
|
||||
assert.ok(palette_index < chunk.palette.length);
|
||||
const cell = {height: heights.readUInt16LE(index * 2), ...chunk.palette[palette_index]};
|
||||
if (levels) {
|
||||
cell.level = levels.readUInt16LE(index * 2);
|
||||
assert.ok(cell.level >= 0 && cell.level <= 15);
|
||||
if (cell.height) assert.ok(['water', 'lava'].includes(cell.name));
|
||||
else assert.equal(cell.level, 0);
|
||||
}
|
||||
result.push(cell);
|
||||
}
|
||||
assert.ok(chunk.palette.length >= 1 && chunk.palette.length <= 512);
|
||||
for (const entry of chunk.palette) {
|
||||
assert.deepEqual(Object.keys(entry).sort(), ['color', 'name']);
|
||||
assert.ok(entry.name.length <= 128);
|
||||
assert.ok(Number.isInteger(entry.color) && entry.color >= 0 && entry.color <= 0xffffff);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
test('surface feature sampling retains torch, sign, hanging and waterlogged states above their foundation', async t => {
|
||||
const h = fixture(t), mf = createRequire(require.resolve('@wp2508/mineflayer'));
|
||||
const data = mf('minecraft-data')('1.21.4');
|
||||
const id = (name, properties) => {
|
||||
const b = data.blocksByName[name]; let offset = 0;
|
||||
for (const p of b.states) offset = offset * p.num_values + (p.type === 'bool' ? properties[p.name] === true ? 0 : 1 : p.values.indexOf(String(properties[p.name])));
|
||||
const value = b.minStateId + offset; h.bot.registry.blocksByStateId[value] = b; return value;
|
||||
};
|
||||
const states = [id('wall_torch', {facing: 'east'}), id('oak_sign', {rotation: 7, waterlogged: false}),
|
||||
id('oak_hanging_sign', {rotation: 3, attached: true, waterlogged: true}),
|
||||
id('oak_wall_hanging_sign', {facing: 'north', waterlogged: false}), id('oak_slab', {type: 'bottom', waterlogged: true}),
|
||||
id('oak_stairs', {facing: 'south', half: 'top', shape: 'inner_left', waterlogged: true})];
|
||||
h.columns.set('-1,-2', h.column((x,y,z) => z === 0 && x < states.length ? y === 3 ? states[x] : y === 2 ? 303 : 7 : y === 2 ? 303 : 7));
|
||||
const result = await h.snapshot(), chunk = result.chunks[0];
|
||||
assert.equal(chunk.features.entries.length, states.length);
|
||||
const entries = chunk.features.entries;
|
||||
assert.deepEqual(entries[0].properties, {facing: 'east'});
|
||||
assert.equal(entries[1].properties.rotation, 7); assert.equal(entries[1].properties.waterlogged, false);
|
||||
assert.equal(entries[2].properties.attached, true); assert.equal(entries[2].properties.waterlogged, true);
|
||||
assert.equal(entries[4].properties.type, 'bottom'); assert.equal(entries[5].properties.shape, 'inner_left');
|
||||
assert.ok(entries.every(e => e.height === 8));
|
||||
assert.ok(decoded(chunk, chunk.features).slice(0, states.length).every(c => c.height === 7 && c.name === 'stone'));
|
||||
});
|
||||
|
||||
test('feature sampling retains short runs and falls back without holes when a column exceeds its bound', async t => {
|
||||
const h = fixture(t, {minY: 0, height: 24});
|
||||
h.bot.registry.blocksByStateId[700] = {name: 'oak_sign', minStateId: 700, states: [{name: 'rotation', num_values: 16, values: Array.from({length: 16}, (_, i) => String(i))}]};
|
||||
h.columns.set('-1,-2', h.column((x,y,z) => {
|
||||
if (z !== 0 || x > 1) return y === 1 ? 303 : 7;
|
||||
if (x === 0) return y === 3 || y === 2 ? 700 : y === 1 ? 303 : 7;
|
||||
return y >= 2 && y <= 18 ? 700 : y === 1 ? 303 : 7;
|
||||
}));
|
||||
const chunk = (await h.snapshot()).chunks[0];
|
||||
assert.equal(chunk.features.entries.length, 2);
|
||||
assert.ok(chunk.features.entries.every(e => e.index === 0));
|
||||
const bases = decoded(chunk, chunk.features);
|
||||
assert.equal(bases[0].height, 2);
|
||||
assert.equal(bases[1].height, 19);
|
||||
assert.equal(bases[1].name, 'oak_sign');
|
||||
});
|
||||
|
||||
test('world protocol validates exact bounded requests, revisions and descriptors', () => {
|
||||
const valid = {radius: 3, known: [{x: -1, z: -2, revision: randomUUID()}], world_generation: randomUUID(), watch_id: randomUUID()};
|
||||
assert.equal(validate_rpc(request('world.snapshot', valid)).payload, valid);
|
||||
assert.equal(validate_rpc(request('world.unwatch', {watch_id: valid.watch_id})).method, 'world.unwatch');
|
||||
for (const payload of [null, {}, [], {radius: 1}, {known: []}, {...valid, extra: true},
|
||||
...[0, 4, -1, 1.5, NaN, Infinity, '1', null].map(radius => ({...valid, radius})),
|
||||
{...valid, known: Array.from({length: 50}, (_, x) => ({x, z: 0, revision: randomUUID()}))},
|
||||
{...valid, known: Array(1)}, {...valid, known: [{x: 0, z: 0}]},
|
||||
{...valid, known: [{x: 1.2, z: 0, revision: randomUUID()}]},
|
||||
{...valid, known: [{x: 0, z: Infinity, revision: randomUUID()}]},
|
||||
{...valid, known: [{x: 0, z: 0, revision: 'bad'}]},
|
||||
{...valid, known: [{x: 0, z: 0, revision: randomUUID(), extra: true}]},
|
||||
{...valid, known: [valid.known[0], valid.known[0]]},
|
||||
{...valid, world_generation: undefined}, {...valid, watch_id: 'bad'}])
|
||||
assert.throws(() => validate_rpc(request('world.snapshot', payload)), code('INVALID_REQUEST'));
|
||||
for (const payload of [{}, {watch_id: 'bad'}, {watch_id: valid.watch_id, extra: 1}, {watch_id: null}])
|
||||
assert.throws(() => validate_rpc(request('world.unwatch', payload)), code('INVALID_REQUEST'));
|
||||
for (const name of ['radius', 'known', 'watch_id', 'world_generation']) {
|
||||
let reads = 0;
|
||||
const payload = Object.defineProperty({...valid}, name, {enumerable: true, get() { reads++; return valid[name]; }});
|
||||
assert.throws(() => validate_rpc(request('world.snapshot', payload)), code('INVALID_REQUEST'));
|
||||
assert.equal(reads, 0);
|
||||
}
|
||||
let reads = 0;
|
||||
const known = [];
|
||||
Object.defineProperty(known, '0', {enumerable: true, get() { reads++; return valid.known[0]; }});
|
||||
assert.throws(() => validate_rpc(request('world.snapshot', {...valid, known})), code('INVALID_REQUEST'));
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
|
||||
test('snapshot encodes negative chunk coordinates, z-major heights and runtime registry surface names', async t => {
|
||||
const app = fixture(t);
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => {
|
||||
if (x === 1 && z === 0) return y === -1 ? 202 : 7;
|
||||
if (x === 2 && z === 0) return y === 10 ? 404 : 7;
|
||||
if (x === 3 && z === 0) return y === -4 ? 303 : 7;
|
||||
if (x === 4 && z === 0) return y === 2 ? 505 : 7;
|
||||
if (x === 0 && z === 1) return y > 4 ? (y % 2 ? 8 : 9) : y === 4 ? 101 : 7;
|
||||
return 7;
|
||||
}));
|
||||
const result = await app.snapshot();
|
||||
assert.deepEqual(Object.keys(result).sort(), ['world_generation', 'context_generation', 'stream_id', 'watch_id', 'version',
|
||||
'dimension', 'min_y', 'height', 'center', 'position', 'chunks', 'loaded', 'partial'].sort());
|
||||
for (const key of ['world_generation', 'context_generation', 'stream_id', 'watch_id']) assert.match(result[key], uuid);
|
||||
assert.deepEqual(result.center, {x: -1, z: -2});
|
||||
assert.deepEqual(result.position, {x: -0.25, y: 3.5, z: -16.25, yaw: Math.PI / 3});
|
||||
assert.equal(result.min_y, -4); assert.equal(result.height, 16);
|
||||
assert.equal(result.version, app.bot.version); assert.equal(result.dimension, 'overworld');
|
||||
assert.equal(result.partial, true); assert.equal(result.chunks.length, 1); assert.equal(result.loaded.length, 1);
|
||||
const chunk = result.chunks[0];
|
||||
assert.deepEqual(result.loaded, [{x: -1, z: -2, revision: chunk.revision}]);
|
||||
const cells = decoded(chunk);
|
||||
assert.deepEqual(cells[0], {height: 0, name: 'air', color: 0});
|
||||
assert.deepEqual(cells[1], {height: 4, name: 'water', color: 0x3f76e4});
|
||||
assert.equal(cells[2].height, 15); assert.equal(cells[2].name, 'oak_leaves');
|
||||
assert.equal(cells[3].height, 1); assert.equal(cells[3].name, 'stone');
|
||||
assert.equal(cells[4].height, 7); assert.equal(cells[4].name, 'modded_marble');
|
||||
assert.equal(cells[16].height, 9); assert.equal(cells[16].name, 'grass_block');
|
||||
assert.notEqual(cells[1].color, cells[16].color);
|
||||
});
|
||||
|
||||
test('only loaded columns are returned, zero-chunk metadata remains valid and radius 3 is bounded at 49', async t => {
|
||||
const app = fixture(t, {direct: true});
|
||||
const empty = await app.snapshot();
|
||||
assert.deepEqual(empty.chunks, []); assert.deepEqual(empty.loaded, []); assert.equal(empty.partial, true);
|
||||
for (let z = -5; z <= 1; z++) for (let x = -4; x <= 2; x++) app.columns.set(`${x},${z}`, app.column(() => 303));
|
||||
const full = await app.snapshot({radius: 3, watch_id: empty.watch_id});
|
||||
assert.equal(full.chunks.length, 49); assert.equal(full.loaded.length, 49); assert.equal(full.partial, false);
|
||||
const cached = await app.snapshot({radius: 3, watch_id: full.watch_id, world_generation: full.world_generation, known: full.loaded});
|
||||
assert.equal(cached.chunks.length, 0); assert.equal(cached.loaded.length, 49); assert.equal(cached.partial, false);
|
||||
});
|
||||
|
||||
test('matching generation omits unchanged chunks without reads, block events change revisions before hint', async t => {
|
||||
const app = fixture(t);
|
||||
let state = 101;
|
||||
app.columns.set('-1,-2', app.column((_x, y) => y === 0 ? state : 7));
|
||||
app.columns.set('0,-2', app.column(() => 303));
|
||||
const first = await app.snapshot();
|
||||
const reads = app.reads;
|
||||
const input = {known: first.loaded, world_generation: first.world_generation, watch_id: first.watch_id};
|
||||
const same = await app.snapshot(input);
|
||||
assert.equal(same.chunks.length, 0); assert.equal(same.watch_id, first.watch_id); assert.equal(app.reads, reads);
|
||||
state = 202;
|
||||
app.bot.emit('blockUpdate', null, {position: {x: -1, y: 0, z: -17}});
|
||||
const changed = await app.snapshot(input);
|
||||
assert.equal(changed.world_generation, first.world_generation);
|
||||
assert.equal(changed.chunks.length, 1);
|
||||
assert.equal(decoded(changed.chunks[0])[0].name, 'water');
|
||||
assert.notEqual(changed.chunks[0].revision, first.chunks[0].revision);
|
||||
const forced = await app.snapshot({...input, world_generation: randomUUID()});
|
||||
assert.equal(forced.chunks.length, 2);
|
||||
first.chunks[0].palette[0].name = 'mutated';
|
||||
const clone = await app.snapshot({watch_id: first.watch_id});
|
||||
assert.notEqual(clone.chunks[0].palette[0].name, 'mutated');
|
||||
});
|
||||
|
||||
test('column replacement and unload cannot reuse stale cached surfaces', async t => {
|
||||
const app = fixture(t);
|
||||
app.columns.set('-1,-2', app.column(() => 101));
|
||||
const first = await app.snapshot();
|
||||
const input = {known: first.loaded, world_generation: first.world_generation, watch_id: first.watch_id};
|
||||
app.columns.set('-1,-2', app.column(() => 303));
|
||||
const replacement = await app.snapshot(input);
|
||||
assert.notEqual(replacement.chunks[0].revision, first.loaded[0].revision);
|
||||
assert.equal(decoded(replacement.chunks[0])[0].name, 'stone');
|
||||
app.columns.delete('-1,-2');
|
||||
app.bot.emit('chunkColumnUnload', {x: -16, z: -32});
|
||||
const unloaded = await app.snapshot(input);
|
||||
assert.deepEqual(unloaded.loaded, []); assert.deepEqual(unloaded.chunks, []);
|
||||
app.columns.set('-1,-2', app.column(() => 202));
|
||||
app.bot.emit('chunkColumnLoad', {x: -16, z: -32});
|
||||
const reloaded = await app.snapshot(input);
|
||||
assert.notEqual(reloaded.loaded[0].revision, first.loaded[0].revision);
|
||||
});
|
||||
|
||||
test('world object, dimension, version and explicit same-world respawn reset the generation', async t => {
|
||||
const app = fixture(t);
|
||||
app.columns.set('-1,-2', app.column(() => 303));
|
||||
let last = await app.snapshot();
|
||||
for (const reset of [() => { app.bot.world = {sync: app.bot.world.sync}; },
|
||||
() => { app.bot.game.dimension = 'the_nether'; }, () => { app.bot.version = '1.20.4'; },
|
||||
() => { app.bot.emit('respawn'); }]) {
|
||||
reset();
|
||||
const next = await app.snapshot({watch_id: last.watch_id, world_generation: last.world_generation, known: last.loaded});
|
||||
assert.notEqual(next.world_generation, last.world_generation);
|
||||
assert.equal(next.chunks.length, 1);
|
||||
assert.equal(next.watch_id, last.watch_id);
|
||||
last = next;
|
||||
}
|
||||
});
|
||||
|
||||
test('watch events coalesce at 200ms, filter by active radius and only invalidate on chunk movement', async t => {
|
||||
const app = fixture(t);
|
||||
const events = [];
|
||||
app.stream.subscribe(event => { if (event.name === 'info_update') events.push(event); });
|
||||
assert.equal(app.bot.listenerCount('chunkColumnLoad'), 0);
|
||||
const first = await app.snapshot();
|
||||
assert.equal(app.bot.listenerCount('chunkColumnLoad'), 1);
|
||||
for (let index = 0; index < 20; index++) app.bot.emit('blockUpdate', null, {position: {x: -1, z: -17}});
|
||||
app.bot.emit('chunkColumnLoad', {x: 0, z: -32});
|
||||
assert.equal(events.length, 0);
|
||||
await delay(230);
|
||||
assert.equal(events.length, 1);
|
||||
assert.deepEqual(events[0].payload, {name: 'world_preview', value: {world_generation: first.world_generation}});
|
||||
app.bot.emit('chunkColumnLoad', {x: 1600, z: 1600});
|
||||
app.bot.entity.position.x = -0.5; app.bot.emit('move');
|
||||
await delay(230);
|
||||
assert.equal(events.length, 1);
|
||||
app.bot.entity.position.x = 0.1; app.bot.emit('move');
|
||||
await delay(230);
|
||||
assert.equal(events.length, 2);
|
||||
assert.deepEqual(app.stream.world_unwatch({watch_id: first.watch_id}, 2000), {released: true});
|
||||
assert.deepEqual(app.stream.world_unwatch({watch_id: first.watch_id}, 2000), {released: true});
|
||||
for (const name of ['chunkColumnLoad', 'chunkColumnUnload', 'blockUpdate', 'move', 'respawn', 'end'])
|
||||
assert.equal(app.bot.listenerCount(name), 0);
|
||||
});
|
||||
|
||||
test('leases cap at 64, renew for 45 seconds and final unwatch or TTL removes caches, timers and listeners', async t => {
|
||||
const app = fixture(t);
|
||||
app.columns.set('-1,-2', app.column(() => 303));
|
||||
const first = await app.snapshot();
|
||||
const ids = [first.watch_id];
|
||||
for (let index = 1; index < 64; index++) ids.push((await app.snapshot()).watch_id);
|
||||
await assert.rejects(app.snapshot(), code('RATE_LIMITED'));
|
||||
app.time(44_000);
|
||||
const renewed = await app.snapshot({watch_id: first.watch_id, known: first.loaded, world_generation: first.world_generation});
|
||||
assert.equal(renewed.watch_id, first.watch_id);
|
||||
assert.equal(renewed.chunks.length, 0);
|
||||
app.time(45_101);
|
||||
for (const id of ids.slice(1)) app.stream.world_unwatch({watch_id: id}, 90_000);
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 1);
|
||||
app.time(89_001);
|
||||
app.bot.emit('move');
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
const reads = app.reads;
|
||||
const fresh = await app.snapshot({watch_id: first.watch_id, known: first.loaded, world_generation: first.world_generation});
|
||||
assert.notEqual(fresh.watch_id, first.watch_id); assert.notEqual(fresh.world_generation, first.world_generation);
|
||||
assert.ok(app.reads > reads);
|
||||
app.bot.emit('chunkColumnLoad', {x: -16, z: -32});
|
||||
app.stream.world_unwatch({watch_id: fresh.watch_id}, 100_000);
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
});
|
||||
|
||||
test('lease expiry timer detaches without further bot traffic and cancels a queued invalidation', async t => {
|
||||
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
|
||||
const app = fixture(t);
|
||||
app.time(1000);
|
||||
const events = [];
|
||||
app.stream.subscribe(event => { if (event.name === 'info_update') events.push(event); });
|
||||
const first = await app.snapshot();
|
||||
app.time(45_900); t.mock.timers.tick(44_900);
|
||||
app.bot.emit('chunkColumnLoad', {x: -16, z: -32});
|
||||
app.time(46_000); t.mock.timers.tick(100);
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(events.length, 0);
|
||||
assert.deepEqual(app.stream.world_unwatch({watch_id: first.watch_id}, 47_000), {released: true});
|
||||
});
|
||||
|
||||
test('watch radii use the union of active leases and shrink after releasing the wider watch', async t => {
|
||||
t.mock.timers.enable({apis: ['setTimeout']});
|
||||
const app = fixture(t);
|
||||
const events = [];
|
||||
app.stream.subscribe(event => { if (event.name === 'info_update') events.push(event); });
|
||||
const narrow = await app.snapshot();
|
||||
const wide = await app.snapshot({radius: 3});
|
||||
app.bot.emit('chunkColumnLoad', {x: 32, z: -32});
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(events.length, 1);
|
||||
app.stream.world_unwatch({watch_id: wide.watch_id}, 2000);
|
||||
app.bot.emit('chunkColumnLoad', {x: 32, z: -32});
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(events.length, 1);
|
||||
app.bot.emit('respawn');
|
||||
t.mock.timers.tick(200);
|
||||
assert.equal(events.length, 2);
|
||||
const reset = await app.snapshot({watch_id: narrow.watch_id});
|
||||
assert.equal(reset.world_generation, events[1].payload.value.world_generation);
|
||||
});
|
||||
|
||||
test('caller mutation after dispatch cannot change radius or known revisions, and scans are bounded to four concurrent calls', async t => {
|
||||
const app = fixture(t);
|
||||
app.columns.set('-1,-2', app.column());
|
||||
const input = {radius: 1, known: []};
|
||||
const pending = app.stream.world_snapshot(input, 2100);
|
||||
input.radius = 3; input.known.push({x: 0, z: 0, revision: 'bad'});
|
||||
const result = await pending;
|
||||
assert.equal(result.chunks.length, 1);
|
||||
app.bot.emit('blockUpdate', null, {position: {x: -1, z: -17}});
|
||||
const calls = Array.from({length: 4}, () => app.snapshot());
|
||||
await assert.rejects(app.snapshot(), code('RATE_LIMITED'));
|
||||
await Promise.all(calls);
|
||||
});
|
||||
|
||||
test('scalar column adapters and deterministic fallback colors remain compatible with the surface contract', async t => {
|
||||
const app = fixture(t);
|
||||
app.columns.set('-1,-2', {getBlockStateId(x, y, z) {
|
||||
assert.equal(typeof x, 'number'); assert.equal(typeof z, 'number');
|
||||
return y === 3 ? 505 : 7;
|
||||
}});
|
||||
const first = await app.snapshot();
|
||||
const cells = decoded(first.chunks[0]);
|
||||
assert.equal(cells[0].name, 'modded_marble'); assert.equal(cells[0].height, 8);
|
||||
assert.equal(Object.hasOwn(first.chunks[0], 'terrain'), false);
|
||||
assert.equal(Object.hasOwn(first.chunks[0], 'fluids'), false);
|
||||
assert.equal(Object.hasOwn(first.chunks[0], 'spikes'), false);
|
||||
app.bot.emit('respawn');
|
||||
const reset = await app.snapshot({watch_id: first.watch_id});
|
||||
assert.equal(decoded(reset.chunks[0])[0].color, cells[0].color);
|
||||
});
|
||||
|
||||
test('height 4096 and a full 256-entry surface palette fit UInt16, and transient metadata cannot retain stale revisions', async t => {
|
||||
const app = fixture(t, {minY: -4096, height: 4096});
|
||||
for (let index = 0; index < 256; index++) app.bot.registry.blocksByStateId[1000 + index] = {name: `surface_${index}`};
|
||||
app.columns.set('-1,-2', app.column((x, _y, z) => 1000 + z * 16 + x));
|
||||
const first = await app.snapshot();
|
||||
const cells = decoded(first.chunks[0]);
|
||||
assert.equal(first.chunks[0].palette.length, 256);
|
||||
assert.equal(cells[255].name, 'surface_255'); assert.equal(cells[255].height, 4096);
|
||||
app.bot.entity.yaw = NaN;
|
||||
app.bot.emit('blockUpdate', null, {position: {x: -1, z: -17}});
|
||||
app.bot.entity.yaw = 0;
|
||||
const next = await app.snapshot({known: first.loaded, world_generation: first.world_generation, watch_id: first.watch_id});
|
||||
assert.equal(next.chunks.length, 1); assert.notEqual(next.loaded[0].revision, first.loaded[0].revision);
|
||||
});
|
||||
|
||||
test('surface reads yield and abort promptly on deadline or transport disconnection', async t => {
|
||||
for (const cause of ['deadline', 'transport']) await t.test(cause, async t => {
|
||||
const app = fixture(t, {minY: -64, height: 384});
|
||||
app.columns.set('-1,-2', app.column());
|
||||
let stale = false;
|
||||
setImmediate(() => { if (cause === 'deadline') app.time(5000); else stale = true; });
|
||||
await assert.rejects(app.snapshot({}, () => {
|
||||
if (stale) throw Object.assign(new Error(), {code: 'SESSION_STALE'});
|
||||
}), code(cause === 'deadline' ? 'TIMEOUT' : 'SESSION_STALE'));
|
||||
assert.ok(app.reads <= 4096, `scan must stop during first read batch: ${app.reads}`);
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('in-flight reads reject context replacement, offline transition, disposal and column mutation', async t => {
|
||||
for (const cause of ['context', 'instance', 'offline', 'dispose', 'block', 'replacement', 'unload', 'respawn', 'world', 'move'])
|
||||
await t.test(cause, async t => {
|
||||
const app = fixture(t);
|
||||
app.columns.set('-1,-2', app.column());
|
||||
setImmediate(() => {
|
||||
if (cause === 'context') app.set(Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: app.bot}));
|
||||
if (cause === 'instance') app.context.bot_instance = {...app.bot};
|
||||
if (cause === 'offline') { app.context.bot_status = 'offline'; app.context.emit('status_update'); }
|
||||
if (cause === 'dispose') app.stream.dispose();
|
||||
if (cause === 'block') app.bot.emit('blockUpdate', null, {position: {x: -1, z: -17}});
|
||||
if (cause === 'replacement') app.columns.set('-1,-2', app.column(() => 303));
|
||||
if (cause === 'unload') app.columns.delete('-1,-2');
|
||||
if (cause === 'respawn') app.bot.emit('respawn');
|
||||
if (cause === 'world') app.bot.world = {sync: app.bot.world.sync};
|
||||
if (cause === 'move') { app.bot.entity.position.x = 20; app.bot.emit('move'); }
|
||||
});
|
||||
await assert.rejects(app.snapshot(), error => ['CANCELLED', 'BOT_NOT_ONLINE'].includes(error.code));
|
||||
app.stream.dispose();
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('offline, missing context, invalid metadata and unknown registry data never fabricate air', async t => {
|
||||
const app = fixture(t);
|
||||
await assert.rejects(app.stream.world_snapshot({radius: 0, known: []}, 200), code('INVALID_REQUEST'));
|
||||
await assert.rejects(app.stream.world_snapshot({radius: 1, known: []}, 100), code('TIMEOUT'));
|
||||
app.context.bot_status = 'offline';
|
||||
await assert.rejects(app.snapshot(), code('BOT_NOT_ONLINE'));
|
||||
assert.deepEqual(app.stream.world_unwatch({watch_id: randomUUID()}, 200), {released: true});
|
||||
app.context.bot_status = 'online';
|
||||
app.bot.entity.yaw = NaN;
|
||||
await assert.rejects(app.snapshot(), code('INTERNAL_ERROR'));
|
||||
app.bot.entity.yaw = 0;
|
||||
app.columns.set('-1,-2', app.column(() => 999_999));
|
||||
await assert.rejects(app.snapshot(), code('INTERNAL_ERROR'));
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
app.set(undefined);
|
||||
await assert.rejects(app.snapshot(), code('NO_CONTEXT'));
|
||||
assert.deepEqual(app.stream.world_unwatch({watch_id: randomUUID()}, 200), {released: true});
|
||||
app.stream.dispose(); app.stream.dispose();
|
||||
await assert.rejects(app.snapshot(), code('CANCELLED'));
|
||||
assert.equal(app.observers.size, 0);
|
||||
});
|
||||
|
||||
test('fluid columns retain top surfaces and sample contiguous water plants down to actual terrain', async t => {
|
||||
const app = fixture(t);
|
||||
const plants = ['bubble_column', 'kelp', 'kelp_plant', 'seagrass', 'tall_seagrass'];
|
||||
plants.forEach((name, index) => { app.bot.registry.blocksByStateId[600 + index] = {name: `minecraft:${name}`}; });
|
||||
app.bot.registry.blocksByStateId[700] = {name: 'minecraft:lava'};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => {
|
||||
if (z) return 7;
|
||||
if (x === 0) return y >= -2 && y <= 2 ? 202 : y === -3 ? 303 : 7;
|
||||
if (x >= 1 && x <= 5) return y === 4 ? 599 + x : y === 3 ? 602 : y === 2 ? 202 : y === 1 ? 101 : 7;
|
||||
if (x === 6) return y === 6 || y === 5 ? 700 : y === 4 ? 505 : 7;
|
||||
if (x === 7) return y > 8 ? 7 : y === 8 ? 404 : 999_999; // Solid tops must never inspect lower cells.
|
||||
if (x === 8) return y === -4 ? 202 : 7;
|
||||
if (x === 9) return y === 4 ? 202 : y === -4 ? 303 : 7;
|
||||
if (x === 10 || x === 11) return y > 4 ? 7 : y === 4 ? (x === 10 ? 202 : 700)
|
||||
: y === 3 ? (x === 10 ? 700 : 202) : 999_999;
|
||||
return 7;
|
||||
}));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
const top = decoded(chunk), terrain = decoded(chunk, chunk.terrain), fluids = decoded(chunk, chunk.fluids);
|
||||
assert.deepEqual(top[0], {height: 7, name: 'water', color: 0x3f76e4});
|
||||
assert.deepEqual(terrain[0], {height: 2, name: 'stone', color: 0x858585});
|
||||
assert.deepEqual(fluids[0], {...top[0], level: 0}); // Missing metadata defaults to source.
|
||||
for (let x = 1; x <= 5; x++) {
|
||||
assert.deepEqual(top[x], {height: 9, name: 'water', color: 0x3f76e4});
|
||||
assert.deepEqual(fluids[x], {...top[x], level: 0});
|
||||
assert.equal(terrain[x].name, 'grass_block'); assert.equal(terrain[x].height, 6);
|
||||
}
|
||||
assert.equal(top[6].name, 'lava'); assert.equal(fluids[6].name, 'lava'); assert.equal(fluids[6].level, 0);
|
||||
assert.equal(fluids[6].height, 11); assert.equal(terrain[6].name, 'modded_marble'); assert.equal(terrain[6].height, 9);
|
||||
assert.deepEqual(terrain[7], top[7]); assert.equal(fluids[7].height, 0);
|
||||
assert.equal(fluids[8].height, 1); assert.equal(terrain[8].height, 0);
|
||||
assert.equal(fluids[9].height, 9); assert.equal(terrain[9].height, 0); // No floor across an air gap.
|
||||
for (const x of [10, 11]) {
|
||||
assert.equal(top[x].name, x === 10 ? 'water' : 'lava');
|
||||
assert.deepEqual(fluids[x], {...top[x], level: 0});
|
||||
assert.equal(terrain[x].height, 0); // A different fluid ends the column without becoming solid terrain.
|
||||
}
|
||||
assert.equal(top[12].height, 0); assert.equal(terrain[12].height, 0); assert.equal(fluids[12].height, 0);
|
||||
assert.ok(!chunk.palette.some(entry => plants.some(name => entry.name.endsWith(name))));
|
||||
});
|
||||
|
||||
test('fluid level decoding respects registry property order and explicit level value order', async t => {
|
||||
const app = fixture(t, {minY: -10, height: 4});
|
||||
const states = [{name: 'before', num_values: 2},
|
||||
{name: 'level', num_values: 4, values: ['8', '0', '15', '3']}, {name: 'after', num_values: 3}];
|
||||
const expected = [8, 0, 15, 3];
|
||||
expected.forEach((_level, index) => {
|
||||
const state = 1000 + 12 + index * 3 + 2;
|
||||
app.bot.registry.blocksByStateId[state] = {name: 'water', minStateId: 1000, states};
|
||||
});
|
||||
app.columns.set('-1,-2', app.column((x, y) => y === -7 && x < 4 ? 1000 + 12 + x * 3 + 2 : 303));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
assert.deepEqual(decoded(chunk, chunk.fluids).slice(0, 4).map(cell => cell.level), expected);
|
||||
assert.deepEqual(decoded(chunk, chunk.terrain).slice(0, 4).map(cell => cell.height), [3, 3, 3, 3]);
|
||||
});
|
||||
|
||||
test('one shared palette accommodates 256 distinct floors plus water and lava', async t => {
|
||||
const app = fixture(t, {minY: -64, height: 2});
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'lava'};
|
||||
for (let index = 0; index < 256; index++) app.bot.registry.blocksByStateId[1000 + index] = {name: `floor_${index}`};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => y === -63 ? (x % 2 ? 202 : 600) : 1000 + z * 16 + x));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
const terrain = decoded(chunk, chunk.terrain), fluids = decoded(chunk, chunk.fluids);
|
||||
assert.equal(chunk.palette.length, 258);
|
||||
for (let index = 0; index < 256; index++) {
|
||||
assert.equal(terrain[index].name, `floor_${index}`); assert.equal(terrain[index].height, 1);
|
||||
assert.equal(fluids[index].name, index % 2 ? 'water' : 'lava'); assert.equal(fluids[index].height, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('fluid terrain scans reject unknown blocks and yield before cancellation without caching partial data', async t => {
|
||||
await t.test('unknown beneath fluid', async t => {
|
||||
const app = fixture(t);
|
||||
let unknown = true;
|
||||
app.columns.set('-1,-2', app.column((_x, y) => y === 11 ? 202 : unknown ? 999_999 : 303));
|
||||
await assert.rejects(app.snapshot(), code('INTERNAL_ERROR'));
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
unknown = false;
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
assert.equal(decoded(chunk, chunk.terrain)[0].height, 15);
|
||||
});
|
||||
await t.test('deep fluid cancellation', async t => {
|
||||
const app = fixture(t, {minY: -64, height: 4096});
|
||||
app.columns.set('-1,-2', app.column(() => 202));
|
||||
let stale = false;
|
||||
setImmediate(() => { stale = true; });
|
||||
await assert.rejects(app.snapshot({}, () => {
|
||||
if (stale) throw Object.assign(new Error(), {code: 'SESSION_STALE'});
|
||||
}), code('SESSION_STALE'));
|
||||
assert.equal(app.reads, 4096);
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
app.columns.set('-1,-2', app.column(() => 303));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
assert.equal(Object.hasOwn(chunk, 'terrain'), false);
|
||||
assert.equal(Object.hasOwn(chunk, 'fluids'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('spike runs decode mixed-radix direction and every thickness while preserving original surfaces', async t => {
|
||||
const app = fixture(t);
|
||||
const thicknesses = ['base', 'middle', 'frustum', 'tip_merge', 'tip'];
|
||||
const states = [{name: 'before', num_values: 2},
|
||||
{name: 'vertical_direction', num_values: 2, values: ['down', 'up']},
|
||||
{name: 'thickness', num_values: 5, values: thicknesses},
|
||||
{name: 'waterlogged', num_values: 2, values: ['true', 'false']}, {name: 'after', num_values: 3}];
|
||||
const state_id = (direction, thickness) => 1000 + (((2 + direction) * 5 + thickness) * 2 + 1) * 3 + 2;
|
||||
for (let direction = 0; direction < 2; direction++) for (let thickness = 0; thickness < 5; thickness++)
|
||||
app.bot.registry.blocksByStateId[state_id(direction, thickness)] = {name: 'minecraft:pointed_dripstone', minStateId: 1000, states};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => {
|
||||
if (z || x > 2) return y === 4 ? 101 : 7;
|
||||
if (x === 2) return y === 9 ? 404 : y < 9 ? 999_999 : 7;
|
||||
return y <= 7 && y >= 3 ? state_id(x, 7 - y) : y === 2 ? 303 : 7;
|
||||
}));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
const top = decoded(chunk), bases = decoded(chunk, chunk.spikes);
|
||||
assert.equal(Object.hasOwn(chunk, 'terrain'), false);
|
||||
assert.equal(Object.hasOwn(chunk, 'fluids'), false);
|
||||
for (const x of [0, 1]) {
|
||||
assert.equal(top[x].name, 'minecraft:pointed_dripstone'); assert.equal(top[x].height, 12);
|
||||
assert.equal(bases[x].name, 'stone'); assert.equal(bases[x].height, 7);
|
||||
}
|
||||
for (let index = 2; index < 256; index++) assert.deepEqual(bases[index], top[index]);
|
||||
assert.deepEqual(chunk.spikes.segments, [0, 1].flatMap(index => thicknesses.map((thickness, offset) =>
|
||||
({index, height: 12 - offset, direction: index ? 'up' : 'down', thickness}))));
|
||||
});
|
||||
|
||||
test('spike bases stop at air gaps and the world minimum, and absent metadata defaults to up/tip', async t => {
|
||||
const app = fixture(t);
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'pointed_dripstone'};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => {
|
||||
if (z) return 7;
|
||||
if (x === 0) return y === 4 || y === 3 ? 600 : y === -4 ? 303 : 7;
|
||||
if (x === 1) return y === -4 ? 600 : 7;
|
||||
if (x === 2) return y > 4 ? 7 : y === 4 ? 600 : y === 3 ? 8 : 999_999;
|
||||
if (x === 3) return y > 4 ? 7 : y === 4 ? 600 : y === 3 ? 9 : 999_999;
|
||||
return 7;
|
||||
}));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
const bases = decoded(chunk, chunk.spikes);
|
||||
for (const x of [0, 1, 2, 3]) assert.deepEqual(bases[x], {height: 0, name: 'air', color: 0});
|
||||
assert.deepEqual(chunk.spikes.segments, [
|
||||
{index: 0, height: 9, direction: 'up', thickness: 'tip'},
|
||||
{index: 0, height: 8, direction: 'up', thickness: 'tip'},
|
||||
{index: 1, height: 1, direction: 'up', thickness: 'tip'},
|
||||
{index: 2, height: 9, direction: 'up', thickness: 'tip'},
|
||||
{index: 3, height: 9, direction: 'up', thickness: 'tip'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('underwater spikes override only spike bases and mirror terrain in every other cell', async t => {
|
||||
const app = fixture(t);
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'pointed_dripstone'};
|
||||
app.bot.registry.blocksByStateId[601] = {name: 'lava'};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => {
|
||||
if (z) return y === 0 ? 101 : 7;
|
||||
if (x <= 1) return y === 8 || y === 7 ? 202 : y === 6 || y === 5 ? 600 : y === 4 && !x ? 303 : 7;
|
||||
if (x === 2) return y === 8 ? 202 : y === 7 ? 303 : 7;
|
||||
if (x === 3) return y > 8 ? 7 : y === 8 ? 202 : y === 7 ? 601 : 999_999;
|
||||
if (x === 4) return y > 8 ? 7 : y === 8 ? 404 : 999_999;
|
||||
return 7;
|
||||
}));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
const top = decoded(chunk), terrain = decoded(chunk, chunk.terrain), fluids = decoded(chunk, chunk.fluids);
|
||||
const bases = decoded(chunk, chunk.spikes);
|
||||
for (const x of [0, 1]) {
|
||||
assert.equal(top[x].name, 'water'); assert.equal(top[x].height, 13);
|
||||
assert.equal(fluids[x].height, 13); assert.equal(fluids[x].level, 0);
|
||||
assert.equal(terrain[x].name, 'pointed_dripstone'); assert.equal(terrain[x].height, 11);
|
||||
}
|
||||
assert.equal(bases[0].name, 'stone'); assert.equal(bases[0].height, 9);
|
||||
assert.equal(bases[1].height, 0); assert.equal(bases[1].name, 'air');
|
||||
for (let index = 2; index < 256; index++) assert.deepEqual(bases[index], terrain[index]);
|
||||
assert.deepEqual(chunk.spikes.segments.map(({index, height}) => ({index, height})),
|
||||
[{index: 0, height: 11}, {index: 0, height: 10}, {index: 1, height: 11}, {index: 1, height: 10}]);
|
||||
});
|
||||
|
||||
test('spike caps commit complete columns and retain original columns when limits would be exceeded', async t => {
|
||||
await t.test('sixteen per column, oversized runs omitted', async t => {
|
||||
const app = fixture(t, {minY: 0, height: 20});
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'pointed_dripstone'};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => !z && x < 2 ? (y >= (x ? 3 : 4) ? 600 : 303) : 7));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
const top = decoded(chunk), bases = decoded(chunk, chunk.spikes);
|
||||
assert.equal(chunk.spikes.segments.length, 16);
|
||||
assert.ok(chunk.spikes.segments.every(segment => segment.index === 0));
|
||||
assert.equal(bases[0].height, 4); assert.equal(bases[0].name, 'stone');
|
||||
assert.deepEqual(bases[1], top[1]);
|
||||
});
|
||||
await t.test('chunk budget does not commit partial columns and later smaller runs can fit', async t => {
|
||||
const app = fixture(t, {minY: 0, height: 20});
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'pointed_dripstone'};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => {
|
||||
const index = z * 16 + x;
|
||||
const length = index < 31 ? 16 : index === 31 ? 15 : index === 32 ? 2 : 1;
|
||||
return y >= 20 - length ? 600 : 303;
|
||||
}));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
const top = decoded(chunk), bases = decoded(chunk, chunk.spikes);
|
||||
assert.equal(chunk.spikes.segments.length, 512);
|
||||
assert.equal(chunk.spikes.segments.filter(segment => segment.index === 31).length, 15);
|
||||
assert.equal(chunk.spikes.segments.filter(segment => segment.index === 32).length, 0);
|
||||
assert.equal(chunk.spikes.segments.filter(segment => segment.index === 33).length, 1);
|
||||
assert.equal(bases[33].height, 19); assert.equal(bases[33].name, 'stone');
|
||||
for (const index of [32, ...Array.from({length: 222}, (_, offset) => offset + 34)])
|
||||
assert.deepEqual(bases[index], top[index]);
|
||||
assert.ok(app.reads <= 256 + 512 + 256 + 3, `bounded spike scan: ${app.reads}`);
|
||||
});
|
||||
await t.test('no successfully sampled spikes omits the feature', async t => {
|
||||
const app = fixture(t, {minY: 0, height: 20});
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'pointed_dripstone'};
|
||||
app.columns.set('-1,-2', app.column(() => 600));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
assert.equal(Object.hasOwn(chunk, 'spikes'), false);
|
||||
assert.ok(decoded(chunk).every(cell => cell.name === 'pointed_dripstone' && cell.height === 20));
|
||||
assert.equal(app.reads, 256 * 17);
|
||||
});
|
||||
await t.test('underwater oversized runs retain pointed terrain and fluid surfaces', async t => {
|
||||
const app = fixture(t, {minY: 0, height: 20});
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'pointed_dripstone'};
|
||||
app.columns.set('-1,-2', app.column((x, y, z) => {
|
||||
if (z || x > 1) return 7;
|
||||
if (y === 19) return 202;
|
||||
return y >= (x ? 2 : 17) ? 600 : 303;
|
||||
}));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
const top = decoded(chunk), terrain = decoded(chunk, chunk.terrain), fluids = decoded(chunk, chunk.fluids);
|
||||
const bases = decoded(chunk, chunk.spikes);
|
||||
assert.equal(chunk.spikes.segments.length, 2);
|
||||
assert.ok(chunk.spikes.segments.every(segment => segment.index === 0));
|
||||
assert.equal(bases[0].name, 'stone'); assert.equal(bases[0].height, 17);
|
||||
assert.deepEqual(bases[1], terrain[1]);
|
||||
assert.equal(terrain[1].name, 'pointed_dripstone'); assert.equal(terrain[1].height, 19);
|
||||
assert.equal(top[1].name, 'water'); assert.equal(top[1].height, 20);
|
||||
assert.deepEqual(fluids[1], {...top[1], level: 0});
|
||||
});
|
||||
});
|
||||
|
||||
test('spike scans reject unknown states and preserve yield/cancellation bounds without caching partial features', async t => {
|
||||
await t.test('unknown segment or foundation', async t => {
|
||||
const app = fixture(t);
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'pointed_dripstone'};
|
||||
let unknown = true;
|
||||
app.columns.set('-1,-2', app.column((_x, y) => y >= 9 ? 600 : unknown ? 999_999 : 303));
|
||||
await assert.rejects(app.snapshot(), code('INTERNAL_ERROR'));
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
unknown = false;
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
assert.equal(chunk.spikes.segments.length, 510);
|
||||
assert.equal(decoded(chunk, chunk.spikes)[0].height, 13);
|
||||
});
|
||||
await t.test('cancel at row yield', async t => {
|
||||
const app = fixture(t, {minY: 0, height: 20});
|
||||
app.bot.registry.blocksByStateId[600] = {name: 'pointed_dripstone'};
|
||||
app.columns.set('-1,-2', app.column((_x, y) => y >= 4 ? 600 : 303));
|
||||
let stale = false;
|
||||
setImmediate(() => { stale = true; });
|
||||
await assert.rejects(app.snapshot({}, () => {
|
||||
if (stale) throw Object.assign(new Error(), {code: 'SESSION_STALE'});
|
||||
}), code('SESSION_STALE'));
|
||||
assert.equal(app.reads, 32 * 17);
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
app.columns.set('-1,-2', app.column(() => 303));
|
||||
const {chunks: [chunk]} = await app.snapshot();
|
||||
assert.equal(Object.hasOwn(chunk, 'spikes'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('real installed WorldSync and chunk registry produce the same surface wire format', async t => {
|
||||
const installed = createRequire(require.resolve('@wp2508/mineflayer'));
|
||||
const registry = installed('@wp2508/minecraft-data')('1.21.4');
|
||||
const Chunk = installed('prismarine-chunk')('1.21.4');
|
||||
const World = installed('prismarine-world')('1.21.4');
|
||||
const chunk = new Chunk({minY: -64, worldHeight: 384});
|
||||
chunk.setBlockStateId({x: 0, y: 67, z: 0}, registry.blocksByName.grass_block.minStateId);
|
||||
chunk.setBlockStateId({x: 1, y: 70, z: 0}, registry.blocksByName.water.minStateId);
|
||||
for (const [x, name, level] of [[3, 'water', 3], [4, 'water', 8], [5, 'water', 15],
|
||||
[6, 'lava', 0], [7, 'lava', 8], [8, 'lava', 15]]) {
|
||||
chunk.setBlockStateId({x, y: 70, z: 0}, registry.blocksByName[name].minStateId + level);
|
||||
chunk.setBlockStateId({x, y: 69, z: 0}, registry.blocksByName.stone.minStateId);
|
||||
}
|
||||
const dripstone = registry.blocksByName.pointed_dripstone;
|
||||
const spike_state = properties => dripstone.minStateId + dripstone.states.reduce((offset, property) =>
|
||||
offset * property.num_values + (property.values ? property.values.indexOf(properties[property.name])
|
||||
: properties[property.name] === 'false' ? 1 : 0), 0);
|
||||
const real_spikes = [];
|
||||
for (const direction of ['up', 'down']) for (const thickness of ['tip', 'tip_merge', 'frustum', 'middle', 'base']) {
|
||||
const index = 16 + real_spikes.length;
|
||||
const state = spike_state({vertical_direction: direction, thickness, waterlogged: 'false'});
|
||||
chunk.setBlockStateId({x: index % 16, y: 70, z: 1}, state);
|
||||
chunk.setBlockStateId({x: index % 16, y: 69, z: 1}, registry.blocksByName.stone.minStateId);
|
||||
real_spikes.push({index, height: 135, direction, thickness});
|
||||
}
|
||||
const app = fixture(t, {minY: -64, height: 384});
|
||||
const world = new World(null, null, {savingInterval: 0});
|
||||
app.bot.world = world.sync;
|
||||
app.bot.registry = registry;
|
||||
app.bot.world.setColumn(-1, -2, chunk, false);
|
||||
const result = await app.snapshot();
|
||||
const cells = decoded(result.chunks[0]);
|
||||
assert.equal(cells[0].height, 132); assert.equal(cells[0].name, 'grass_block');
|
||||
assert.equal(cells[1].height, 135); assert.equal(cells[1].name, 'water');
|
||||
assert.equal(cells[2].height, 0);
|
||||
const fluids = decoded(result.chunks[0], result.chunks[0].fluids);
|
||||
const terrain = decoded(result.chunks[0], result.chunks[0].terrain);
|
||||
assert.equal(fluids[1].level, 0); assert.equal(terrain[1].height, 0);
|
||||
for (const [x, name, level] of [[3, 'water', 3], [4, 'water', 8], [5, 'water', 15],
|
||||
[6, 'lava', 0], [7, 'lava', 8], [8, 'lava', 15]]) {
|
||||
assert.equal(fluids[x].name, name); assert.equal(fluids[x].level, level); assert.equal(fluids[x].height, 135);
|
||||
assert.equal(terrain[x].name, 'stone'); assert.equal(terrain[x].height, 134);
|
||||
}
|
||||
assert.deepEqual(result.chunks[0].spikes.segments, real_spikes);
|
||||
const bases = decoded(result.chunks[0], result.chunks[0].spikes);
|
||||
for (const {index} of real_spikes) {
|
||||
assert.equal(terrain[index].name, 'pointed_dripstone'); assert.equal(terrain[index].height, 135);
|
||||
assert.equal(bases[index].name, 'stone'); assert.equal(bases[index].height, 134);
|
||||
}
|
||||
});
|
||||
|
||||
function transport_fixture(t) {
|
||||
const app = fixture(t);
|
||||
app.columns.set('-1,-2', app.column());
|
||||
const command = {forward_id: 0, forward_session_id: randomUUID(), node_session_id: 'node-session', node_id: 1, bot_id: 1,
|
||||
url: 'http://127.0.0.1:1234', path: '/api/forward_server', namespace: '/', token: randomUUID(),
|
||||
expires_at: Date.now() + 30_000, deadline_at: Date.now() + 2000};
|
||||
let socket;
|
||||
const manager = new NodeForwardManager(1, 'node-session', {
|
||||
stream_factory: () => app.stream,
|
||||
socket_factory() {
|
||||
socket = new EventEmitter(); socket.auth = {};
|
||||
socket.connect = () => { socket.connected = true; queueMicrotask(() => socket.emit('connect')); };
|
||||
socket.disconnect = () => { socket.connected = false; socket.emit('disconnect'); };
|
||||
socket.io = {engine: {writeBuffer: []}};
|
||||
socket.volatile = {emit() {}};
|
||||
socket.timeout = () => ({emit(event, _payload, ack) {
|
||||
const {forward_id, forward_session_id, node_session_id, node_id, bot_id, expires_at} = command;
|
||||
queueMicrotask(() => ack(null, {status: 'success', data: event === 'forward_node_ready' ? {node_ready: true}
|
||||
: {forward_id, forward_session_id, node_session_id, node_id, bot_id, role: 'node', node_ready: false, expires_at}}));
|
||||
}});
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
t.after(() => manager.stop());
|
||||
return {...app, manager, command, get socket() { return socket; },
|
||||
rpc(method, payload) { return new Promise(resolve => socket.emit('relay_request', request(method, payload, command.forward_session_id), resolve)); },
|
||||
};
|
||||
}
|
||||
|
||||
test('manager dispatches world reads through guarded RPC and rejects malformed requests and disconnect during yield', async t => {
|
||||
const app = transport_fixture(t);
|
||||
assert.equal((await app.manager.ensure(app.command)).status, 'success');
|
||||
const snapshot = await app.rpc('world.snapshot', {radius: 1, known: []});
|
||||
assert.equal(snapshot.status, 'success'); assert.match(snapshot.data.watch_id, uuid);
|
||||
assert.deepEqual(await app.rpc('world.unwatch', {watch_id: snapshot.data.watch_id}), {status: 'success', data: {released: true}});
|
||||
assert.equal((await app.rpc('world.snapshot', {radius: 4, known: []})).data.code, 'INVALID_REQUEST');
|
||||
const pending = app.rpc('world.snapshot', {radius: 1, known: []});
|
||||
setImmediate(() => app.socket.disconnect());
|
||||
assert.equal((await pending).data.code, 'SESSION_STALE');
|
||||
assert.equal(app.bot.listenerCount('blockUpdate'), 0);
|
||||
});
|
||||
Reference in New Issue
Block a user