292 lines
14 KiB
JavaScript
292 lines
14 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {EventEmitter} from 'node:events';
|
|
import {createRequire} from 'node:module';
|
|
import test from 'node:test';
|
|
import {register} from 'tsx/esm/api';
|
|
|
|
const loader = register({namespace: 'bot-functions-test'});
|
|
const tsImport = loader.import;
|
|
test.after(() => loader.unregister());
|
|
|
|
const names = ['chat', 'jump', 'squat_once', 'squat_continue_switch', 'use_direction_key', 'look_at', 'stop_current_function'];
|
|
const operations = Object.fromEntries(await Promise.all(names.map(async name => {
|
|
const {init} = await tsImport(`../bot/function/bot/${name}.ts`, import.meta.url);
|
|
return [name, () => new init()];
|
|
})));
|
|
const require = createRequire(import.meta.url);
|
|
const {Vec3} = createRequire(require.resolve('@wp2508/mineflayer'))('vec3');
|
|
|
|
function fixture() {
|
|
const bot = new EventEmitter();
|
|
bot.entity = {position: new Vec3(1, 2, 3), eyeHeight: 1.62, onGround: true, isValid: true};
|
|
bot._client = Object.assign(new EventEmitter(), {state: 'play'});
|
|
bot.controlState = {};
|
|
bot.calls = [];
|
|
bot.getControlState = key => bot.controlState[key] === true;
|
|
bot.setControlState = (key, enabled) => {
|
|
bot.calls.push([key, enabled]);
|
|
bot.controlState[key] = enabled;
|
|
};
|
|
bot.chat = message => { bot.message = message; };
|
|
bot.lookAt = async target => { bot.target = target; };
|
|
return bot;
|
|
}
|
|
|
|
function clean(bot, client = bot._client) {
|
|
for (const event of ['end', 'error', 'physicsTick']) assert.equal(bot.listenerCount(event), 0, event);
|
|
for (const event of ['end', 'error']) assert.equal(client.listenerCount(event), 0, `client ${event}`);
|
|
}
|
|
|
|
// Track pending timers as well as using the test runner's deterministic clock.
|
|
function clock(t) {
|
|
t.mock.timers.enable({apis: ['setTimeout']});
|
|
const schedule = globalThis.setTimeout;
|
|
const clear = globalThis.clearTimeout;
|
|
const pending = new Set();
|
|
t.mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => {
|
|
const timer = schedule(() => { pending.delete(timer); callback(...args); }, delay);
|
|
pending.add(timer);
|
|
return timer;
|
|
});
|
|
t.mock.method(globalThis, 'clearTimeout', timer => { pending.delete(timer); clear(timer); });
|
|
return {tick: ms => t.mock.timers.tick(ms), clean: () => assert.equal(pending.size, 0, 'pending timers')};
|
|
}
|
|
|
|
test('chat preserves exact content and validates its single argument without server acknowledgement', async () => {
|
|
const bot = fixture();
|
|
for (const message of [' hello ', '/help', 'x'.repeat(256), '你好']) {
|
|
assert.equal(await operations.chat().run(bot, [message]), true);
|
|
assert.equal(bot.message, message);
|
|
clean(bot);
|
|
}
|
|
for (const args of [null, [], ['a', 'b'], [1], [''], [' \t '], ['x'.repeat(257)], ['a\rb'], ['a\nb'], ['a\0b']]) {
|
|
delete bot.message;
|
|
assert.equal(await operations.chat().run(bot, args), false);
|
|
assert.equal(bot.message, undefined);
|
|
}
|
|
bot.chat = () => { throw Error('send failed'); };
|
|
assert.equal(await operations.chat().run(bot, ['hello']), false);
|
|
clean(bot);
|
|
});
|
|
|
|
test('pulses hold their own key for one physicsTick and listen before enabling', async t => {
|
|
const timer = clock(t);
|
|
for (const [name, key] of [['jump', 'jump'], ['squat_once', 'sneak']]) {
|
|
const bot = fixture();
|
|
bot.controlState.forward = true;
|
|
const pending = operations[name]().run(bot, []);
|
|
assert.equal(bot.controlState[key], true);
|
|
timer.tick(999);
|
|
assert.equal(bot.controlState[key], true);
|
|
bot.emit('physicsTick');
|
|
assert.equal(await pending, true);
|
|
assert.deepEqual(bot.calls, [[key, true], [key, false]]);
|
|
assert.equal(bot.controlState.forward, true);
|
|
clean(bot);
|
|
timer.clean();
|
|
|
|
const synchronous = fixture();
|
|
const set = synchronous.setControlState;
|
|
synchronous.setControlState = (control, enabled) => {
|
|
set(control, enabled);
|
|
if (enabled) synchronous.emit('physicsTick');
|
|
};
|
|
assert.equal(await operations[name]().run(synchronous, []), true);
|
|
assert.equal(synchronous.controlState[key], false);
|
|
clean(synchronous);
|
|
timer.clean();
|
|
}
|
|
});
|
|
|
|
test('pulses time out after one second and reject held keys, airborne jump and extra args', async t => {
|
|
const timer = clock(t);
|
|
for (const [name, key] of [['jump', 'jump'], ['squat_once', 'sneak']]) {
|
|
const bot = fixture();
|
|
const pending = operations[name]().run(bot, []);
|
|
timer.tick(1000);
|
|
assert.equal(await pending, false);
|
|
assert.equal(bot.controlState[key], false);
|
|
clean(bot);
|
|
timer.clean();
|
|
bot.controlState[key] = true;
|
|
bot.calls = [];
|
|
assert.equal(await operations[name]().run(bot, []), false);
|
|
for (const args of [null, [false], [1]]) assert.equal(await operations[name]().run(bot, args), false);
|
|
assert.deepEqual(bot.calls, []);
|
|
clean(bot);
|
|
}
|
|
const bot = fixture();
|
|
bot.entity.onGround = false;
|
|
assert.equal(await operations.jump().run(bot, []), false);
|
|
assert.deepEqual(bot.calls, []);
|
|
timer.clean();
|
|
});
|
|
|
|
test('persistent sneak is explicit and idempotent and survives a concurrent squat pulse', async t => {
|
|
const timer = clock(t);
|
|
const bot = fixture();
|
|
const fn = operations.squat_continue_switch();
|
|
for (const enabled of [true, true, false, false]) {
|
|
assert.equal(await fn.run(bot, [enabled]), true);
|
|
assert.equal(bot.controlState.sneak, enabled);
|
|
clean(bot);
|
|
}
|
|
for (const args of [null, [], [0], ['true'], [undefined], [true, false]])
|
|
assert.equal(await fn.run(bot, args), false);
|
|
const pending = operations.squat_once().run(bot, []);
|
|
assert.equal(await fn.run(bot, [true]), true);
|
|
bot.emit('physicsTick');
|
|
assert.equal(await pending, true);
|
|
assert.equal(bot.controlState.sneak, true);
|
|
assert.equal(await operations.squat_once().run(bot, []), false);
|
|
assert.equal(bot.controlState.sneak, true);
|
|
assert.equal(await fn.run(bot, [false]), true);
|
|
clean(bot);
|
|
timer.clean();
|
|
});
|
|
|
|
test('directions use exact millisecond durations, reject held keys and leave unrelated keys alone', async t => {
|
|
const timer = clock(t);
|
|
for (const direction of ['forward', 'back', 'left', 'right']) {
|
|
for (const duration of [1, 5000]) {
|
|
const bot = fixture();
|
|
bot.controlState.sneak = true;
|
|
const pending = operations.use_direction_key().run(bot, [direction, duration]);
|
|
assert.equal(bot.controlState[direction], true);
|
|
bot.emit('physicsTick');
|
|
timer.tick(duration - 1);
|
|
assert.equal(bot.controlState[direction], true);
|
|
assert.equal(await operations.use_direction_key().run(bot, [direction, 1]), false);
|
|
timer.tick(1);
|
|
assert.equal(await pending, true);
|
|
assert.deepEqual(bot.calls, [[direction, true], [direction, false]]);
|
|
assert.equal(bot.controlState.sneak, true);
|
|
clean(bot);
|
|
timer.clean();
|
|
}
|
|
}
|
|
const bot = fixture();
|
|
for (const args of [null, [], ['forward'], ['forward', 1, 2], ...['jump', 'sneak', 'Forward', '', 1].map(key => [key, 1]),
|
|
...[0, -1, 5001, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '5', null].map(ms => ['forward', ms])])
|
|
assert.equal(await operations.use_direction_key().run(bot, args), false);
|
|
assert.deepEqual(bot.calls, []);
|
|
clean(bot);
|
|
timer.clean();
|
|
});
|
|
|
|
test('owned controls release on end, error, identity change, disconnect and synchronous failure', async t => {
|
|
const timer = clock(t);
|
|
for (const [name, key, args] of [['jump', 'jump', []], ['squat_once', 'sneak', []], ['use_direction_key', 'left', ['left', 20]]]) {
|
|
for (const reason of ['end', 'error', 'client-end', 'client-error', 'self', 'client', 'disconnect', 'throw', 'tick-throw']) {
|
|
const bot = fixture();
|
|
const client = bot._client;
|
|
if (reason === 'throw' || reason === 'tick-throw') {
|
|
const set = bot.setControlState;
|
|
bot.setControlState = (control, enabled) => {
|
|
set(control, enabled);
|
|
if (enabled) {
|
|
if (reason === 'tick-throw') bot.emit('physicsTick');
|
|
throw Error('failed');
|
|
}
|
|
};
|
|
}
|
|
const pending = operations[name]().run(bot, args);
|
|
if (reason === 'end' || reason === 'error') bot.emit(reason, Error('closed'));
|
|
if (reason.startsWith('client-')) client.emit(reason.slice(7), Error('closed'));
|
|
if (reason === 'self') bot.entity = {...bot.entity};
|
|
if (reason === 'client') bot._client = Object.assign(new EventEmitter(), {state: 'play'});
|
|
if (reason === 'disconnect') client.state = 'disconnected';
|
|
if (['self', 'client', 'disconnect'].includes(reason)) {
|
|
bot.emit('physicsTick');
|
|
timer.tick(20);
|
|
}
|
|
assert.equal(await pending, false, `${name}: ${reason}`);
|
|
assert.equal(bot.controlState[key], false);
|
|
clean(bot, client);
|
|
timer.clean();
|
|
}
|
|
}
|
|
const bot = fixture();
|
|
bot.setControlState = () => { throw Error('API failure'); };
|
|
assert.equal(await operations.squat_continue_switch().run(bot, [true]), false);
|
|
clean(bot);
|
|
timer.clean();
|
|
});
|
|
|
|
test('look_at constructs a vector from position, accepts decimals and rejects invalid or eye-coincident targets', async t => {
|
|
const timer = clock(t);
|
|
const bot = fixture();
|
|
assert.equal(await operations.look_at().run(bot, [-1.5, 4.25, 8.75]), true);
|
|
assert.ok(bot.target instanceof Vec3);
|
|
assert.deepEqual(bot.target, new Vec3(-1.5, 4.25, 8.75));
|
|
assert.deepEqual(bot.entity.position, new Vec3(1, 2, 3));
|
|
for (const args of [null, [], [1, 2], [1, 2, 3, 4], ['1', 2, 3], [Infinity, 2, 3], [1, NaN, 3],
|
|
[1, 2, undefined], [1, 2 + bot.entity.eyeHeight, 3]]) {
|
|
delete bot.target;
|
|
assert.equal(await operations.look_at().run(bot, args), false);
|
|
assert.equal(bot.target, undefined);
|
|
clean(bot);
|
|
timer.clean();
|
|
}
|
|
});
|
|
|
|
test('look_at bounds waits and contains late rejections, synchronous errors and session changes', async t => {
|
|
const timer = clock(t);
|
|
for (const reason of ['timeout', 'end', 'client-end', 'error', 'self', 'client', 'disconnect', 'invalid', 'throw', 'reject']) {
|
|
const bot = fixture();
|
|
const client = bot._client;
|
|
let resolveLook, rejectLook;
|
|
bot.lookAt = () => new Promise((resolve, reject) => { resolveLook = resolve; rejectLook = reject; });
|
|
if (reason === 'throw') bot.lookAt = () => { throw Error('look failed'); };
|
|
if (reason === 'reject') bot.lookAt = () => Promise.reject(Error('look failed'));
|
|
const pending = operations.look_at().run(bot, [1.5, 2.5, 3.5]);
|
|
if (reason === 'timeout') timer.tick(5000);
|
|
if (reason === 'end' || reason === 'error') bot.emit(reason, Error('closed'));
|
|
if (reason === 'client-end') client.emit('end');
|
|
if (reason === 'self') bot.entity = {...bot.entity};
|
|
if (reason === 'client') bot._client = Object.assign(new EventEmitter(), {state: 'play'});
|
|
if (reason === 'disconnect') client.state = 'disconnected';
|
|
if (reason === 'invalid') bot.entity.isValid = false;
|
|
if (['self', 'client', 'disconnect', 'invalid'].includes(reason)) resolveLook();
|
|
assert.equal(await pending, false, reason);
|
|
clean(bot, client);
|
|
timer.clean();
|
|
rejectLook?.(Error('late failure'));
|
|
await Promise.resolve();
|
|
}
|
|
});
|
|
|
|
test('all operations reject stale sessions before side effects', async () => {
|
|
for (const [name, args] of [['chat', ['hello']], ['jump', []], ['squat_once', []],
|
|
['squat_continue_switch', [true]], ['use_direction_key', ['forward', 1]], ['look_at', [1, 2, 3]]]) {
|
|
for (const change of [bot => { bot.entity = null; }, bot => { bot.entity.isValid = false; },
|
|
bot => { bot._client.state = 'disconnected'; }]) {
|
|
const bot = fixture();
|
|
change(bot);
|
|
assert.equal(await operations[name]().run(bot, args), false, name);
|
|
assert.deepEqual(bot.calls, []);
|
|
assert.equal(bot.message, undefined);
|
|
assert.equal(bot.target, undefined);
|
|
clean(bot);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('dynamic discovery publishes all bot operations and their exact positional 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 === 'bot');
|
|
assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort());
|
|
const expected = {stop_current_function: [], chat: [['message', 'string']], jump: [], squat_once: [], squat_continue_switch: [['enabled', 'boolean']],
|
|
use_direction_key: [['direction', 'string'], ['duration_ms', 'number']], look_at: [['x', 'number'], ['y', 'number'], ['z', 'number']]};
|
|
for (const fn of category.function_list) {
|
|
assert.equal(fn.i18n.lang, 'zh-CN');
|
|
assert.ok(fn.i18n.name && fn.i18n.description);
|
|
assert.deepEqual(fn.args.map(arg => [arg.name, arg.type]), expected[fn.name]);
|
|
for (const arg of fn.args) {
|
|
assert.equal(arg.i18n.lang, 'zh-CN');
|
|
assert.ok(arg.i18n.name && arg.i18n.description);
|
|
assert.equal(Object.hasOwn(arg, 'default'), false);
|
|
}
|
|
}
|
|
});
|