Compare commits

...

5 Commits

Author SHA1 Message Date
6df6dbcaf6
[plugin/flyctl] add .ascend() 2023-11-02 08:30:59 +08:00
a6f5d0062a
[plugin/control] add .jumpToHighest() 2023-11-02 08:30:11 +08:00
e22e27fd69
[utils] re-arch 2023-11-02 08:29:19 +08:00
d805e4dfae
[utils] add Task._waitDependent 2023-11-02 08:28:56 +08:00
f6aa131caa
[plugin/control] add .ladderAscend() 2023-11-01 13:32:05 +08:00
8 changed files with 399 additions and 75 deletions

View File

@ -57,6 +57,7 @@ async function main() {
await waitEvent(bot, 'inject_allowed');
bot.loadPlugin((await import('mineflayer-event-promise')).default);
bot.loadPlugin((await import('mineflayer-control')).default);
bot.loadPlugin((await import('mineflayer-fly-control')).default);
await bot.waitEvent('spawn');
async function loadReplContextModules(context) {
@ -72,12 +73,14 @@ async function main() {
return bot.players[args.owner];
};
context.debug = debug;
context.sc = {};
context.sc.pos = () => bot.entity.position;
context.sc.debug_enable = (module) => debug.enable(module);
context.sc.debug_disable = (module) => debug.disable(module);
context.sc.debug_mfc = () => debug.enable('mineflayer-control');
context.sc.debug_mff = () => debug.enable('mineflayer-fly-control');
context.sc.q = () => bot.quit();
context.sc.sleep = asyncSleep;
context.sc.tossHeld = () => bot.tossStack(bot.heldItem);
}
if (!args.noRepl) {
@ -89,6 +92,8 @@ async function main() {
terminal: true,
ignoreUndefined: true,
});
r.on('exit', () => bot.quit());
loadReplContextModules(r.context);
}
}

View File

