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