diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a403a871..c18669d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,4 +52,4 @@ jobs: - name: Install Dependencies run: npm install - name: Start Tests - run: npm run mocha_test -- -g ${{ matrix.mcVersion }}v \ No newline at end of file + run: npm run mocha_test -- -g ${{ matrix.mcVersion }}v diff --git a/config/default-settings.json b/config/default-settings.json index 17d76aec..f4cbb834 100644 --- a/config/default-settings.json +++ b/config/default-settings.json @@ -25,5 +25,5 @@ }, "everybody-op": false, "max-entities":100, - "version": "1.17.1" + "version": "1.18.2" } diff --git a/docs/README.md b/docs/README.md index 913b3e68..89d1dc60 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ flying-squid Create Minecraft servers with a powerful, stable, and high level JavaScript API. ## Features -* Support for Minecraft 1.8, 1.9, 1.10, 1.11, 1.12, 1.13, 1.14, 1.15, 1.16 and 1.17 +* Support for Minecraft 1.8, 1.9, 1.10, 1.11, 1.12, 1.13, 1.14, 1.15, 1.16, 1.17 and 1.18 * Players can see the world * Players see each other in-game and in tab * Digging @@ -139,7 +139,7 @@ mcServer.createMCServer({ }, 'everybody-op': true, 'max-entities': 100, - 'version': '1.17.1' + 'version': '1.18' }) ``` diff --git a/src/index.js b/src/index.js index e524cb22..646e6f30 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ const { EventEmitter } = require('events') const { testedVersions, latestSupportedVersion, oldestSupportedVersion } = require('./lib/version') const Command = require('./lib/command') const plugins = require('./lib/plugins') +const { onceWithTimeout } = require('./lib/utils') module.exports = { createMCServer, @@ -60,8 +61,14 @@ class MCServer extends EventEmitter { Promise.all(promises).then(() => { this.emit('pluginsReady') this.pluginsReady = true + this.debug?.('Loaded plugins') }) + this.waitForReady = (timeout) => { + if (this.pluginsReady) return Promise.resolve() + return onceWithTimeout(this, 'pluginsReady', timeout) + } + if (options.logging === true) this.createLog() this._server.on('error', error => this.emit('error', error)) this._server.on('listening', () => this.emit('listening', this._server.socketServer.address().port)) diff --git a/src/lib/plugins/digging.js b/src/lib/plugins/digging.js index 01f65714..5b8d06df 100644 --- a/src/lib/plugins/digging.js +++ b/src/lib/plugins/digging.js @@ -1,7 +1,7 @@ const Vec3 = require('vec3').Vec3 module.exports.player = function (player, serv, { version }) { - const { registry } = serv.registry + const { registry } = serv function cancelDig ({ position, block }) { player.sendBlock(position, block.type) } diff --git a/src/lib/plugins/errorHandler.js b/src/lib/plugins/errorHandler.js index 4efd8509..d3f804bc 100644 --- a/src/lib/plugins/errorHandler.js +++ b/src/lib/plugins/errorHandler.js @@ -1,4 +1,5 @@ module.exports.player = async function (player, serv) { + if (globalThis.isMocha || serv.debug) return // Don't eat errors when debugging function unhandledRejection (promise) { serv.warn('-------------------------------') serv.warn('Please report this error to flying-squid! This is can be bug') diff --git a/src/lib/plugins/log.js b/src/lib/plugins/log.js index 3c280c4d..e13548ac 100644 --- a/src/lib/plugins/log.js +++ b/src/lib/plugins/log.js @@ -41,6 +41,12 @@ module.exports.server = function (serv, settings) { serv.info(banner.username + ' banned ' + bannedUsername + (reason ? ' (' + reason + ')' : ''))) serv.on('seed', (seed) => serv.info('World seed: ' + seed)) + serv._server.on('close', function () { + serv.info('Server is closed.') + // Remove server from managed list so GC can clean it up + _servers.splice(_servers.indexOf(serv), 1) + }) + const logFile = path.join('logs', timeStarted + '.log') serv.log = message => { @@ -69,7 +75,7 @@ module.exports.server = function (serv, settings) { serv.log('[' + colors.yellow('WARN') + ']: ' + message) } - if (isInNode) { + if (isInNode && !process.env.CI) { console.log = (function () { const orig = console.log return function () { diff --git a/src/lib/plugins/login.js b/src/lib/plugins/login.js index 287623bb..ee31c6e9 100644 --- a/src/lib/plugins/login.js +++ b/src/lib/plugins/login.js @@ -12,6 +12,7 @@ module.exports.server = function (serv, options) { serv._server.on('login', async (client) => { if (!serv.pluginsReady) { client.end('Server is still starting! Please wait before reconnecting.') + serv.info(`[${client.socket.remoteAddress}] ${client.username} (${client.uuid}) disconnected as server is still starting`) return } serv.debug?.(`[login] ${client.socket?.remoteAddress} - ${client.username} (${client.uuid}) connected`, client.version, client.protocolVersion) diff --git a/src/lib/plugins/world.js b/src/lib/plugins/world.js index 1157ba4d..56613f91 100644 --- a/src/lib/plugins/world.js +++ b/src/lib/plugins/world.js @@ -4,6 +4,11 @@ const generations = require('../generations') const playerDat = require('../playerDat') const spiralloop = require('spiralloop') const { level } = require('prismarine-provider-anvil') +const nbt = require('prismarine-nbt') + +function sleep (ms = 0) { + return new Promise(resolve => setTimeout(resolve, ms)) +} module.exports.server = async function (serv, options = {}) { const { version, worldFolder, generation = { name: 'diamond_square', options: { worldHeight: 80 } } } = options @@ -30,7 +35,7 @@ module.exports.server = async function (serv, options = {}) { await level.writeLevel(worldFolder + '/level.dat', { RandomSeed: [seed, 0], Version: { Name: options.version }, - generatorName: { superfalt: 'flat', diamond_square: 'default' }[generation.name] || 'customized' + generatorName: { superflat: 'flat', diamond_square: 'default' }[generation.name] || 'customized' }) } } else { @@ -170,52 +175,60 @@ module.exports.player = function (player, serv, settings) { } } - // let chunkSendCtr = 0 player.sendChunk = (chunkX, chunkZ, column) => { - // console.log(chunkSendCtr++, 'Sending chunk at', chunkX, chunkZ, 'to', player.username, chunkX * 16, chunkZ * 16) return player.behavior('sendChunk', { x: chunkX, z: chunkZ, chunk: column }, ({ x, z, chunk }) => { - player._client.write('map_chunk', { - x, - z, - groundUp: true, - bitMap: chunk.getMask(), - biomes: chunk.dumpBiomes(), - ignoreOldData: true, // should be false when a chunk section is updated instead of the whole chunk being overwritten, do we ever do that? - heightmaps: { - type: 'compound', - name: '', - value: { - MOTION_BLOCKING: { type: 'longArray', value: new Array(36).fill([0, 0]) } - } - }, // FIXME: fake heightmap - chunkData: chunk.dump(), - blockEntities: [] + // FIXME: fake heightmap + const heightmaps = nbt.comp({ + MOTION_BLOCKING: nbt.longArray(new Array(36).fill([0, 0])) }) - - if (serv.supportFeature('newLightingDataFormat')) { // 1.17+ - player._client.write('update_light', { - chunkX: x, - chunkZ: z, - trustEdges: true, // trust edges for lighting updates + const trustEdges = true // trust edges for lighting updates - should be false when a chunk section is updated instead of the whole chunk being overwritten, do we ever do that? + if (serv.supportFeature('tallWorld')) { // 1.18+ - merged chunk and light data + player._client.write('map_chunk', { + x, + z, + heightmaps, + chunkData: chunk.dump(), + blockEntities: [], + trustEdges, ...chunk.dumpLight() }) - } else if (serv.supportFeature('lightSentSeparately')) { // -1.16.5 - player._client.write('update_light', { - chunkX: x, - chunkZ: z, - trustEdges: true, // should be false when a chunk section is updated instead of the whole chunk being overwritten, do we ever do that? - skyLightMask: chunk.skyLightMask, - blockLightMask: chunk.blockLightMask, - emptySkyLightMask: 0, - emptyBlockLightMask: 0, - data: chunk.dumpLight() + } else { + player._client.write('map_chunk', { + x, + z, + groundUp: true, + bitMap: chunk.getMask(), + biomes: chunk.dumpBiomes(), + ignoreOldData: true, // should be false when a chunk section is updated instead of the whole chunk being overwritten, do we ever do that? + heightmaps, + chunkData: chunk.dump(), + blockEntities: [] }) + + if (serv.supportFeature('newLightingDataFormat')) { // 1.17+ + player._client.write('update_light', { + chunkX: x, + chunkZ: z, + trustEdges, + ...chunk.dumpLight() + }) + } else if (serv.supportFeature('lightSentSeparately')) { // -1.16.5 + player._client.write('update_light', { + chunkX: x, + chunkZ: z, + trustEdges, + skyLightMask: chunk.skyLightMask, + blockLightMask: chunk.blockLightMask, + emptySkyLightMask: 0, + emptyBlockLightMask: 0, + data: chunk.dumpLight() + }) + } } - return Promise.resolve() }) } @@ -248,12 +261,7 @@ module.exports.player = function (player, serv, settings) { .then(() => player.world.getColumn(chunkX, chunkZ)) .then((column) => player.sendChunk(chunkX, chunkZ, column)) return group ? p.then(() => sleep(5)) : p - } - , Promise.resolve()) - } - - function sleep (ms = 0) { - return new Promise(resolve => setTimeout(resolve, ms)) + }, Promise.resolve()) } player.worldSendInitialChunks = () => { diff --git a/src/lib/utils.js b/src/lib/utils.js index 06c471a8..280f9570 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -4,9 +4,23 @@ function emitAsync (listener, event, ...args) { return Promise.all(listeners.map(listener => listener(...args))) } +function onceWithTimeout (emitter, event, timeout = 10000, checkCondition) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`Timeout waiting for '${event}' event`)) + }, timeout) + emitter.once(event, (data) => { + if (checkCondition && !checkCondition(data)) return + clearTimeout(timeoutId) + resolve(data) + }) + }) +} + const skipMcPrefix = (name) => typeof name === 'string' ? name.replace(/^minecraft:/, '') : name module.exports = { + onceWithTimeout, skipMcPrefix, emitAsync } diff --git a/src/lib/version.js b/src/lib/version.js index cf566e43..24a51d8c 100644 --- a/src/lib/version.js +++ b/src/lib/version.js @@ -1,4 +1,4 @@ -const testedVersions = ['1.8.8', '1.9.4', '1.10.2', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1'] +const testedVersions = ['1.8.8', '1.9.4', '1.10.2', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1', '1.18', '1.18.2'] module.exports = { testedVersions, latestSupportedVersion: testedVersions[testedVersions.length - 1], diff --git a/test/mineflayer.test.js b/test/mineflayer.test.js index 1a9c7e6c..4f6fd460 100644 --- a/test/mineflayer.test.js +++ b/test/mineflayer.test.js @@ -1,15 +1,22 @@ /* eslint-env mocha */ - +globalThis.isMocha = true +const fs = require('fs') +const { join } = require('path') const squid = require('flying-squid') const settings = require('../config/default-settings.json') const mineflayer = require('mineflayer') const { Vec3 } = require('vec3') +const { onceWithTimeout } = require('../src/lib/utils') const expect = require('expect').default function assertPosEqual (actual, expected, precision = 1) { expect(actual.distanceTo(expected)).toBeLessThan(precision) } +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + const { once } = require('events') squid.testedVersions.forEach((testedVersion, i) => { @@ -39,11 +46,22 @@ squid.testedVersions.forEach((testedVersion, i) => { } async function waitMessage (bot, message) { - const [msg1] = await once(bot, 'message') - expect(msg1.extra[0].text).toEqual(message) + // const [msg1] = await once(bot, 'message') + // expect(msg1.extra[0].text).toEqual(message) + onceWithTimeout(bot, 'message', 5000, (msg) => { + console.log('*msg', msg) + return msg.toString() === message + }) } - beforeEach(async () => { + // Clear the world dir before each test + const worldFolder = 'world/test_' + testedVersion + const dir = join(__dirname, '../', worldFolder) + console.log('Clearing world dir', dir) + fs.rmSync(dir, { recursive: true, force: true }) + + beforeEach(async function () { + console.log('🔻 Running test: ' + this.currentTest.title) const options = settings options['online-mode'] = false options['everybody-op'] = true @@ -59,6 +77,8 @@ squid.testedVersions.forEach((testedVersion, i) => { } } + options.worldFolder = worldFolder + options.debug = console.log serv = squid.createMCServer(options) if (registry.supportFeature('entityCamelCase')) { entityName = 'EnderDragon' @@ -66,8 +86,10 @@ squid.testedVersions.forEach((testedVersion, i) => { entityName = 'ender_dragon' } - await once(serv, 'listening') - const port = serv._server.socketServer.address().port + console.log('[test] Waiting for server to start') + const [port] = await once(serv, 'listening') + await serv.waitForReady() + console.log('[test] Server is started on', port, version.minecraftVersion) bot = mineflayer.createBot({ host: 'localhost', port, @@ -81,50 +103,69 @@ squid.testedVersions.forEach((testedVersion, i) => { version: version.minecraftVersion }) - await Promise.all([once(bot, 'login'), once(bot2, 'login')]) + await Promise.all([once(bot, 'spawn'), once(bot2, 'spawn'), waitForReady(bot), waitForReady(bot2)]) bot.entity.onGround = false bot2.entity.onGround = false - }) - afterEach(async () => { - await serv.quit() + // log what the bot is standing on for debugging + const bot1StandingOn = bot.blockAt(bot.entity.position.floored().offset(0, -1, 0)) + const bot2StandingOn = bot2.blockAt(bot2.entity.position.floored().offset(0, -1, 0)) + console.log('bot1 is standing on', bot1StandingOn) + console.log('bot2 is standing on', bot2StandingOn) }) - function waitSpawnZone (bot, view) { - const nbChunksExpected = (view * 2) * (view * 2) - let c = 0 - return new Promise(resolve => { - const listener = () => { - c++ - if (c === nbChunksExpected) { - bot.removeListener('chunkColumnLoad', listener) + function waitForReady (bot) { + const viewDistance = 2 + const testExpectedNoChunks = (viewDistance * 2) * (viewDistance * 2) + return new Promise((resolve) => { + let recvChunks = 0 + function onColumnLoad () { + recvChunks++ + if (recvChunks === testExpectedNoChunks) { + bot.removeListener('chunkColumnLoad', onColumnLoad) resolve() } } - bot.on('chunkColumnLoad', listener) + bot.on('chunkColumnLoad', onColumnLoad) }) } + afterEach(async () => { + console.log('Quitting server...') + await serv.quit() + console.log('Quit server!') + }) + describe('actions', () => { - it('can dig', async () => { - await Promise.all([waitSpawnZone(bot, 2), waitSpawnZone(bot2, 2), onGround(bot), onGround(bot2)]) + // Log the name of the test being run + beforeEach(function () { + console.log('🔻 Running actions test: ' + this.currentTest.title) + }) + it('can dig', async () => { const pos = bot.entity.position.offset(0, -1, 0).floored() - const p = once(bot2, 'blockUpdate', { array: true }) + // Set a dirt block below the bot so we can easily dig + bot.chat(`/setblock ${pos.x} ${pos.y} ${pos.z} dirt`) + await sleep(1000) + console.log('Block at', bot.entity.position, bot.blockAt(pos)) + + const p = once(bot2, 'blockUpdate') bot.dig(bot.blockAt(pos)) + console.log('Digging...') const [, newBlock] = await p + console.log('Dug.') assertPosEqual(newBlock.position, pos) expect(newBlock.type).toEqual(0) }) it('can place a block', async () => { - await Promise.all([waitSpawnZone(bot, 2), waitSpawnZone(bot2, 2), onGround(bot), onGround(bot2)]) - const pos = bot.entity.position.offset(0, -2, 0).floored() - const digPromise = once(bot2, 'blockUpdate', { array: true }) + const digPromise = once(bot2, 'blockUpdate') bot.dig(bot.blockAt(pos)) + console.log(' ✔️ dug block at', pos) + let [, newBlock] = await digPromise assertPosEqual(newBlock.position, pos) expect(newBlock.type).toEqual(0) @@ -137,16 +178,17 @@ squid.testedVersions.forEach((testedVersion, i) => { bot.creative.setInventorySlot(36, new Item(1, 1)) await invPromise + console.log(' ✔️ updated inventory') + const placePromise = once(bot2, 'blockUpdate', { array: true }) bot.placeBlock(bot.blockAt(pos.offset(0, -1, 0)), new Vec3(0, 1, 0)); [, newBlock] = await placePromise + console.log(' ✔️ placed block at', pos) assertPosEqual(newBlock.position, pos) expect(newBlock.type).toEqual(1) }) it('can open and close a chest', async () => { - await Promise.all([waitSpawnZone(bot, 2), onGround(bot), waitSpawnZone(bot2, 2), onGround(bot2)]) - const chestId = registry.blocksByName.chest.id const [x, y, z] = [1, 2, 3] @@ -188,8 +230,11 @@ squid.testedVersions.forEach((testedVersion, i) => { }) describe('commands', () => { + beforeEach(function () { + console.log('🔻 Running commands test: ' + this.currentTest.title) + }) + it('has an help command', async () => { - await waitMessagePromise('bot joined the game.') bot.chat('/help') await once(bot, 'message') }) @@ -243,14 +288,12 @@ squid.testedVersions.forEach((testedVersion, i) => { assertPosEqual(bot2.entity.position, bot.entity.position) }) it('can tp with relative positions', async () => { - await onGround(bot) const initialPosition = bot.entity.position.clone() bot.chat('/tp ~1 ~-2 ~3') await once(bot, 'forcedMove') assertPosEqual(bot.entity.position, initialPosition.offset(1, -2, 3), 2) }) it('can tp somebody else with relative positions', async () => { - await Promise.all([onGround(bot), onGround(bot2)]) const initialPosition = bot2.entity.position.clone() bot.chat('/tp bot2 ~1 ~-2 ~3') await once(bot2, 'forcedMove') @@ -258,7 +301,6 @@ squid.testedVersions.forEach((testedVersion, i) => { }) }) it('can use /deop', async () => { - await waitMessagePromise('bot joined the game.') bot.chat('/deop bot') await waitMessage(bot, '§7§o[Server: Deopped bot]') bot.chat('/op bot') @@ -266,7 +308,6 @@ squid.testedVersions.forEach((testedVersion, i) => { serv.getPlayer('bot').op = true }) it('can use /setblock', async () => { - await Promise.all([waitSpawnZone(bot, 2), onGround(bot)]) const chestId = registry.blocksByName.chest.id const p = once(bot, 'blockUpdate:' + new Vec3(1, 2, 3), { array: true }) bot.chat(`/setblock 1 2 3 ${chestId} 0`) @@ -301,7 +342,6 @@ squid.testedVersions.forEach((testedVersion, i) => { } it('can use /banlist, /ban, /pardon', async () => { - await waitMessagePromise('bot joined the game.') bot.chat('/banlist') await waitMessagePromise('There are 0 total banned players') bot.chat('/ban bot2') diff --git a/test/portal.test.js b/test/portal.test.js index 0bbc5c6d..9d7434d4 100644 --- a/test/portal.test.js +++ b/test/portal.test.js @@ -1,5 +1,5 @@ /* eslint-env mocha */ - +globalThis.isMocha = true const squid = require('flying-squid') const PortalDetector = require('../src/lib/portal_detector') const expect = require('expect').default diff --git a/test/simple.test.js b/test/simple.test.js index 30c91dfa..6b7bf414 100644 --- a/test/simple.test.js +++ b/test/simple.test.js @@ -1,5 +1,5 @@ /* eslint-env mocha */ - +globalThis.isMocha = true const net = require('net') const squid = require('flying-squid')