@ -14,8 +14,7 @@
"mineflayer-event-promise": "file:plugin/event-promise",
"compass-utils": "file:utils",
"mineflayer-control": "file:plugin/control",
"mineflayer-pathfinder": "^2.4.5",
"prismarine-viewer": "^1.25.0",
"mineflayer-fly-control": "file:plugin/fly-control",
"yargs": "^17.7.2"
},
"engines": {

View File

@ -1,6 +1,7 @@
import debug from 'debug';
import { Queue, Task, isIterable } from 'compass-utils';
import { Vec3 } from 'vec3';
import assert from 'node:assert/strict';
const logger = debug('mineflayer-control');
// yaw = axis * Math.PI / 2
@ -27,10 +28,12 @@ export const AXIS_UNIT = {
3: new Vec3(1, 0, 0),
};
AXIS_UNIT['-Z'] = AXIS_UNIT['NORTH'] = AXIS_UNIT[0];
AXIS_UNIT['-X'] = AXIS_UNIT['WEST'] = AXIS_UNIT[1];
AXIS_UNIT['+Z'] = AXIS_UNIT['SOUTH'] = AXIS_UNIT[2];
AXIS_UNIT['+X'] = AXIS_UNIT['EAST'] = AXIS_UNIT[3];
export const AXIS_NAME = {
0: '-Z',
1: '-X',
2: '+Z',
3: '+X',
};
export const MOVE_LEVEL = {
WALK: 1,
@ -94,8 +97,16 @@ export class MovePathBlockedError extends Error {
constructor() { super('Move path is possiblely blocked.'); }
};
export class NotOnGroundError extends Error {
constructor() { super('bot is not on ground, cannot jump'); }
};
async function moveAxisTask(bot, task, axis_raw, target_raw, level) {
const axis = AXIS[axis_raw];
assert.equal(typeof axis, 'number', 'axis');
assert.ok(0 <= axis && axis <= 3, 'axis');
assert.equal(typeof level, 'number', 'level');
assert.ok(target_raw instanceof Vec3, 'target');
const stable_axis = "xz"[axis % 2];
const target = target_raw.clone();
adjustXZ(target);
@ -138,7 +149,7 @@ async function moveAxisTask(bot, task, axis_raw, target_raw, level) {
let time_used = 0, pos_queue = new Queue();
const TRACK_TICKS = 5;
pos_queue.push(pos.clone());
do {
while (true) {
await bot.waitForTicks(1);
task._interuptableHere();
@ -166,7 +177,7 @@ async function moveAxisTask(bot, task, axis_raw, target_raw, level) {
let pos5t = pos_queue.front();
if (pos.distanceSquared(pos5t) < Number.EPSILON) {
logger('moveAxisTask() position changed too little.');
logger(`moveAxisTask() position 5 ticks ago: ${pos5t}.`);
logger(`moveAxisTask() position ${TRACK_TICKS} ticks ago: ${pos5t}.`);
logger(`moveAxisTask() position now: ${pos}.`);
throw new MovePathBlockedError();
}
@ -175,7 +186,7 @@ async function moveAxisTask(bot, task, axis_raw, target_raw, level) {
delta.update(target.minus(pos));
remaining_dis = delta.dot(AXIS_UNIT[axis]);
if (Math.abs(remaining_dis) <= 0.5) {
logger('moveAxisTask() very close to target now.');
logger(`moveAxisTask() very close! remain: ${remaining_dis}.`);
pos.x = target.x;
pos.z = target.z;
bot.entity.velocity.x = 0;
@ -187,11 +198,63 @@ async function moveAxisTask(bot, task, axis_raw, target_raw, level) {
logger('moveAxisTask() went past target.');
throw new MoveInterferedError();
}
} while (true);
}
bot.clearControlStates();
task._ready(time_used);
}
async function ladderAscendTask(bot, task, target_y) {
assert.equal(typeof target_y, 'number', 'target_y');
bot.control.adjustXZ();
const start_pos = bot.entity.position.clone();
logger(`ladderAscendTask() initial position: ${start_pos}.`);
logger(`ladderAscendTask() target y: ${target_y}.`);
if (start_pos.y > target_y) {
throw new Error('Invalid Argument: target_y is smaller than current y.');
}
let controls = new ControlState('jump');
controls.apply(bot);
logger('ladderAscendTask() started.');
const TRACK_TICKS = 10;
let time_used = 0, last_y = start_pos.y;
while (true) {
await bot.waitForTicks(1);
task._interuptableHere();
time_used += 1;
const pos = bot.entity.position;
if (pos.xzDistanceTo(start_pos) > 1) { throw new MoveInterferedError(); }
bot.control.adjustXZ();
if (Math.abs(pos.y - target_y) < 0.2) {
logger('ladderAscendTask() reached.');
bot.clearControlStates();
pos.y = target_y;
break;
}
if (pos.y - target_y > 0.4) {
logger('ladderAscendTask() went past target.');
throw new MoveInterferedError();
}
if (time_used % TRACK_TICKS == 0) {
let now_y = pos.y;
if (Math.abs(now_y - last_y) < 0.3) {
logger(`ladderAscendTask() move too little in past ${TRACK_TICKS} ticks!`);
logger(`ladderAscendTask() now pos.y=${now_y}`);
logger(`ladderAscendTask() ${TRACK_TICKS} ticks ago pos.y=${last_y}`);
throw new MovePathBlockedError();
}
last_y = now_y;
}
}
task._ready(time_used);
}
export default function inject(bot) {
bot.control = {};
bot.control.getState = () => { return ControlState.from(bot.controlState); };
@ -213,6 +276,12 @@ export default function inject(bot) {
bot.control.jumpUp = async (axis_raw, time=5) => {
const axis = AXIS[axis_raw];
assert.ok(typeof axis == 'number');
assert.ok(0 <= axis && axis <= 3);
assert.ok(typeof time == 'number');
if (!bot.entity.onGround) {
throw new NotOnGroundError();
}
bot.control.adjustXZ();
await bot.look(axis * Math.PI / 2, 0, true);
let controls = new ControlState('forward', 'jump');
@ -230,6 +299,14 @@ export default function inject(bot) {
if (tactic == null) { tactic = {}; }
if (tactic.sprint == null) { tactic.sprint = dis > 3; }
if (tactic.speed == null) { tactic.speed = tactic.sprint ? .355 : .216; }
assert.ok(typeof axis == 'number');
assert.ok(0 <= axis && axis <= 3);
assert.ok(typeof dis == 'number');
assert.ok(typeof tactic.sprint == 'boolean');
assert.ok(typeof tactic.speed == 'number');
if (!bot.entity.onGround) {
throw new NotOnGroundError();
}
const axis = AXIS[axis_raw];
bot.control.adjustXZ();
@ -264,8 +341,61 @@ export default function inject(bot) {
}
bot.control.jump = async () => {
if (!bot.entity.onGround) {
throw new NotOnGroundError();
}
bot.setControlState('jump', true);
await bot.waitForTicks(1);
bot.setControlState('jump', false);
};
bot.control.jumpToHighest = () => {
let task = new Task();
queueMicrotask(async () => {
try {
task._start();
if (!bot.entity.onGround) {
throw new NotOnGroundError();
}
let controls = new ControlState('jump');
controls.apply(bot);
let time_used = 0;
while (true) {
await bot.waitForTicks(1);
task._interuptableHere();
time_used += 1;
if (time_used == 1) {
controls.jump = false;
controls.apply(bot);
}
if (bot.entity.velocity.y < 0) {
break;
}
}
task._ready(time_used);
} catch(err) {
bot.clearControlStates();
task._fail(err);
}
});
return task;
};
bot.control.ladderAscend = (target_y) => {
let task = new Task();
queueMicrotask(async () => {
try {
task._start();
await ladderAscendTask(bot, task, target_y);
} catch(err) {
bot.clearControlStates();
task._fail(err);
}
});
return task;
};
}

View File

@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import debug from 'debug';
import { Task } from 'compass-utils';
const logger = debug('mineflayer-fly-control');
export class ElytraNotEquippedError extends Error {
constructor() { super('Elytra is not equipped!'); }
}
export class InsufficientRocketError extends Error {
constructor(flight_requirement, flight_actual) {
super(`Expected ${flight_requirement} flight in total, got ${flight_actual}`);
this.flight_requirement = flight_requirement;
this.flight_actual = flight_actual;
}
};
export class AlreadyElytraFlyingError extends Error {
constructor() { super('Already elytra flying!'); }
}
export function fireworkFlight(firework_item) {
return firework_item?.nbt?.value?.Fireworks?.value?.Flight?.value ?? 1;
}
async function takeOffTask(bot, task) {
if (bot.entity.elytraFlying) { throw new AlreadyElytraFlyingError(); }
if (bot.entity.onGround) {
await task._waitDependent(bot.control.jumpToHighest());
task._interuptableHere();
}
await bot.elytraFly();
task._interuptableHere();
}
async function ascendTask(bot, task, flight, gracefulMode) {
assert.ok(typeof flight == 'number');
bot.control.adjustXZ();
await bot.look(0, Math.PI / 2, true);
task._interuptableHere();
if (!bot.entity.elytraFlying) { await takeOffTask(bot, task); }
await bot.waitForTicks(1);
task._interuptableHere();
function gracefulModePredicate() {
return bot.entity.velocity.y >= 0.01;
}
function fastModePredicate() {
return bot.fireworkRocketDuration > 0;
}
const predicate = gracefulMode ? gracefulModePredicate : fastModePredicate;
let flight_pre_rocket = fireworkFlight(bot.heldItem);
for (let i = 0; i < flight; i += flight_pre_rocket) {
bot.activateItem();
do {
await bot.waitForTicks(Math.max(1, bot.fireworkRocketDuration));
task._interuptableHere();
} while (predicate());
}
}
export default function inject(bot) {
const firework_id = bot.registry.itemsByName.firework_rocket.id;
const elytra_id = bot.registry.itemsByName.elytra.id;
const elytra_slot = bot.getEquipmentDestSlot('torso');
bot.flyctl = {};
bot.flyctl.skipValidation = false;
function beforeFlyValidation(flight_requirement = 0) {
assert.ok(typeof flight_requirement == 'number');
assert.ok(flight_requirement >= 0);
if (bot.flyctl.skipValidation) {
logger('beforeFlyValidation() skipped.');
return;
}
logger(`beforeFlyValidation() elytra slot: ${elytra_slot}`);
let elytra_slot_item = bot.inventory.slots[elytra_slot]?.type;
if (elytra_slot_item != elytra_id) {
logger(`beforeFlyValidation() failed: elytra slot found ${elytra_slot_item}.`);
logger(`beforeFlyValidation() expected ${elytra_id}.`);
throw new ElytraNotEquippedError();
}
if (flight_requirement > 0) {
let rocket_item = bot.heldItem;
if (rocket_item?.type != firework_id) {
logger('beforeFlyValidation() failed: holding is not rocket.');
logger(`beforeFlyValidation() found ${rocket_item?.type} expected ${firework_id} .`);
throw new InsufficientRocketError(flight_requirement, 0);
}
let flight_sum = rocket_item.count * fireworkFlight(rocket_item);
if (flight_sum < flight_requirement) {
throw new InsufficientRocketError(flight_requirement, flight_sum);
}
}
logger('beforeFlyValidation() passed.');
}
bot.flyctl.prepare = async () => {
await bot.equip(elytra_id, 'torso');
await bot.equip(firework_id);
};
bot.flyctl.ascend = async (flight = 1, gracefulMode = true) => {
let task = new Task();
queueMicrotask(async () => {
try {
beforeFlyValidation(flight);
task._ready(await ascendTask(bot, task, flight, gracefulMode));
} catch(err) {
task._fail(err);
}
});
return task;
};
bot.flyctl.gracefulAscend = (flight = 1) => {
return bot.flyctl.ascend(flight, true);
};
bot.flyctl.fastAscend = (flight = 1) => {
return bot.flyctl.ascend(flight, false);
};
bot.flyctl.takeOff = () => {
let task = new Task();
queueMicrotask(async () => {
try {
beforeFlyValidation(flight);
task._ready(await takeOffTask(bot, task));
} catch(err) {
task._fail(err);
}
});
return task;
};
}

View File

@ -0,0 +1,11 @@
{
"name": "mineflayer-fly-control",
"description": "High-level & handy API for mineflayer bot elytra flying.",
"type": "module",
"main": "index.mjs",
"dependencies": {
"debug": "^4.3.4",
"vec3": "^0.1.8",
"compass-utils": "file:../../utils"
}
}

View File

@ -1,63 +1,6 @@
import fs from 'node:fs/promises';
// Reads JSON data from file, returns null if file not found or has other errors.
export async function readJsonFile(path) {
try {
const data = await fs.readFile(path, 'utf8');
return JSON.parse(data);
} catch (error) {
return null;
}
}
// Write JSON data into a file.
export function writeJsonFile(path, data) {
const json_string = JSON.stringify(data);
return fs.writeFile(path, json_string, 'utf8');
}
// Parse format "profile@host:port", port can be undefined.
export function parseLogin(url) {
const [profile_host, port] = url.split(':');
const [profile, host] = profile_host.split('@');
return [profile, host, port ? parseInt(port) : undefined];
}
// Returns a promise, wait unitl the EventEmitter emits certian event next time.
export function waitEvent(em, event) {
return new Promise((resolve, _reject) => {
em.once(event, resolve);
});
}
export function asyncSleep(t) {
return new Promise((resolve, _reject) => {
setTimeout(resolve, t);
});
}
export function asyncTimeout(t) {
return new Promise((_resolve, reject) => {
setTimeout(reject, t);
});
}
export function promiseTimeout(p, t) {
return Promise.race([p, asyncTimeout(t)]);
}
export function yieldTask() {
return new Promise((resolve, _reject) => {
queueMicrotask(resolve);
});
}
// Checks wheather an object is iterable.
export function isIterable(obj) {
if (obj == null) { return false; }
return typeof obj[Symbol.iterator] == 'function';
}
export { readJsonFile, writeJsonFile, parseLogin, waitEvent,
asyncSleep, asyncTimeout, promiseTimeout, yieldTask,
isIterable} from './misc.mjs';
export { Queue, QueueEmptyError } from './queue.mjs';
export { AsyncLock } from './async-lock.mjs';
export { Task, TaskInteruptedError } from './task.mjs';

59
utils/misc.mjs Normal file
View File

@ -0,0 +1,59 @@
import fs from 'node:fs/promises';
// Reads JSON data from file, returns null if file not found or has other errors.
export async function readJsonFile(path) {
try {
const data = await fs.readFile(path, 'utf8');
return JSON.parse(data);
} catch (error) {
return null;
}
}
// Write JSON data into a file.
export function writeJsonFile(path, data) {
const json_string = JSON.stringify(data);
return fs.writeFile(path, json_string, 'utf8');
}
// Parse format "profile@host:port", port can be undefined.
export function parseLogin(url) {
const [profile_host, port] = url.split(':');
const [profile, host] = profile_host.split('@');
return [profile, host, port ? parseInt(port) : undefined];
}
// Returns a promise, wait unitl the EventEmitter emits certian event next time.
export function waitEvent(em, event) {
return new Promise((resolve, _reject) => {
em.once(event, resolve);
});
}
export function asyncSleep(t) {
return new Promise((resolve, _reject) => {
setTimeout(resolve, t);
});
}
export function asyncTimeout(t) {
return new Promise((_resolve, reject) => {
setTimeout(reject, t);
});
}
export function promiseTimeout(p, t) {
return Promise.race([p, asyncTimeout(t)]);
}
export function yieldTask() {
return new Promise((resolve, _reject) => {
queueMicrotask(resolve);
});
}
// Checks wheather an object is iterable.
export function isIterable(obj) {
if (obj == null) { return false; }
return typeof obj[Symbol.iterator] == 'function';
}

View File

@ -1,3 +1,5 @@
import assert from 'node:assert/strict';
export class TaskInteruptedError extends Error {
constructor() { super('Task has been interupted.'); }
};
@ -8,6 +10,7 @@ export class Task {
#promise;
#resolve;
#reject;
#dependent_tasks
constructor() {
this.id = ++task_id;
@ -18,10 +21,15 @@ export class Task {
this.#resolve = resolve;
this.#reject = reject;
});
this.#dependent_tasks = null;
}
isDone() {
return this.#promise == null;
}
_ready(result) {
if (this.#promise == null) { return; }
if (this.isDone()) { return; }
this.result = result;
this.status = Task.STATUS.ready;
this.#resolve(this.result);
@ -29,7 +37,7 @@ export class Task {
}
_fail(error) {
if (this.#promise == null) { return; }
if (this.isDone()) { return; }
this.error = error;
this.status = Task.STATUS.failed;
this.#reject(this.error);
@ -38,11 +46,16 @@ export class Task {
_start() {
if (this.status != Task.STATUS.pending) {
throw new Error('Task has already left pending stage');
throw new Error('_start() called twice');
}
this.status = Task.STATUS.running;
}
finally() {
if (this.isDone()) { return Promise.resolve(); }
return this.#promise.finally();
}
interupt() {
if (this.status == Task.STATUS.pending) {
this._confirmInterupt();
@ -51,14 +64,32 @@ export class Task {
if (this.#promise == null) { return Promise.resolve(); }
this.status = Task.STATUS.interupting;
if (this.#dependent_tasks) {
for (let dependent of this.#dependent_tasks) {
dependent.interupt();
}
}
return this.#promise.finally();
}
async _waitDependent(dependent) {
assert.equal(this.#dependent_tasks, null);
if (dependent instanceof Task) { this.#dependent_tasks = [dependent]; }
else { this.#dependent_tasks = dependent; }
await Promise.allSettled(this.#dependent_tasks.map(t => t.finally()));
this.#dependent_tasks = null;
if (dependent instanceof Task) {
return await dependent.get();
}
return dependent.map(async t => await t.get());
}
_shouldInterupt() {
return this.status == Task.STATUS.interupting;
}
_confirmInterupt() {
assert.equal(this.#dependent_tasks, null);
this.status = Task.STATUS.interupted;
this.error = new TaskInteruptedError();
this.#reject(this.error);