feat: relay world snapshot and voxel payloads
Allow world.* methods through the relay, validate their payloads, and track per-session world revisions in the session store.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
import type {WorldSnapshot} from '../types.ts';
|
||||
import {DEFAULT_LIMITS} from '../utils/relay_config.ts';
|
||||
import {RelayError, check_permissions, success, validate_info_update, validate_node_ack, validate_request} from '../utils/relay_validation.ts';
|
||||
|
||||
const uuid = randomUUID();
|
||||
const revision = {x: 0, z: 0, revision: uuid};
|
||||
const cells = Buffer.alloc(512).toString('base64');
|
||||
const encoded = (value: number, index = 255) => {
|
||||
const buffer = Buffer.alloc(512);
|
||||
buffer.writeUInt16LE(value, index * 2);
|
||||
return buffer.toString('base64');
|
||||
};
|
||||
const snapshot = (): WorldSnapshot => ({
|
||||
world_generation: uuid, context_generation: uuid, stream_id: uuid, watch_id: uuid,
|
||||
version: '1.21.1', dimension: 'minecraft:overworld', min_y: -64, height: 384,
|
||||
center: {x: 0, z: 0}, position: {x: 0.5, y: -63, z: -0.5, yaw: Math.PI},
|
||||
chunks: [{...revision, palette: [{name: 'minecraft:air', color: 0}], heights: cells, blocks: cells}],
|
||||
loaded: [revision], partial: false,
|
||||
});
|
||||
const fluid_chunk = (): WorldSnapshot['chunks'][number] => ({
|
||||
...snapshot().chunks[0]!,
|
||||
palette: [{name: 'minecraft:stone', color: 0x777777}, {name: 'minecraft:water', color: 0x3f76e4}],
|
||||
heights: encoded(384), blocks: encoded(1),
|
||||
terrain: {heights: encoded(383), blocks: cells},
|
||||
fluids: {heights: encoded(384), blocks: encoded(1), levels: encoded(15)},
|
||||
});
|
||||
const request = (method: string, payload: unknown) => validate_request({request_id: uuid, method, payload}, DEFAULT_LIMITS);
|
||||
const ack = (data: unknown, radius = 3) => validate_node_ack('world.snapshot', success(data), 1, DEFAULT_LIMITS, {radius, known: []});
|
||||
const rejected = (action: () => unknown) => assert.throws(action,
|
||||
(error: unknown) => error instanceof RelayError && error.code === 'INVALID_REQUEST');
|
||||
|
||||
test('surface features preserve bounded rotation, attachments and waterlogged states', () => {
|
||||
const chunk = {...snapshot().chunks[0]!, heights: encoded(10), blocks: cells,
|
||||
palette: [{name: 'oak_hanging_sign', color: 0}, {name: 'stone', color: 1}],
|
||||
features: {heights: encoded(8), blocks: encoded(1), entries: [{index: 255, height: 10, block: 0, properties: {rotation: 7, attached: true, waterlogged: true}}]}};
|
||||
const accept = (value: unknown) => ack({...snapshot(), chunks: [value]});
|
||||
accept(chunk);
|
||||
for (const patch of [{index: 256}, {height: 11}, {height: 8}, {block: 2}, {properties: {rotation: 16}},
|
||||
{properties: {attached: 'true'}}, {properties: {waterlogged: 1}}, {properties: {unknown: true}}, {properties: {facing: 'sideways'}}]) {
|
||||
rejected(() => accept({...chunk, features: {...chunk.features, entries: [{...chunk.features.entries[0], ...patch}]}}));
|
||||
}
|
||||
rejected(() => accept({...chunk, features: {...chunk.features, entries: [chunk.features.entries[0], chunk.features.entries[0]]}}));
|
||||
rejected(() => accept({...chunk, features: {...chunk.features, entries: []}}));
|
||||
rejected(() => accept({...chunk, features: {...chunk.features, heights: ''}}));
|
||||
});
|
||||
|
||||
test('spike feature payload validates exact states, bounds, duplicate cells and column budgets', () => {
|
||||
const chunk = {...snapshot().chunks[0]!, palette: [{name: 'pointed_dripstone', color: 0}, {name: 'stone', color: 1}],
|
||||
heights: encoded(10), blocks: cells, spikes: {heights: encoded(8), blocks: encoded(1),
|
||||
segments: [{index: 255, height: 10, direction: 'up', thickness: 'tip'}]}};
|
||||
const accept = (value: unknown) => ack({...snapshot(), chunks: [value]});
|
||||
accept(chunk);
|
||||
const segment = chunk.spikes.segments[0]!;
|
||||
for (const segments of [[], [segment, segment], [{...segment, direction: 'north'}], [{...segment, height: 11}],
|
||||
[{...segment, height: 8}], [{...segment, index: 256}], [{...segment, thickness: 'unknown'}],
|
||||
Array.from({length: 513}, () => segment)]) rejected(() => accept({...chunk, spikes: {...chunk.spikes, segments}}));
|
||||
rejected(() => accept({...chunk, spikes: {...chunk.spikes, extra: true}}));
|
||||
rejected(() => accept({...chunk, spikes: {...chunk.spikes, heights: cells.slice(0, -1)}}));
|
||||
rejected(() => accept({...chunk, palette: [{name: 'stone', color: 0}, {name: 'stone', color: 1}]}));
|
||||
});
|
||||
|
||||
test('maximum bounded spike segments survive the world-only complexity budget across 49 chunks', () => {
|
||||
const data = snapshot(), heights = Buffer.alloc(512);
|
||||
for (let offset = 0; offset < 512; offset += 2) heights.writeUInt16LE(16, offset);
|
||||
const segments = Array.from({length: 512}, (_, index) => ({index: Math.floor(index / 16), height: index % 16 + 1,
|
||||
direction: 'up' as const, thickness: 'middle' as const}));
|
||||
data.chunks = []; data.loaded = [];
|
||||
for (let z = -3; z <= 3; z++) for (let x = -3; x <= 3; x++) {
|
||||
const revision = {x, z, revision: randomUUID()};
|
||||
data.loaded.push(revision);
|
||||
data.chunks.push({...revision, heights: heights.toString('base64'), blocks: cells,
|
||||
palette: Array.from({length: 512}, (_, i) => ({name: i === 0 ? 'pointed_dripstone' : `block_${i}`, color: i})),
|
||||
spikes: {heights: cells, blocks: cells, segments}});
|
||||
}
|
||||
ack(data);
|
||||
});
|
||||
|
||||
test('world RPC whitelist uses existing capabilities and exact bounded request payloads', () => {
|
||||
check_permissions(['bot.snapshot']);
|
||||
rejected(() => check_permissions(['world.snapshot']));
|
||||
rejected(() => check_permissions(['world.unwatch']));
|
||||
for (const radius of [1, 2, 3]) {
|
||||
request('world.snapshot', {radius, known: []});
|
||||
request('world.snapshot', {radius, known: [revision], world_generation: uuid, watch_id: uuid});
|
||||
}
|
||||
request('world.snapshot', {radius: 3, known: Array.from({length: 49}, (_, x) => ({...revision, x: x - 24}))});
|
||||
request('world.snapshot', {radius: 1, known: [{...revision, x: -1_875_000, z: 1_875_000}]});
|
||||
request('world.unwatch', {watch_id: uuid});
|
||||
for (const payload of [null, {}, {radius: 1}, {known: []}, {radius: 0, known: []}, {radius: 4, known: []},
|
||||
{radius: 1.5, known: []}, {radius: '1', known: []}, {radius: 1, known: [revision, revision]},
|
||||
{radius: 1, known: [revision, {...revision, x: -0}]},
|
||||
{radius: 1, known: Array.from({length: 50}, (_, x) => ({...revision, x}))},
|
||||
...['world_generation', 'watch_id', 'bot_id', 'forward_session_id', 'context_generation'].map(key => ({radius: 1, known: [] as unknown[], [key]: 'bad'})),
|
||||
...[{x: 1.2}, {x: 1_875_001}, {z: -1_875_001}, {revision: 'bad'}, {extra: true}]
|
||||
.map(fields => ({radius: 1, known: [{...revision, ...fields}]})),
|
||||
]) rejected(() => request('world.snapshot', payload));
|
||||
for (const payload of [{}, {watch_id: 'bad'}, {watch_id: uuid, radius: 1}, {watch_id: uuid, bot_id: 1}]) {
|
||||
rejected(() => request('world.unwatch', payload));
|
||||
}
|
||||
rejected(() => request('world.subscribe', {}));
|
||||
const sparse = new Array(1);
|
||||
rejected(() => request('world.snapshot', {radius: 1, known: sparse}));
|
||||
});
|
||||
|
||||
test('world snapshot ACKs require exact metadata and JSON safe scoped fields', () => {
|
||||
ack(snapshot());
|
||||
ack({...snapshot(), min_y: -4096, height: 4096, partial: true});
|
||||
ack({...snapshot(), min_y: 4096, height: 1, chunks: [], loaded: []});
|
||||
validate_node_ack('world.unwatch', success({released: true}), 1, DEFAULT_LIMITS);
|
||||
for (const data of [null, {}, {released: false}, {released: true, watch_id: uuid}]) {
|
||||
rejected(() => validate_node_ack('world.unwatch', success(data), 1, DEFAULT_LIMITS));
|
||||
}
|
||||
for (const field of Object.keys(snapshot())) {
|
||||
const data = {...snapshot()} as Record<string, unknown>;
|
||||
delete data[field];
|
||||
rejected(() => ack(data));
|
||||
}
|
||||
const invalid: Record<string, unknown>[] = [
|
||||
{world_generation: 'bad'}, {context_generation: null}, {stream_id: 'bad'}, {watch_id: 'bad'},
|
||||
{version: ''}, {version: ' '.repeat(64)}, {version: 'v'.repeat(65)}, {dimension: ''}, {dimension: 'x'.repeat(257)},
|
||||
{min_y: -4097}, {min_y: 4097}, {min_y: 0.5}, {height: 0}, {height: 4097}, {height: 1.5}, {partial: 1},
|
||||
{bot_id: 99}, {forward_session_id: uuid}, {center: {x: 0, z: 0, y: 0}}, {center: {x: 0.1, z: 0}},
|
||||
{center: {x: 1_875_001, z: 0}}, {position: {x: 0, y: 0, z: 0}},
|
||||
{position: {x: 0, y: 0, z: 0, yaw: NaN}}, {position: {x: Infinity, y: 0, z: 0, yaw: 0}},
|
||||
{position: {x: 0, y: 0, z: 0, yaw: 0, pitch: 0}},
|
||||
];
|
||||
for (const fields of invalid) rejected(() => ack({...snapshot(), ...fields}));
|
||||
for (const binary of [Buffer.alloc(512), new Uint16Array(256), new Blob(['data'])]) {
|
||||
rejected(() => ack({...snapshot(), chunks: [{...snapshot().chunks[0], heights: binary}]}));
|
||||
}
|
||||
const accessor = {...snapshot()};
|
||||
Object.defineProperty(accessor, 'height', {enumerable: true, get() {throw new Error('must not execute');}});
|
||||
rejected(() => ack(accessor));
|
||||
const size = Buffer.byteLength(JSON.stringify(success(snapshot())));
|
||||
validate_node_ack('world.snapshot', success(snapshot()), 1, {...DEFAULT_LIMITS, max_payload_bytes: size});
|
||||
rejected(() => validate_node_ack('world.snapshot', success(snapshot()), 1, {...DEFAULT_LIMITS, max_payload_bytes: size - 1}));
|
||||
});
|
||||
|
||||
test('world encoded cells enforce canonical 684 character base64 and UInt16LE bounds', () => {
|
||||
const chunk = snapshot().chunks[0]!;
|
||||
ack({...snapshot(), chunks: [{...chunk, heights: encoded(384)}]});
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, heights: encoded(385)}]}));
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, blocks: encoded(1)}]}));
|
||||
const palette = Array.from({length: 256}, (_, color) => ({name: `block_${color}`, color}));
|
||||
ack({...snapshot(), chunks: [{...chunk, palette, blocks: encoded(255)}]});
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, palette, blocks: encoded(256)}]}));
|
||||
const expanded_palette = Array.from({length: 512}, (_, color) => ({name: `block_${color}`, color}));
|
||||
ack({...snapshot(), chunks: [{...chunk, palette: expanded_palette, blocks: encoded(511)}]});
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, palette: expanded_palette, blocks: encoded(512)}]}));
|
||||
const noncanonical = `${cells.slice(0, -2)}B=`;
|
||||
assert.equal(Buffer.from(noncanonical, 'base64').length, 512);
|
||||
assert.equal(Buffer.from(noncanonical, 'base64').toString('base64'), cells);
|
||||
for (const field of ['heights', 'blocks']) {
|
||||
for (const value of ['', cells.slice(0, -1), `${cells}\n`, 'A'.repeat(685), 'A'.repeat(684),
|
||||
Buffer.alloc(511).toString('base64'), Buffer.alloc(513).toString('base64'), noncanonical]) {
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, [field]: value}]}));
|
||||
}
|
||||
}
|
||||
for (const palette of [[], Array.from({length: 513}, () => ({name: 'a', color: 0})),
|
||||
[{name: 'x'.repeat(129), color: 0}], [{name: 'a', color: -1}], [{name: 'a', color: 0x1000000}],
|
||||
[{name: 'a', color: 0.5}], [{name: 'a', color: 0, extra: true}], [{color: 0}]]) {
|
||||
rejected(() => ack({...snapshot(), chunks: [{...chunk, palette}]}));
|
||||
}
|
||||
ack({...snapshot(), chunks: [{...chunk, palette: [{name: 'x'.repeat(128), color: 0xffffff}]}]});
|
||||
});
|
||||
|
||||
test('world terrain and fluids are paired exact optional objects with canonical bounded cells', () => {
|
||||
const chunk = fluid_chunk();
|
||||
const accept = (value: unknown) => ack({...snapshot(), chunks: [value]});
|
||||
accept(chunk);
|
||||
accept(snapshot().chunks[0]);
|
||||
for (const missing of ['terrain', 'fluids']) {
|
||||
const half = {...chunk};
|
||||
delete half[missing as 'terrain' | 'fluids'];
|
||||
rejected(() => accept(half));
|
||||
}
|
||||
for (const field of ['terrain', 'fluids'] as const) {
|
||||
for (const value of [null, undefined, [], {}, {...chunk[field], extra: true}]) {
|
||||
rejected(() => accept({...chunk, [field]: value}));
|
||||
}
|
||||
for (const key of Object.keys(chunk[field]!)) {
|
||||
const incomplete = {...chunk[field]} as Record<string, unknown>;
|
||||
delete incomplete[key];
|
||||
rejected(() => accept({...chunk, [field]: incomplete}));
|
||||
const noncanonical = `${cells.slice(0, -2)}B=`;
|
||||
for (const value of [null, 0, Buffer.alloc(512), '', cells.slice(0, -1), `${cells}\n`,
|
||||
'A'.repeat(684), Buffer.alloc(511).toString('base64'), Buffer.alloc(513).toString('base64'), noncanonical]) {
|
||||
rejected(() => accept({...chunk, [field]: {...chunk[field], [key]: value}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [field, key, value] of [
|
||||
['terrain', 'heights', 385], ['terrain', 'blocks', 2], ['fluids', 'heights', 385],
|
||||
['fluids', 'blocks', 2], ['fluids', 'levels', 16], ['fluids', 'levels', 65535],
|
||||
] as const) {
|
||||
for (const index of [0, 255]) {
|
||||
rejected(() => accept({...chunk, [field]: {...chunk[field], [key]: encoded(value, index)}}));
|
||||
}
|
||||
}
|
||||
accept({...chunk, terrain: {...chunk.terrain!, heights: encoded(384)}});
|
||||
accept({...chunk, fluids: {...chunk.fluids!, levels: cells}});
|
||||
// Absent fluid columns may refer to any in-bounds palette entry, but levels remain bounded.
|
||||
accept({...chunk, fluids: {heights: cells, blocks: cells, levels: encoded(15)}});
|
||||
rejected(() => accept({...chunk, fluids: {heights: cells, blocks: cells, levels: encoded(16)}}));
|
||||
});
|
||||
|
||||
test('world fluid columns require water or lava and consistent terrain and visible heights', () => {
|
||||
const chunk = fluid_chunk();
|
||||
const accept = (value: unknown) => ack({...snapshot(), chunks: [value]});
|
||||
for (const name of ['water', 'lava', 'minecraft:water', 'minecraft:lava']) {
|
||||
accept({...chunk, palette: [chunk.palette[0], {name, color: 0}]});
|
||||
}
|
||||
for (const name of ['stone', 'minecraft:stone', 'flowing_water', 'minecraft:flowing_lava',
|
||||
'other:water', 'Minecraft:water', 'water_extra', 'minecraft:water\n', '']) {
|
||||
rejected(() => accept({...chunk, palette: [chunk.palette[0], {name, color: 0}]}));
|
||||
}
|
||||
rejected(() => accept({...chunk, fluids: {...chunk.fluids!, blocks: cells}}));
|
||||
rejected(() => accept({...chunk, fluids: {...chunk.fluids!, heights: encoded(382)}}));
|
||||
rejected(() => accept({...chunk, heights: encoded(383)}));
|
||||
for (const index of [0, 255]) {
|
||||
const heights = encoded(384, index);
|
||||
accept({...chunk, heights, terrain: {heights: cells, blocks: cells},
|
||||
fluids: {heights, blocks: encoded(1, index), levels: encoded(8, index)}});
|
||||
}
|
||||
});
|
||||
|
||||
test('world snapshots support 49 mixed legacy and fluid chunks with expanded shared palettes', () => {
|
||||
const full = snapshot();
|
||||
full.loaded = Array.from({length: 49}, (_, index) => ({...revision, x: index % 7 - 3, z: Math.floor(index / 7) - 3}));
|
||||
full.chunks = full.loaded.map((entry, index) => {
|
||||
const chunk = index % 2 ? snapshot().chunks[0]! : fluid_chunk();
|
||||
const palette = Array.from({length: index % 2 ? 256 : 512}, (_, color) => ({name: `block_${color}`, color}));
|
||||
if (index % 2) return {...chunk, ...entry, palette, blocks: encoded(255)};
|
||||
palette[511] = {name: index % 4 ? 'lava' : 'minecraft:water', color: 0xffffff};
|
||||
return {...chunk, ...entry, palette, blocks: encoded(511),
|
||||
terrain: {heights: encoded(384), blocks: encoded(510)},
|
||||
fluids: {heights: encoded(384), blocks: encoded(511), levels: encoded(15)}};
|
||||
});
|
||||
ack(full);
|
||||
const size = Buffer.byteLength(JSON.stringify(success(full)));
|
||||
validate_node_ack('world.snapshot', success(full), 1, {...DEFAULT_LIMITS, max_payload_bytes: size});
|
||||
rejected(() => validate_node_ack('world.snapshot', success(full), 1, {...DEFAULT_LIMITS, max_payload_bytes: size - 1}));
|
||||
rejected(() => ack(full, 2));
|
||||
const largest_palette = full.chunks[0]!.palette;
|
||||
const maximum = {...full, chunks: full.loaded.map(entry => ({...full.chunks[0]!, ...entry, palette: largest_palette}))};
|
||||
ack(maximum);
|
||||
// The extra traversal allowance belongs only to world snapshots, not other ACKs.
|
||||
assert.throws(() => validate_node_ack('bot.snapshot', success(maximum), 1, DEFAULT_LIMITS),
|
||||
(error: unknown) => error instanceof RelayError && error.message === 'JSON complexity limit exceeded');
|
||||
});
|
||||
|
||||
test('world chunks are unique loaded members with matching revisions inside the requested radius', () => {
|
||||
const full = snapshot();
|
||||
full.loaded = Array.from({length: 49}, (_, index) => ({...revision, x: index % 7 - 3, z: Math.floor(index / 7) - 3}));
|
||||
full.chunks = full.loaded.map(entry => ({...full.chunks[0]!, ...entry}));
|
||||
ack(full);
|
||||
validate_node_ack('world.snapshot', success(full), 1, DEFAULT_LIMITS);
|
||||
rejected(() => ack(full, 2));
|
||||
for (const data of [
|
||||
{...snapshot(), chunks: [snapshot().chunks[0], snapshot().chunks[0]]},
|
||||
{...snapshot(), loaded: [revision, revision]}, {...snapshot(), loaded: []},
|
||||
{...snapshot(), loaded: [{...revision, revision: randomUUID()}]},
|
||||
{...snapshot(), chunks: [{...snapshot().chunks[0], x: 1}]},
|
||||
{...snapshot(), chunks: [{...snapshot().chunks[0], token: 'secret'}]},
|
||||
{...snapshot(), loaded: [{...revision, x: 4}]},
|
||||
{...snapshot(), loaded: [{...revision, x: 0.5}]},
|
||||
{...snapshot(), loaded: [{...revision, extra: true}]},
|
||||
{...full, loaded: [...full.loaded, {...revision, x: 4}]},
|
||||
{...full, chunks: [...full.chunks, {...full.chunks[0], x: 4}]},
|
||||
]) rejected(() => ack(data));
|
||||
const edge: WorldSnapshot = {...snapshot(), center: {x: 0, z: 0}, loaded: [{...revision, x: 2}], chunks: []};
|
||||
rejected(() => ack(edge, 1));
|
||||
ack(edge, 2);
|
||||
ack({...snapshot(), loaded: [{...revision, revision: uuid.toUpperCase()}]});
|
||||
});
|
||||
|
||||
test('world_preview hints retain existing bounded JSON info_update validation', () => {
|
||||
const hint = {forward_session_id: uuid, bot_id: 1, context_generation: uuid, stream_id: uuid,
|
||||
seq: 1, emitted_at: Date.now(), data: {name: 'world_preview', value: {world_generation: uuid}}};
|
||||
validate_info_update(hint, DEFAULT_LIMITS);
|
||||
rejected(() => validate_info_update({...hint, data: {...hint.data, value: {world_generation: Buffer.alloc(16)}}}, DEFAULT_LIMITS));
|
||||
rejected(() => validate_info_update({...hint, bot_id: 0}, DEFAULT_LIMITS));
|
||||
});
|
||||
Reference in New Issue
Block a user