Files
node_server/tests/block_functions.test.mjs
2026-10-22 17:07:08 +08:00

259 lines
13 KiB
JavaScript

import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const names = ['destroy', 'place', 'use'].flatMap(action =>
['cursor', 'nearest', 'position', 'position_group'].map(target => `${action}_${target}_block`));
const operations = Object.fromEntries(await Promise.all(names.map(async name => {
const {init} = await tsImport(`../bot/function/block/${name}.ts`, import.meta.url);
return [name, () => new init()];
})));
const require = createRequire(import.meta.url);
const mf = createRequire(require.resolve('@wp2508/mineflayer'));
const {Vec3} = mf('vec3');
function fixture() {
const bot = new EventEmitter();
bot.entity = {position: new Vec3(0, 0, 0), eyeHeight: 1.62, height: 1.8, isValid: true};
bot._client = Object.assign(new EventEmitter(), {state: 'play'});
bot.heldItem = {name: 'stone'};
const block = {name: 'stone', type: 1, stateId: 1, position: new Vec3(2, 1, 0),
boundingBox: 'block', shapes: [[0, 0, 0, 1, 1, 1]], diggable: true};
const blocks = new Map([[block.position.toString(), block]]);
bot.blockAt = position => blocks.get(position.toString()) ?? null;
bot.findBlocks = options => [...blocks.values()].filter(options.matching).map(block => block.position);
bot.blockAtCursor = reach => {
bot.cursorReach = reach;
return {...block, face: 4, intersect: new Vec3(2, 1.25, 0.5)};
};
bot.calls = [];
bot.looks = [];
bot.lookAt = async point => { bot.looks.push(point); };
bot.canDigBlock = () => true;
bot.dig = async (...args) => { bot.calls.push(['dig', ...args]); };
bot.placeBlock = async (...args) => { bot.calls.push(['place', ...args]); };
bot._placeBlockWithOptions = async (...args) => { bot.calls.push(['place', ...args]); };
bot.activateBlock = async (...args) => { bot.calls.push(['use', ...args]); };
bot.stopDigging = () => { bot.stops = (bot.stops ?? 0) + 1; bot.targetDigBlock = null; };
return {bot, block, blocks};
}
test('position operations pass correct faces and await API completion', async () => {
const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]];
for (const action of ['destroy', 'place', 'use']) {
for (let face = 0; face < 6; face++) {
const {bot, block} = fixture();
assert.equal(await operations[`${action}_position_block`]().run(bot, [2, 1, 0, face]), true);
assert.equal(bot.calls[0][1], block);
const vector = bot.calls[0][action === 'destroy' ? 3 : 2];
assert.deepEqual([vector.x, vector.y, vector.z], faces[face]);
}
}
});
test('cursor operations preserve the hit face and surface offset', async () => {
for (const action of ['destroy', 'place', 'use']) {
const {bot} = fixture();
assert.equal(await operations[`${action}_cursor_block`]().run(bot, [4.5]), true);
assert.equal(bot.cursorReach, 4.5);
const call = bot.calls[0];
const vector = call[action === 'destroy' ? 3 : 2];
assert.deepEqual([vector.x, vector.y, vector.z], [-1, 0, 0]);
if (action === 'destroy') assert.equal(call[2], 'ignore');
if (action === 'place') {
assert.equal(call[3].forceLook, 'ignore');
assert.deepEqual([call[3].delta.x, call[3].delta.y, call[3].delta.z], [0, 0.25, 0.5]);
}
if (action === 'use') {
assert.equal(call[4], 'ignore');
assert.deepEqual([call[3].x, call[3].y, call[3].z], [0, 0.25, 0.5]);
}
}
});
test('nearest operations match partial type IDs and choose the closest loaded block', async () => {
for (const action of ['destroy', 'place', 'use']) {
const {bot, block, blocks} = fixture();
blocks.set('3,1,0', {...block, position: new Vec3(3, 1, 0)});
assert.equal(await operations[`${action}_nearest_block`]().run(bot, ['MINECRAFT:STO', 4.5, 1]), true);
assert.equal(bot.calls[0][1], block);
assert.equal(await operations[`${action}_nearest_block`]().run(bot, ['dirt', 4.5, 1]), false);
}
});
test('all block functions default to no aim and disable internal aiming', async () => {
for (const name of names) {
for (const flag of [[], [false], [true]]) {
const {bot} = fixture();
const args = name.includes('group') ? [[2, 1, 0, 4]] : name.includes('position') ? [2, 1, 0, 4]
: name.includes('nearest') ? ['stone', 4.5, 4] : [4.5];
bot.placeBlock = () => { assert.fail('public placement may aim internally'); };
assert.equal(await operations[name]().run(bot, [...args, ...flag]), true, name);
assert.equal(bot.looks.length, flag[0] === true ? 1 : 0, name);
if (flag[0]) assert.deepEqual(bot.looks[0], new Vec3(2, 1.5, 0.5));
const call = bot.calls[0];
if (name.startsWith('destroy')) assert.equal(call[2], 'ignore');
if (name.startsWith('place')) {
assert.equal(call[3].forceLook, 'ignore');
assert.equal(call[3].swingArm, 'right');
if (!name.includes('cursor')) assert.equal(call[3].delta, undefined);
}
if (name.startsWith('use')) assert.equal(call[4], 'ignore');
}
}
});
test('groups await a separate face-center look before every operation on all faces', async () => {
const faces = [[0, -1, 0], [0, 1, 0], [0, 0, -1], [0, 0, 1], [-1, 0, 0], [1, 0, 0]];
for (const action of ['destroy', 'place', 'use']) {
const {bot, block} = fixture();
const events = [];
bot.lookAt = async point => {
events.push('look');
await Promise.resolve();
bot.looks.push(point);
events.push('looked');
};
const record = async () => { events.push('operate'); };
bot.dig = bot._placeBlockWithOptions = bot.activateBlock = record;
assert.equal(await operations[`${action}_position_group_block`]().run(bot,
[faces.flatMap((_, face) => [2, 1, 0, face]), true]), true);
assert.deepEqual(events, faces.flatMap(() => ['look', 'looked', 'operate']));
assert.deepEqual(bot.looks, faces.map(face => block.position.offset(0.5, 0.5, 0.5)
.plus(new Vec3(...face).scaled(0.5))));
}
});
test('operations revalidate after aiming and clean up on cancellation or look failure', async () => {
for (const name of names) {
const changes = [
({bot}) => bot.emit('end'),
({bot}) => { bot.entity = {...bot.entity}; },
({bot}) => { bot.entity.position = new Vec3(100, 0, 0); },
({blocks}) => blocks.clear(),
({block, blocks}) => blocks.set(block.position.toString(), {...block, type: 2}),
({block, blocks}) => blocks.set(block.position.toString(), {...block, stateId: 2}),
() => { throw Error('look failed'); },
];
if (name.startsWith('place')) changes.push(({bot}) => { bot.heldItem = null; });
if (name.startsWith('destroy')) changes.push(({bot}) => { bot.canDigBlock = () => false; });
for (const change of changes) {
const context = fixture();
const {bot} = context;
bot.lookAt = async () => { change(context); };
const args = name.includes('group') ? [[2, 1, 0, 1]] : name.includes('position') ? [2, 1, 0, 1]
: name.includes('nearest') ? ['stone', 4.5, 1] : [4.5];
assert.equal(await operations[name]().run(bot, [...args, true]), false, name);
assert.equal(bot.calls.length, 0, name);
assert.equal(bot.listenerCount('end'), 0, name);
}
}
});
test('dig ownership covers pending explicit aim and is released on disconnect', async () => {
const {bot} = fixture();
let finishLook;
bot.lookAt = () => new Promise(resolve => { finishLook = resolve; });
const fn = operations.destroy_position_block();
const first = fn.run(bot, [2, 1, 0, 1, true]);
assert.equal(await fn.run(bot, [2, 1, 0, 1]), false);
bot.emit('end');
assert.equal(await first, false);
finishLook();
await Promise.resolve();
assert.equal(bot.calls.length, 0);
assert.equal(bot.stops, undefined);
assert.equal(await fn.run(bot, [2, 1, 0, 1]), true);
});
test('groups prevalidate every tuple, execute in order and stop on a failure', async () => {
for (const action of ['destroy', 'place', 'use']) {
const {bot, block, blocks} = fixture();
const second = {...block, position: new Vec3(3, 1, 0)};
blocks.set(second.position.toString(), second);
const fn = operations[`${action}_position_group_block`]();
assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 7]]), false);
assert.equal(bot.calls.length, 0);
assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), true);
assert.deepEqual(bot.calls.map(call => call[1].position.x), [2, 3]);
bot.calls.length = 0;
blocks.delete(second.position.toString());
assert.equal(await fn.run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), false);
assert.equal(bot.calls.length, 1);
}
});
test('block operations reject invalid parameters, missing targets and API errors', async () => {
for (const name of names) {
const {bot} = fixture();
const args = name.includes('group') ? [[2, 1, 0, 1]] : name.includes('position') ? [2, 1, 0, 1]
: name.includes('nearest') ? ['stone', 4.5, 1] : [4.5];
const fn = operations[name]();
for (const invalid of [[], null, [...args, 1], [...args, undefined], [...args, false, true]])
assert.equal(await fn.run(bot, invalid), false, name);
const fail = async () => { throw Error('failed'); };
bot.dig = bot.placeBlock = bot._placeBlockWithOptions = bot.activateBlock = fail;
assert.equal(await fn.run(bot, args), false, name);
}
const {bot, block} = fixture();
for (const args of [[2.5, 1, 0, 1], [2, 1, 0, 6], [20, 1, 0, 1]])
assert.equal(await operations.use_position_block().run(bot, args), false);
bot.heldItem = null;
assert.equal(await operations.place_position_block().run(bot, [2, 1, 0, 1]), false);
bot.canDigBlock = () => false;
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false);
block.name = 'air'; block.type = 0;
assert.equal(await operations.use_position_block().run(bot, [2, 1, 0, 1]), false);
});
test('owned digging stops on disconnect and releases operation listeners', async () => {
const {bot} = fixture();
bot.dig = async block => { bot.targetDigBlock = block; bot.emit('end', 'disconnected'); };
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false);
assert.equal(bot.stops, 1);
assert.equal(bot.listenerCount('end'), 0);
});
test('digging does not overlap and releases ownership after rejection', async () => {
const {bot} = fixture();
let release;
bot.dig = () => new Promise(resolve => { release = resolve; });
const first = operations.destroy_position_block().run(bot, [2, 1, 0, 1]);
for (let i = 0; i < 10; i++) await Promise.resolve();
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false);
release();
assert.equal(await first, true);
bot.dig = async () => { throw Error('rejected'); };
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), false);
bot.dig = async () => {};
assert.equal(await operations.destroy_position_block().run(bot, [2, 1, 0, 1]), true);
assert.equal(bot.listenerCount('end'), 0);
});
test('groups stop after disconnect or bot instance replacement', async () => {
for (const change of [bot => bot.emit('end'), bot => { bot.entity = {...bot.entity}; }]) {
const {bot, block, blocks} = fixture();
const second = {...block, position: new Vec3(3, 1, 0)};
blocks.set(second.position.toString(), second);
bot.activateBlock = async (...args) => { bot.calls.push(['use', ...args]); change(bot); };
assert.equal(await operations.use_position_group_block().run(bot, [[2, 1, 0, 1, 3, 1, 0, 1]]), false);
assert.equal(bot.calls.length, 1);
assert.equal(bot.listenerCount('end'), 0);
}
});
test('loader discovers all twelve block functions and valid metadata', async () => {
const {get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url);
const category = (await get_function_list()).find(item => item.path === 'block');
assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort());
for (const fn of category.function_list) {
const types = fn.name.includes('group') ? ['number-array'] : fn.name.includes('position') ? Array(4).fill('number')
: fn.name.includes('nearest') ? ['string', 'number', 'number'] : ['number'];
assert.deepEqual(fn.args.map(arg => arg.type), [...types, 'boolean']);
assert.equal(fn.args.at(-1).name, 'need_look');
assert.equal(fn.args.at(-1).default, false);
}
});