/* eslint-env mocha */ const mineflayer = require('mineflayer') const { goals, pathfinder, Movements } = require('mineflayer-pathfinder') const { Vec3 } = require('vec3') const mc = require('minecraft-protocol') const assert = require('assert') const { v4: uuidv4 } = require('uuid') const PEntity = require('prismarine-entity') const { once, on } = require('events') const { Schematic } = require('prismarine-schematic') const { promises: fs } = require('fs') const path = require('path') const Physics = require('../lib/physics') const Version = '1.16.5' const ServerPort = 25567 /** * Returns a flat bedrock chunk with a single gold block in it. * @param {string} Version version * @returns {import('prismarine-chunk').Chunk} */ function flatMap (Version) { const targetBlock = new Vec3(12, 1, 8) // a gold block away from the spawn position const Block = require('prismarine-block')(Version) const Chunk = require('prismarine-chunk')(Version) const mcData = require('minecraft-data')(Version) const chunk = new Chunk() chunk.initialize((x, y, z) => { if (targetBlock.x === x && targetBlock.y === y && targetBlock.z === z) { return new Block(mcData.blocksByName.gold_block.id, 1, 0) } return y === 0 ? new Block(mcData.blocksByName.bedrock.id, 1, 0) : new Block(mcData.blocksByName.air.id, 1, 0) // Bedrock floor }) return chunk } /** * Reads the schematic parkour1.schem and returns a chunk containing the schematic content. * @param {string} Version version to be used * @returns {Promise} */ async function parkourMap (Version) { const pwd = path.join(__dirname, './schematics/parkour1.schem') const readSchem = await Schematic.read(await fs.readFile(pwd), '1.18.2') const Block = require('prismarine-block')(Version) const Chunk = require('prismarine-chunk')(Version) const mcData = require('minecraft-data')(Version) const chunk = new Chunk() chunk.initialize((x, y, z) => { const block = readSchem.getBlock(new Vec3(x, y, z)) if (block.name === 'air') return null // Different versions off schematic are not compatible with each other. Assumes block names between versions stay the same. const blockVersion = mcData.blocksByName[block.name] if (!blockVersion) return null return new Block(blockVersion.id, 1, 0) }) return chunk } function generateChunkPacket (chunk) { const lights = chunk.dumpLight() return { x: 0, z: 0, groundUp: true, biomes: chunk.dumpBiomes !== undefined ? chunk.dumpBiomes() : undefined, heightmaps: { type: 'compound', name: '', value: { MOTION_BLOCKING: { type: 'longArray', value: new Array(36).fill([0, 0]) } } }, // send fake heightmap bitMap: chunk.getMask(), chunkData: chunk.dump(), blockEntities: [], trustEdges: false, skyLightMask: lights?.skyLightMask, blockLightMask: lights?.blockLightMask, emptySkyLightMask: lights?.emptySkyLightMask, emptyBlockLightMask: lights?.emptyBlockLightMask, skyLight: lights?.skyLight, blockLight: lights?.blockLight } } /** * Create a new 1.16 server and handle when clients connect. * @param {import('minecraft-protocol').Server} server * @param {import('vec3').Vec3} spawnPos * @param {string} Version * @param {boolean} useLoginPacket * @returns {Promise} */ async function newServer (server, chunk, spawnPos, Version, useLoginPacket) { const mcData = require('minecraft-data')(Version) server = mc.createServer({ 'online-mode': false, version: Version, // 25565 - local server, 25566 - proxy server port: ServerPort }) server.on('login', (client) => { let loginPacket if (useLoginPacket) { loginPacket = mcData.loginPacket } else { loginPacket = { entityId: 0, levelType: 'fogetaboutit', gameMode: 0, previousGameMode: 255, worldNames: ['minecraft:overworld'], dimension: 0, worldName: 'minecraft:overworld', hashedSeed: [0, 0], difficulty: 0, maxPlayers: 20, reducedDebugInfo: 1, enableRespawnScreen: true } } client.write('login', loginPacket) client.write('map_chunk', generateChunkPacket(chunk)) client.write('position', { x: spawnPos.x, y: spawnPos.y, z: spawnPos.z, yaw: 0, pitch: 0, flags: 0x00 }) }) await once(server, 'listening') return server } function add1x2Weight (entityIntersections, posX, posY, posZ, weight = 1) { entityIntersections[`${posX},${posY},${posZ}`] = entityIntersections[`${posX},${posY},${posZ}`] ?? 0 entityIntersections[`${posX},${posY + 1},${posZ}`] = entityIntersections[`${posX},${posY + 1},${posZ}`] ?? 0 entityIntersections[`${posX},${posY},${posZ}`] += weight entityIntersections[`${posX},${posY + 1},${posZ}`] += weight } describe('pathfinder Goals', function () { const mcData = require('minecraft-data')(Version) const targetBlock = new Vec3(12, 1, 8) // a gold block away from the spawn position const spawnPos = new Vec3(8.5, 1, 8.5) // Center of the chunk & center of the block /** @type { import('mineflayer').Bot & { pathfinder: import('mineflayer-pathfinder').Pathfinder }} */ let bot /** @type { import('minecraft-protocol').Server } */ let server before(async () => { const chunk = flatMap(Version) server = await newServer(server, chunk, spawnPos, Version, true) bot = mineflayer.createBot({ username: 'player', version: Version, port: ServerPort }) await once(bot, 'chunkColumnLoad') }) after(() => { bot.end() bot = null server.close() }) describe('Goals', () => { beforeEach(() => { bot.entity.position = spawnPos.clone() }) it('GoalBlock', () => { const goal = new goals.GoalBlock(targetBlock.x, targetBlock.y, targetBlock.z) assert.ok(!goal.isEnd(bot.entity.position)) bot.entity.position = targetBlock.clone() assert.ok(goal.isEnd(bot.entity.position)) }) it('GoalNear', () => { const goal = new goals.GoalNear(targetBlock.x, targetBlock.y, targetBlock.z, 1) assert.ok(!goal.isEnd(bot.entity.position)) bot.entity.position = targetBlock.offset(1, 0, 0) assert.ok(goal.isEnd(bot.entity.position)) }) it('GoalXZ', () => { const goal = new goals.GoalXZ(targetBlock.x, targetBlock.z) assert.ok(!goal.isEnd(bot.entity.position)) bot.entity.position = targetBlock.offset(0, 1, 0) assert.ok(goal.isEnd(bot.entity.position)) }) it('GoalNearXZ', () => { const goal = new goals.GoalNearXZ(targetBlock.x, targetBlock.z, 1) assert.ok(!goal.isEnd(bot.entity.position)) bot.entity.position = targetBlock.offset(1, 0, 0) assert.ok(goal.isEnd(bot.entity.position)) }) it('GoalY', () => { const goal = new goals.GoalY(targetBlock.y + 1) assert.ok(!goal.isEnd(bot.entity.position)) bot.entity.position = targetBlock.offset(0, 1, 0) assert.ok(goal.isEnd(bot.entity.position)) }) it('GoalGetToBlock', () => { const goal = new goals.GoalGetToBlock(targetBlock.x, targetBlock.y, targetBlock.z) assert.ok(!goal.isEnd(bot.entity.position)) bot.entity.position = targetBlock.offset(1, 0, 0) assert.ok(goal.isEnd(bot.entity.position)) }) it('GoalCompositeAny', () => { const targetBlock2 = new Vec3(10, 1, 0) const goal1 = new goals.GoalBlock(targetBlock.x, targetBlock.y, targetBlock.z) const goal2 = new goals.GoalBlock(targetBlock2.x, targetBlock2.y, targetBlock2.z) const goalComposite = new goals.GoalCompositeAny() goalComposite.goals = [goal1, goal2] assert.ok(!goalComposite.isEnd(bot.entity.position)) bot.entity.position = targetBlock.clone() assert.ok(goalComposite.isEnd(bot.entity.position)) // target block 1 bot.entity.position = targetBlock2.clone() assert.ok(goalComposite.isEnd(bot.entity.position)) // target block 2 }) it('GoalCompositeAll', () => { const targetBlock = new Vec3(2, 1, 0) const block2 = new Vec3(3, 1, 0) const goal1 = new goals.GoalBlock(targetBlock.x, targetBlock.y, targetBlock.z) const goal2 = new goals.GoalNear(block2.x, block2.y, block2.z, 2) const goalComposite = new goals.GoalCompositeAll() goalComposite.goals = [goal1, goal2] assert.ok(!goalComposite.isEnd(bot.entity.position)) bot.entity.position = targetBlock.offset(0, 0, 0) assert.ok(goalComposite.isEnd(bot.entity.position)) }) it('GoalInvert', () => { const goalBlock = new goals.GoalBlock(targetBlock.x, targetBlock.y, targetBlock.z) const goal = new goals.GoalInvert(goalBlock) bot.entity.position = targetBlock.clone() assert.ok(!goal.isEnd(bot.entity.position)) bot.entity.position = new Vec3(0, 1, 0) assert.ok(goal.isEnd(bot.entity.position)) }) it('GoalPlaceBlock', () => { const placeTarget = targetBlock.offset(0, 1, 0) const goal = new goals.GoalPlaceBlock(placeTarget, bot.world, {}) bot.entity.position = targetBlock.offset(-5, 0, 0) // to far away to reach assert.ok(!goal.isEnd(bot.entity.position.floored())) bot.entity.position = targetBlock.offset(-2, 0, 0) assert.ok(goal.isEnd(bot.entity.position.floored())) }) it('GoalLookAtBlock', () => { const breakTarget = targetBlock.clone() // should be a gold block or any other block thats dig able const goal = new goals.GoalLookAtBlock(breakTarget, bot.world, { reach: 3 }) assert.ok(!goal.isEnd(bot.entity.position.floored())) bot.entity.position = targetBlock.offset(-2, 0, 0) // should now be close enough assert.ok(goal.isEnd(bot.entity.position.floored())) }) }) describe('Goals with entity', () => { beforeEach(() => { bot.entity.position = spawnPos.clone() }) before((done) => { const Entity = PEntity(Version) const chicken = new Entity(mcData.entitiesByName.chicken.id) const client = Object.values(server.clients)[0] client.write('spawn_entity', { // Might only work for 1.16 entityId: chicken.id, objectUUID: uuidv4(), type: chicken.type, x: targetBlock.x, y: targetBlock.y + 1, z: targetBlock.z, pitch: 0, yaw: 0, objectData: 0, velocityX: 0, velocityY: 0, velocityZ: 0 }) setTimeout(done, 100) }) it('GoalFollow', () => { const entity = bot.nearestEntity() const goal = new goals.GoalFollow(entity, 1) assert.ok(!goal.isEnd(bot.entity.position)) bot.entity.position = targetBlock.clone() assert.ok(goal.isEnd(bot.entity.position)) }) }) }) describe('pathfinder events', function () { const mcData = require('minecraft-data')(Version) const targetBlock = new Vec3(12, 1, 8) // a gold block away from the spawn position const spawnPos = new Vec3(8.5, 1, 8.5) // Center of the chunk & center of the block /** @type { import('mineflayer').Bot & { pathfinder: import('mineflayer-pathfinder').Pathfinder }} */ let bot /** @type { import('minecraft-protocol').Server } */ let server before(async () => { const chunk = flatMap(Version) server = await newServer(server, chunk, spawnPos, Version, true) bot = mineflayer.createBot({ username: 'player', version: Version, port: ServerPort }) await once(bot, 'chunkColumnLoad') bot.loadPlugin(pathfinder) bot.pathfinder.setMovements(new Movements(bot, mcData)) }) after(() => server.close()) describe('events', async function () { beforeEach(() => { bot.entity.position = spawnPos.clone() }) afterEach((done) => { bot.pathfinder.setGoal(null) setTimeout(done) const listeners = ['goal_reached', 'goal_updated', 'path_update', 'path_stop'] listeners.forEach(l => bot.removeAllListeners(l)) }) it('goal_reached', function (done) { this.timeout(3000) this.slow(1000) bot.once('goal_reached', () => done()) bot.pathfinder.setGoal(new goals.GoalNear(targetBlock.x, targetBlock.y, targetBlock.z, 1)) }) it('goal_updated', function (done) { this.timeout(100) bot.once('goal_updated', () => done()) bot.pathfinder.setGoal(new goals.GoalNear(targetBlock.x, targetBlock.y, targetBlock.z, 1)) }) it('path_update', function (done) { this.timeout(3000) this.slow(1000) bot.pathfinder.setGoal(new goals.GoalNear(targetBlock.x, targetBlock.y, targetBlock.z, 1)) bot.once('path_update', () => done()) }) it('path_stop', function (done) { this.timeout(3000) this.slow(1000) bot.pathfinder.setGoal(new goals.GoalNear(targetBlock.x, targetBlock.y, targetBlock.z, 1)) bot.once('path_stop', () => done()) bot.pathfinder.stop() }) }) }) describe('pathfinder util functions', function () { const mcData = require('minecraft-data')(Version) const Item = require('prismarine-item')(Version) const targetBlock = new Vec3(12, 1, 8) // a gold block away from the spawn position const spawnPos = new Vec3(8.5, 1, 8.5) // Center of the chunk & center of the block const itemsToGive = [new Item(mcData.itemsByName.diamond_pickaxe.id, 1), new Item(mcData.itemsByName.dirt.id, 64)] /** @type { import('mineflayer').Bot & { pathfinder: import('mineflayer-pathfinder').Pathfinder }} */ let bot /** @type { import('minecraft-protocol').Server } */ let server before(async () => { const chunk = flatMap(Version) server = await newServer(server, chunk, spawnPos, Version, true) bot = mineflayer.createBot({ username: 'player', version: Version, port: ServerPort }) await once(bot, 'chunkColumnLoad') itemsToGive.forEach(item => { const slot = bot.inventory.firstEmptyHotbarSlot() bot.inventory.slots[slot] = item }) bot.loadPlugin(pathfinder) bot.pathfinder.setMovements(new Movements(bot, mcData)) }) after(() => server.close()) describe('paththing', function () { this.afterEach((done) => { bot.pathfinder.setGoal(null) bot.entity.position = spawnPos.clone() bot.stopDigging() setTimeout(() => done()) }) it('Goto', async function () { this.timeout(3000) this.slow(1500) await bot.pathfinder.goto(new goals.GoalGetToBlock(targetBlock.x, targetBlock.y, targetBlock.z)) }) it('isMoving', function (done) { bot.pathfinder.setGoal(new goals.GoalGetToBlock(targetBlock.x, targetBlock.y, targetBlock.z)) const foo = () => { if (bot.pathfinder.isMoving()) { bot.removeListener('physicTick', foo) done() } } bot.on('physicTick', foo) }) // Note: Ordering seams to matter when running the isBuilding test. If run after isMining isBuilding does not seam to work. it('isBuilding', function (done) { this.timeout(5000) this.slow(1500) bot.pathfinder.setGoal(new goals.GoalBlock(targetBlock.x, targetBlock.y + 2, targetBlock.z)) const foo = () => { if (bot.pathfinder.isBuilding()) { bot.removeListener('physicTick', foo) bot.stopDigging() done() } } bot.on('physicTick', foo) }) it('isMining', function (done) { this.timeout(5000) this.slow(1500) bot.pathfinder.setGoal(new goals.GoalBlock(targetBlock.x, targetBlock.y, targetBlock.z)) const foo = () => { if (bot.pathfinder.isMining()) { bot.removeListener('physicTick', foo) bot.stopDigging() done() } } bot.on('physicTick', foo) }) }) it('bestHarvestTool', function () { const block = bot.blockAt(targetBlock) const tool = bot.pathfinder.bestHarvestTool(block) assert.deepStrictEqual(tool, itemsToGive[0]) }) it('getPathTo', function () { const path = bot.pathfinder.getPathTo(bot.pathfinder.movements, new goals.GoalGetToBlock(targetBlock.x, targetBlock.y, targetBlock.z)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(path.status, 'success') assert.ok(path.visitedNodes < 5, `Generated path visited nodes to high (${path.visitedNodes} < 5)`) assert.ok(path.generatedNodes < 30, `Generated path nodes to high (${path.generatedNodes} < 30)`) assert.ok(path.path.length === 3, `Generated path length wrong (${path.path.length} === 3)`) assert.ok(path.time < 50, `Generated path took too long (${path.time} < 50)`) }) }) describe('pathfinder Movement', function () { const mcData = require('minecraft-data')(Version) const Item = require('prismarine-item')(Version) const Block = require('prismarine-block')(Version) const targetBlock = new Vec3(12, 1, 8) // a gold block away from the spawn position const spawnPos = new Vec3(8.5, 1, 8.5) // Center of the chunk & center of the block /** @type { import('mineflayer').Bot & { pathfinder: import('mineflayer-pathfinder').Pathfinder }} */ let bot /** @type { import('minecraft-protocol').Server } */ let server /** @type { import('mineflayer-pathfinder').Movements } */ let defaultMovement const itemsToGive = [new Item(mcData.itemsByName.diamond_pickaxe.id, 1), new Item(mcData.itemsByName.dirt.id, 64)] before(async () => { const chunk = flatMap(Version) server = await newServer(server, chunk, spawnPos, Version, true) bot = mineflayer.createBot({ username: 'player', version: Version, port: ServerPort }) await once(bot, 'chunkColumnLoad') itemsToGive.forEach(item => { const slot = bot.inventory.firstEmptyHotbarSlot() bot.inventory.slots[slot] = item }) defaultMovement = new Movements(bot, mcData) bot.loadPlugin(pathfinder) bot.pathfinder.setMovements(defaultMovement) }) after(() => server.close()) it('countScaffoldingItems', function () { assert.strictEqual(defaultMovement.countScaffoldingItems(), 64) }) it('getScaffoldingItem', function () { assert.strictEqual(defaultMovement.getScaffoldingItem(), itemsToGive[1]) }) it('getBlock', function () { assert.ok(defaultMovement.getBlock(targetBlock, 0, 0, 0).type === mcData.blocksByName.gold_block.id) }) describe('safeToBreak world editing', function () { this.afterAll(async () => { defaultMovement.canDig = true await bot.world.setBlock(targetBlock.offset(1, 0, 0), new Block(mcData.blocksByName.air.id, 0)) }) it('safeToBreak', async function () { const block = bot.blockAt(targetBlock) assert.ok(defaultMovement.safeToBreak(block)) defaultMovement.canDig = false assert.ok(!defaultMovement.safeToBreak(block)) defaultMovement.canDig = true await bot.world.setBlock(targetBlock.offset(1, 0, 0), new Block(mcData.blocksByName.water.id, 0, 0)) assert.ok(!defaultMovement.safeToBreak(block)) }) }) it('safeOrBreak', function () { const block = defaultMovement.getBlock(targetBlock, 0, 0, 0) const toBreak = [] const extraValue = defaultMovement.safeOrBreak(block, toBreak) assert.ok(extraValue < 100, `safeOrBreak to high for block (${extraValue} < 100)`) assert.ok(toBreak.length === 1, `safeOrBreak toBreak array wrong length ${toBreak.length} (${toBreak.length} === 1)`) }) it('getMoveJumpUp', function () { const block = defaultMovement.getBlock(targetBlock, -1, 0, 0) const dir = new Vec3(1, 0, 0) const neighbors = [] defaultMovement.getMoveJumpUp(block.position, dir, neighbors) assert.ok(neighbors.length === 1, `getMoveJumpUp neighbors not right length (${neighbors.length} === 1)`) }) it('getMoveForward', function () { const dir = new Vec3(1, 0, 0) const neighbors = [] defaultMovement.getMoveForward(targetBlock, dir, neighbors) assert.ok(neighbors.length === 1, `getMoveForward neighbors not right length (${neighbors.length} === 1)`) }) it('getMoveDiagonal', function () { const dir = new Vec3(1, 0, 0) const neighbors = [] defaultMovement.getMoveDiagonal(targetBlock, dir, neighbors) assert.ok(neighbors.length === 1, `getMoveDiagonal neighbors not right length (${neighbors.length} === 1)`) }) it('getLandingBlock', function () { const node = targetBlock.offset(-1, 3, 0) const dir = new Vec3(1, 0, 0) const block = defaultMovement.getLandingBlock(node, dir) assert.ok(block != null, 'Landing block is null') if (!block) return assert.ok(block.type === mcData.blocksByName.air.id, `getLandingBlock not the right block (${block.name} === air)`) assert.ok(block.position.offset(0, -1, 0).distanceSquared(targetBlock) === 0, `getLandingBlock not landing (${block.position.offset(0, -1, 0).distanceSquared(targetBlock)}) on target block: ${defaultMovement.getBlock(block.position, 0, -1, 0).name}`) }) it('getMoveDropDown', function () { const dir = new Vec3(1, 0, 0) const neighbors = [] defaultMovement.getMoveDropDown(targetBlock.offset(-1, 4, 0), dir, neighbors) assert.ok(neighbors.length === 1, `getMoveDropDown neighbors not right length (${neighbors.length} === 1)`) }) it('getMoveDown', function () { const neighbors = [] defaultMovement.getMoveDown(targetBlock.offset(0, 4, 0), neighbors) assert.ok(neighbors.length === 1, `getMoveDown neighbors not right length (${neighbors.length} === 1)`) }) it('getMoveUp', function () { const neighbors = [] defaultMovement.getMoveUp(targetBlock.offset(0, 1, 0), neighbors) assert.ok(neighbors.length === 1, `getMoveUp neighbors not right length (${neighbors.length} === 1)`) }) it('getNeighbors', function () { const neighbors = defaultMovement.getNeighbors(targetBlock.offset(0, 1, 0)) assert.ok(neighbors.length > 0, 'getNeighbors length 0') }) }) describe('Parkour path test', function () { const mcData = require('minecraft-data')(Version) const spawnPos = new Vec3(8.5, 1, 8.5) // Center of the chunk & center of the block /** @type { import('mineflayer').Bot & { pathfinder: import('mineflayer-pathfinder').Pathfinder }} */ let bot /** @type { import('minecraft-protocol').Server } */ let server /** @type { import('mineflayer-pathfinder').Movements } */ let defaultMovement const parkourSpawn1 = new Vec3(0.5, 3, 12.5) const parkourSpawn2 = new Vec3(5.5, 3, 12.5) before(async () => { this.timeout(5000) const chunk = await parkourMap(Version) server = await newServer(server, chunk, spawnPos, Version, true) bot = mineflayer.createBot({ username: 'player', version: Version, port: ServerPort }) await once(bot, 'chunkColumnLoad') defaultMovement = new Movements(bot, mcData) bot.loadPlugin(pathfinder) bot.pathfinder.setMovements(defaultMovement) }) after(() => server.close()) it('getMoveParkourForward-1', function () { const dirs = [new Vec3(0, 0, 1), new Vec3(0, 0, -1)] for (let i = 0; i < dirs.length; i++) { const dir = dirs[i] // only 2 dirs as the schematic parkour1.schem only has 2 other blocks to path to. const neighbors = [] defaultMovement.getMoveParkourForward(parkourSpawn1, dir, neighbors) assert.ok(neighbors.length === 1, `getMoveParkourForward jump off gold block neighbors not right length (${neighbors.length} === 1)`) } }) it('getMoveParkourForward-2', function () { const dirs = [new Vec3(1, 0, 0), new Vec3(0, 0, 1), new Vec3(-1, 0, 0), new Vec3(0, 0, -1)] for (let i = 0; i < dirs.length; i++) { const dir = dirs[i] const neighbors = [] defaultMovement.getMoveParkourForward(parkourSpawn2, dir, neighbors) assert.ok(neighbors.length === 1, `getMoveParkourForward jump off gold block neighbors not right length (${neighbors.length} === 1)`) } }) }) describe('Physics test', function () { const mcData = require('minecraft-data')(Version) const spawnPos = new Vec3(8.5, 1, 8.5) // Center of the chunk & center of the block /** @type { import('mineflayer').Bot & { pathfinder: import('mineflayer-pathfinder').Pathfinder }} */ let bot /** @type { import('minecraft-protocol').Server } */ let server /** @type { import('mineflayer-pathfinder').Movements } */ let defaultMovement const parkourSpawn1 = new Vec3(0.5, 3, 12.5) // const parkourSpawn2 = new Vec3(5.5, 3, 12.5) before(async () => { this.timeout(5000) const chunk = await parkourMap(Version) server = await newServer(server, chunk, spawnPos, Version, true) bot = mineflayer.createBot({ username: 'player', version: Version, port: ServerPort }) await once(bot, 'chunkColumnLoad') defaultMovement = new Movements(bot, mcData) bot.loadPlugin(pathfinder) bot.pathfinder.setMovements(defaultMovement) }) after(() => server.close()) it('simulateUntil', async function () { this.slow(1000) this.timeout(2000) const ticksToSimulate = 10 const ticksPressForward = 5 bot.entity.position = parkourSpawn1.clone() bot.entity.velocity = new Vec3(0, 0, 0) // Wait for the bot to be on the ground so bot.entity.onGround == true bot.clearControlStates() await once(bot, 'physicTick') await once(bot, 'physicTick') const physics = new Physics(bot) const simulatedSteps = [] const realSteps = [] const controller = (state, counter) => { state.control.forward = counter <= ticksPressForward state.control.jump = counter <= ticksPressForward simulatedSteps.push(state.pos.toString() + ' Input:' + String(counter <= ticksPressForward)) } const state = physics.simulateUntil(() => false, controller, ticksToSimulate) simulatedSteps.push(state.pos.toString() + ' Input:false') // We have to be carful to not mess up the event scheduling. for await on(bot, 'physicTick') seams to work. // A for loop with just await once(bot, 'physicTick') does not always seam to work. What also works is attaching // a listener to bot with bot.on('physicTick', listener) but this is a lot nicer. let tick = 0 for await (const _ of on(bot, 'physicTick')) { // eslint-disable-line no-unused-vars bot.setControlState('forward', tick <= ticksPressForward) bot.setControlState('jump', tick <= ticksPressForward) realSteps.push(bot.entity.position.toString() + ' Input:' + String(tick <= ticksPressForward)) tick++ if (tick > ticksToSimulate) break } bot.clearControlStates() // console.info(bot.entity.position.toString(), console.info(state.pos.toString())) assert.ok(bot.entity.position.distanceSquared(state.pos) < 0.01, `Simulated states don't match Bot: ${bot.entity.position.toString()} !== Simulation: ${state.pos.toString()}` // + '\nSimulated Steps:\n' // + simulatedSteps.join('\n') + '\n' // + 'Real steps:\n' // + realSteps.join('\n') ) }) // TODO: write test for simulateUntilNextTick }) describe('pathfinder entity avoidance test', function () { const mcData = require('minecraft-data')(Version) const patherOptions = { resetEntityIntersects: false } const maxPathTime = 50 const spawnPos = new Vec3(8.5, 1.0, 8.5) // Center of the chunk & center of the block /** @type { import('mineflayer').Bot & { pathfinder: import('mineflayer-pathfinder').Pathfinder }} */ let bot /** @type { import('minecraft-protocol').Server } */ let server /** @type { import('prismarine-chunk').Chunk } */ let chunk before(async () => { chunk = await parkourMap(Version) server = await newServer(server, chunk, spawnPos, Version, true) bot = mineflayer.createBot({ username: 'player', version: Version, port: ServerPort }) await once(bot, 'chunkColumnLoad') bot.loadPlugin(pathfinder) bot.pathfinder.setMovements(new Movements(bot, mcData)) }) after(() => { bot.end() bot = null server.close() }) /** * Ensure algorithm does not impede performance when handling a large number of entities */ it('entityIndexPerformance', () => { const { performance } = require('perf_hooks') const targetBlock = new Vec3(11.5, 2.0, 10.5) // a gold block away from the spawn position const startPos = new Vec3(11.5, 2.0, 14.5) // Start point for test const goal = new goals.GoalGetToBlock(targetBlock.x, targetBlock.y, targetBlock.z) for (let i = 1; i <= 10000; i++) { const pos = (i % 2) === 0 ? new Vec3(10.5, 2.0, 12.5) : new Vec3(12.5, 2.0, 12.5) bot.entities[i] = { name: 'testEntity', position: pos, height: 2.0, width: 1.0 } } const beforeTime = performance.now() const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal) const { value: { result } } = generator.next() const timeElapsed = performance.now() - beforeTime bot.pathfinder.movements.clearCollisionIndex() for (let i = 1; i <= 10000; i++) { delete bot.entities[i] } assert.ok(timeElapsed < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) }) /** * Tests if bot will prefer a basic path with less entities * The test course is a 3x3x2 with a divider in the center * [O] = Open, [W] = Wall, [S] = Start, [E] = End * W E W * W O O O W * W O W O W * W O O O W * W S W */ describe('Weighted Path Avoidance', () => { const targetBlock = new Vec3(11.5, 2.0, 10.5) // a gold block away from the spawn position const startPos = new Vec3(11.5, 2.0, 14.5) // Start point for test const firstLeftNode = new Vec3(10.5, 2.0, 12.5) const firstRightNode = new Vec3(12.5, 2.0, 12.5) const goal = new goals.GoalGetToBlock(targetBlock.x, targetBlock.y, targetBlock.z) beforeEach((done) => { bot.pathfinder.movements.clearCollisionIndex() setTimeout(done, 100) }) /** * By default, algorithm will favor the Left Path * [X] = Ent, [O] = Open, [W] = Wall * O O O * O W O * O O O */ it('defaultPath', () => { const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 3, `Generated path length wrong (${path.length} === 3)`) assert.ok(leftBranch === true, `Generated path did not follow Left Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}]`) }) /** * Ensure path with weight is avoided * [X] = Ent, [O] = Open, [W] = Wall * O O O * O W X * O O O */ it('rightBranchObstructed', () => { add1x2Weight(bot.pathfinder.movements.entityIntersections, 12, 2, 12) const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 3, `Generated path length wrong (${path.length} === 3)`) assert.ok(leftBranch === true, `Generated path did not follow Left Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}]`) }) /** * Ensure path with more weight is avoided * [X] = Ent, [O] = Open, [W] = Wall * O O O * X W X * X O O */ it('leftBranchMoreObstructed', () => { add1x2Weight(bot.pathfinder.movements.entityIntersections, 12, 2, 12) add1x2Weight(bot.pathfinder.movements.entityIntersections, 10, 2, 12) add1x2Weight(bot.pathfinder.movements.entityIntersections, 10, 2, 13) const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 3, `Generated path length wrong (${path.length} === 3)`) assert.ok(rightBranch === true, `Generated path did not follow Right Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}]`) }) /** * Ensure blocks adjacent to diagonal nodes are detected * [X] = Ent, [O] = Open, [W] = Wall * O O X * X W O * O O X */ it('rightBranchDiagsClear', () => { add1x2Weight(bot.pathfinder.movements.entityIntersections, 12, 2, 13) add1x2Weight(bot.pathfinder.movements.entityIntersections, 12, 2, 11) add1x2Weight(bot.pathfinder.movements.entityIntersections, 10, 2, 12) const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 3, `Generated path length wrong (${path.length} === 3)`) assert.ok(leftBranch === true, `Generated path did not follow Left Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}]`) }) /** * Ensure blocks adjacent to diagonal nodes are detected * [X] = Ent, [O] = Open, [W] = Wall * X O O * O W X * X O O */ it('leftBranchDiagsClear', () => { add1x2Weight(bot.pathfinder.movements.entityIntersections, 12, 2, 12) add1x2Weight(bot.pathfinder.movements.entityIntersections, 10, 2, 13) add1x2Weight(bot.pathfinder.movements.entityIntersections, 10, 2, 11) const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 3, `Generated path length wrong (${path.length} === 3)`) assert.ok(rightBranch === true, `Generated path did not follow Right Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}]`) }) }) /** * Tests if bot will try to path where they cannot build due to an entity and whether it will * try to break a block that would potentially cause an entity to fall. * The test course is a 2x2x4 pit where the start is at the bottom and the end is at the top * [O] = Open, [W] = Wall, [S] = Start, [E] = End * W W W E * W O O W * W S O W * W W W W */ describe('Construction Path Avoidance', () => { const Item = require('prismarine-item')(Version) const scaffoldItemId = mcData.itemsByName.dirt.id const groundYPos = 2 const lidYPos = 5 const forwardPos = { x: 11, z: 6 } const leftPos = { x: 10, z: 6 } const rightPos = { x: 11, z: 7 } const backPos = { x: 10, z: 7 } const targetBlock = new Vec3(forwardPos.x + 1.5, lidYPos + 1.0, forwardPos.z - 0.5) // a gold block away from the spawn position. One block diagonal from forward const startPos = new Vec3(backPos.x + 0.5, groundYPos, backPos.z + 0.5) // Start point for test const firstLeftNode = new Vec3(leftPos.x + 0.5, groundYPos, leftPos.z + 0.5) const firstRightNode = new Vec3(rightPos.x + 0.5, groundYPos, rightPos.z + 0.5) const firstForwardNode = new Vec3(forwardPos.x + 0.5, groundYPos, forwardPos.z + 0.5) const firstBackNode = startPos.clone().plus(new Vec3(-0.5, 1, -0.5)) // Jump up isn't going to half block and targets one block higher const blockersToPlace = [forwardPos, leftPos, rightPos, backPos] const goal = new goals.GoalGetToBlock(targetBlock.x, targetBlock.y, targetBlock.z) /** @type { import('minecraft-protocol').Client } */ let serverClient /** @type { number } */ let hotbarSlot before(() => { serverClient = Object.values(server.clients)[0] hotbarSlot = bot.inventory.firstEmptyHotbarSlot() }) beforeEach((done) => { bot.pathfinder.movements.clearCollisionIndex() bot.inventory.slots[hotbarSlot] = new Item(scaffoldItemId, 64) setTimeout(done, 100) }) afterEach(async () => { blockersToPlace.forEach(hPos => { const blockPos = { x: hPos.x, y: lidYPos, z: hPos.z } serverClient.write('block_change', { location: blockPos, type: mcData.blocksByName.air.id }) chunk.setBlockType(new Vec3(blockPos.x, blockPos.y, blockPos.z), mcData.blocksByName.air.id) }) serverClient.write('map_chunk', generateChunkPacket(chunk)) await once(bot, 'chunkColumnLoad') }) /** * By default, algorithm will favor the Backward Path * [X] = Ent Below, [+] = Ent Above a Block, [O] = Open, [W] = Wall * W W W W * W O O W * W O O W * W W W W */ it('defaultPath', () => { const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) const forwardBranch = (path[0].equals(firstForwardNode) || path[1].equals(firstForwardNode)) const backwardBranch = (path[0].equals(firstBackNode) || path[1].equals(firstBackNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 6, `Generated path length wrong (${path.length} === 6)`) assert.ok(backwardBranch === true, `Generated path did not follow Backward Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}, Forward Branch: ${forwardBranch}, Backward Branch: ${backwardBranch}]`) }) /** * Ensure bot finds a path when it cannot break left blocker with ent on top * [X] = Ent Below, [+] = Ent Above a Block, [O] = Open, [W] = Wall * W W W W * W O O W * W + O W * W W W W */ it('backPathObstructed', async () => { const blockPos = { x: backPos.x, y: lidYPos, z: backPos.z } serverClient.write('block_change', { location: blockPos, type: mcData.blocksByName.dirt.id }) chunk.setBlockType(new Vec3(blockPos.x, blockPos.y, blockPos.z), mcData.blocksByName.dirt.id) add1x2Weight(bot.pathfinder.movements.entityIntersections, blockPos.x, blockPos.y + 1, blockPos.z) serverClient.write('map_chunk', generateChunkPacket(chunk)) await once(bot, 'chunkColumnLoad') const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) const forwardBranch = (path[0].equals(firstForwardNode) || path[1].equals(firstForwardNode)) const backwardBranch = (path[0].equals(firstBackNode) || path[1].equals(firstBackNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 6, `Generated path length wrong (${path.length} === 6)`) assert.ok(rightBranch === true, `Generated path did not follow Right Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}, Forward Branch: ${forwardBranch}, Backward Branch: ${backwardBranch}]`) }) /** * If there are blocks capping the pit with an entity above each block, ensure bot cannot path since * there are no blocks that can be broken without potentially dropping an entity. Bot is expected * to follow forward branch for as far as possible * [X] = Ent Below, [+] = Ent Above a Block, [O] = Open, [W] = Wall * W W W W * W + + W * W + + W * W W W W */ it('noPathsBreakingObstructed', async () => { blockersToPlace.forEach(hPos => { const blockPos = { x: hPos.x, y: lidYPos, z: hPos.z } serverClient.write('block_change', { location: blockPos, type: mcData.blocksByName.dirt.id }) chunk.setBlockType(new Vec3(blockPos.x, blockPos.y, blockPos.z), mcData.blocksByName.dirt.id) add1x2Weight(bot.pathfinder.movements.entityIntersections, blockPos.x, blockPos.y + 1, blockPos.z) }) serverClient.write('map_chunk', generateChunkPacket(chunk)) await once(bot, 'chunkColumnLoad') const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) const forwardBranch = (path[0].equals(firstForwardNode) || path[1].equals(firstForwardNode)) const backwardBranch = (path[0].equals(firstBackNode) || path[1].equals(firstBackNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'noPath') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 2, `Generated path length wrong (${path.length} === 2)`) assert.ok(forwardBranch === true, `Generated path did not attempt to follow Forward Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}, Forward Branch: ${forwardBranch}, Backward Branch: ${backwardBranch}]`) }) /** * If there are blocks capping the pit with an entity above each block, ensure bot can path * if allowed in the movements configuration * [X] = Ent Below, [+] = Ent Above a Block, [O] = Open, [W] = Wall * W W W W * W + + W * W + + W * W W W W */ it('canPathWithBreakingObstructed', async () => { blockersToPlace.forEach(hPos => { const blockPos = { x: hPos.x, y: lidYPos, z: hPos.z } serverClient.write('block_change', { location: blockPos, type: mcData.blocksByName.dirt.id }) chunk.setBlockType(new Vec3(blockPos.x, blockPos.y, blockPos.z), mcData.blocksByName.dirt.id) add1x2Weight(bot.pathfinder.movements.entityIntersections, blockPos.x, blockPos.y + 1, blockPos.z) }) serverClient.write('map_chunk', generateChunkPacket(chunk)) await once(bot, 'chunkColumnLoad') bot.pathfinder.movements.dontMineUnderFallingBlock = false const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path bot.pathfinder.movements.dontMineUnderFallingBlock = true // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) const forwardBranch = (path[0].equals(firstForwardNode) || path[1].equals(firstForwardNode)) const backwardBranch = (path[0].equals(firstBackNode) || path[1].equals(firstBackNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 6, `Generated path length wrong (${path.length} === 6)`) assert.ok(forwardBranch === true, `Generated path did not follow Forward Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}, Forward Branch: ${forwardBranch}, Backward Branch: ${backwardBranch}]`) }) /** * If there are entities filling the entire build area, ensure bot cannot path since * entities will prevent any block placement. Bot is expected to follow forward branch * for as far as possible * [X] = Ent Below, [+] = Ent Above a Block, [O] = Open, [W] = Wall * W W W W * W X X W * W X X W * W W W W */ it('noPathsBuildingObstructed', () => { blockersToPlace.forEach(hPos => { add1x2Weight(bot.pathfinder.movements.entityIntersections, hPos.x, groundYPos, hPos.z) }) const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path const leftBranch = path[0].equals(firstLeftNode) const rightBranch = path[0].equals(firstRightNode) const forwardBranch = path[0].equals(firstForwardNode) const backwardBranch = path[0].equals(firstBackNode) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'noPath') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 1, `Generated path length wrong (${path.length} === 1)`) assert.ok(forwardBranch === true, `Generated path did not attempt to follow Forward Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}, Forward Branch: ${forwardBranch}, Backward Branch: ${backwardBranch}]`) }) /** * If there are entities filling the entire build area except for one space, ensure bot finds a path * [X] = Ent Below, [+] = Ent Above a Block, [O] = Open, [W] = Wall * W W W W * W O X W * W X X W * W W W W */ it('singlePathUnobstructed', () => { blockersToPlace.forEach(hPos => { if ((hPos.x !== leftPos.x) || (hPos.z !== leftPos.z)) { add1x2Weight(bot.pathfinder.movements.entityIntersections, hPos.x, groundYPos, hPos.z) } }) const generator = bot.pathfinder.getPathFromTo(bot.pathfinder.movements, startPos, goal, patherOptions) const { value: { result } } = generator.next() const path = result.path // Look at first and second nodes incase diagonal movements are used const leftBranch = (path[0].equals(firstLeftNode) || path[1].equals(firstLeftNode)) const rightBranch = (path[0].equals(firstRightNode) || path[1].equals(firstRightNode)) const forwardBranch = (path[0].equals(firstForwardNode) || path[1].equals(firstForwardNode)) const backwardBranch = (path[0].equals(firstBackNode) || path[1].equals(firstBackNode)) // All depends on the actually path that gets generated. If target block is moved some were else these values have to change. assert.strictEqual(result.status, 'success') assert.ok(result.time < maxPathTime, `Generated path took too long (${result.time} < ${maxPathTime})`) assert.ok(path.length === 6, `Generated path length wrong (${path.length} === 6)`) assert.ok(leftBranch === true, `Generated path did not follow Left Branch [Left Branch: ${leftBranch}, Right Branch: ${rightBranch}, Forward Branch: ${forwardBranch}, Backward Branch: ${backwardBranch}]`) }) }) })