diff --git a/config/default-settings.json b/config/default-settings.json index 640c82e1..a864fc2c 100644 --- a/config/default-settings.json +++ b/config/default-settings.json @@ -25,5 +25,5 @@ }, "everybody-op": false, "max-entities":100, - "version": "1.19.4" + "version": "1.20.2" } diff --git a/docs/API.md b/docs/API.md index a0095c9f..e1de20e5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -356,6 +356,10 @@ serv.tabComplete.add('tabId', () => { ### Events +#### "ready" + +Fires when the server is ready to accept connections (after `listening` and `pluginsReady` events). + #### "error" (error) Fires when there is an error. @@ -386,6 +390,10 @@ Emitted when `serv.pluginsReady` is set to `true`. ### Methods +#### async serv.waitForReady() + +Returns a promise that resolves when the server is ready to accept connections. + #### serv.formatMessage(message) You can override this function so you can process the message before sending it to the console. diff --git a/docs/README.md b/docs/README.md index 942fe9a6..9fd72340 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, 1.17, 1.18, 1.19 +* Support for Minecraft 1.8, 1.9, 1.10, 1.11, 1.12, 1.13, 1.14, 1.15, 1.16, 1.17, 1.18, 1.19, 1.20 * Players can see the world * Players see each other in-game and in tab * Digging diff --git a/src/index.js b/src/index.js index 91f664b1..ab68cba6 100644 --- a/src/index.js +++ b/src/index.js @@ -56,6 +56,11 @@ class MCServer extends EventEmitter { this.registry = registry this.supportFeature = feature => customFeatures[feature] ?? registry.supportFeature(feature) + Promise.all([onceWithTimeout(this, 'pluginsReady', 5000), onceWithTimeout(this._server, 'listening', 5000)]).then(() => { + this.isReady = true + this.emit('ready') + }) + const promises = [] for (const plugin of plugins.builtinPlugins) { promises.push(plugin.server?.(this, options)) @@ -66,14 +71,33 @@ class MCServer extends EventEmitter { this.debug?.('Loaded plugins') }) - this.waitForReady = (timeout) => { - if (this.pluginsReady) return Promise.resolve() - return onceWithTimeout(this, 'pluginsReady', timeout) + this.waitForReady = async function (timeout) { + if (this.isReady) return true + return onceWithTimeout(this, 'ready', 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)) + this._server.on('listening', () => { + this.listeningPort = this._server.socketServer.address().port + this.emit('listening', this.listeningPort) + }) this.emit('asap') } + + async destroy () { + this.isClosed = true + this.isReady = false + this.pluginsReady = false + this.emit('close') + for (const player of this.players) { + player._client.write = function () { + throw new Error('Tried to write to a closed connection') + } + } + if (this._server) { + this._server.close() + return onceWithTimeout(this._server, 'close', 1000) + } + } } diff --git a/src/lib/plugins/commands.js b/src/lib/plugins/commands.js index fecab09b..a6620cdf 100644 --- a/src/lib/plugins/commands.js +++ b/src/lib/plugins/commands.js @@ -147,9 +147,9 @@ module.exports.server = function (serv, { version }) { info: 'Stop the server', usage: '/stop', op: true, - action () { - serv.quit('Server closed') - process.exit() + async action () { + await serv.quit('Server closed') + process.exit(0) } }) diff --git a/src/lib/plugins/entities.js b/src/lib/plugins/entities.js index 002f6af1..5ac6ac9b 100644 --- a/src/lib/plugins/entities.js +++ b/src/lib/plugins/entities.js @@ -36,8 +36,12 @@ module.exports.server = function (serv) { }) } -module.exports.entity = function (entity) { +module.exports.entity = function (entity, serv) { entity.sendMetadata = (data) => { + if (serv.registry.version['>=']('1.20.2')) { + // todo: fix in mcdata + return + } entity._writeOthersNearby('entity_metadata', { entityId: entity.id, metadata: data diff --git a/src/lib/plugins/log.js b/src/lib/plugins/log.js index e13548ac..a53d1471 100644 --- a/src/lib/plugins/log.js +++ b/src/lib/plugins/log.js @@ -75,7 +75,7 @@ module.exports.server = function (serv, settings) { serv.log('[' + colors.yellow('WARN') + ']: ' + message) } - if (isInNode && !process.env.CI) { + if (isInNode && !process.env.CI && process.stdout.isTTY) { console.log = (function () { const orig = console.log return function () { diff --git a/src/lib/plugins/login.js b/src/lib/plugins/login.js index 6ae5f055..74a8f0c6 100644 --- a/src/lib/plugins/login.js +++ b/src/lib/plugins/login.js @@ -9,7 +9,7 @@ module.exports.server = function (serv, options) { client.on('error', error => serv.emit('clientError', client, error)) }) - serv._server.on('login', async (client) => { + serv._server.on('playerJoin', 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`) @@ -44,8 +44,6 @@ module.exports.server = function (serv, options) { module.exports.player = async function (player, serv, settings) { const Item = require('prismarine-item')(serv.registry) - let playerData - async function addPlayer () { player.type = 'player' player.crouching = false // Needs added in prismarine-entity later @@ -55,8 +53,8 @@ module.exports.player = async function (player, serv, settings) { await player.findSpawnPoint() - playerData = await playerDat.read(player.uuid, player.spawnPoint, settings.worldFolder) - Object.keys(playerData.player).forEach(k => { player[k] = playerData.player[k] }) + const playerData = player._playerData = await playerDat.read(player.uuid, player.spawnPoint, settings.worldFolder) + for (const key in playerData.player) player[key] = playerData.player[key] serv.players.push(player) serv.uuidToPlayer[player.uuid] = player @@ -64,7 +62,7 @@ module.exports.player = async function (player, serv, settings) { } function updateInventory () { - playerData.inventory.forEach((item) => { + player._playerData.inventory.forEach((item) => { const itemName = item.id.value.slice(10) // skip game brand prefix const theItem = serv.registry.itemsByName[itemName] || serv.registry.blocksByName[itemName] @@ -258,6 +256,7 @@ module.exports.player = async function (player, serv, settings) { } await addPlayer() + const pos = player.position sendLogin() sendStatus() player.sendSpawnPosition() @@ -280,6 +279,8 @@ module.exports.player = async function (player, serv, settings) { player.worldSendRestOfChunks() sendChunkWhenMove() - player.save() + // Prevent player from sometimes falling through the world on login by resending their login position + player.sendSelfPosition(pos) + await player.save() } } diff --git a/src/lib/plugins/logout.js b/src/lib/plugins/logout.js index 3a6a8466..ab431a1e 100644 --- a/src/lib/plugins/logout.js +++ b/src/lib/plugins/logout.js @@ -6,8 +6,7 @@ module.exports.server = function (serv) { player.kick(reason) return once(player, 'disconnected') })) - serv._server.close() - await once(serv._server, 'close') + await serv.destroy() } } @@ -18,7 +17,7 @@ module.exports.player = function (player, serv, { worldFolder }) { player._client.on('end', async () => { if (player && player.username) { - player._unloadAllChunks() + player._unloadAllChunks(true /* becasuePlayerLeft */) serv.broadcast(serv.color.yellow + player.username + ' left the game.') serv._sendPlayerEventLeave(player) player.nearbyPlayers().forEach(otherPlayer => otherPlayer.despawnEntities([player])) diff --git a/src/lib/plugins/respawn.js b/src/lib/plugins/respawn.js index f719e304..b1361758 100644 --- a/src/lib/plugins/respawn.js +++ b/src/lib/plugins/respawn.js @@ -10,18 +10,7 @@ module.exports.player = function (player, serv, { version }) { if (actionId === 0) { player.behavior('requestRespawn', {}, () => { - player._client.write('respawn', { - previousGameMode: player.prevGameMode, - dimension: (serv.supportFeature('dimensionIsAString') || serv.supportFeature('dimensionIsAWorld')) ? serv.dimensionNames[0] : 0, - worldName: serv.dimensionNames[0], - difficulty: serv.difficulty, - hashedSeed: serv.hashedSeed, - gamemode: player.gameMode, - levelType: 'default', - isDebug: false, - isFlat: false, - copyMetadata: false - }) + player._sendRespawn() player.sendSelfPosition() player.updateHealth(20) player.nearbyEntities = [] @@ -29,4 +18,20 @@ module.exports.player = function (player, serv, { version }) { }) } }) + + player._sendRespawn = function (newDifficulty, newGameMode, newDimension) { + player._client.write('respawn', { + previousGameMode: player.prevGameMode, + dimension: serv.registry.loginPacket?.dimension || 0, + worldName: serv.dimensionNames[newDimension || 0], + difficulty: newDifficulty ?? serv.difficulty, + hashedSeed: serv.hashedSeed, + gamemode: newGameMode ?? player.gameMode, + levelType: 'default', + isDebug: false, + isFlat: false, + copyMetadata: false, + portalCooldown: 0 // 1.20 + }) + } } diff --git a/src/lib/plugins/signs.js b/src/lib/plugins/signs.js index bed2813b..a22ddd1d 100644 --- a/src/lib/plugins/signs.js +++ b/src/lib/plugins/signs.js @@ -15,7 +15,8 @@ module.exports.server = (serv, { version }) => { } player._client.write('open_sign_entity', { - location: placedPosition + location: placedPosition, + isFrontText: true // 1.20 allows sign text both sides, unsupported atm }) const data = serv.setBlockDataProperties(block.defaultState - block.minStateId, block.states, properties) @@ -32,7 +33,8 @@ module.exports.server = (serv, { version }) => { if (direction === 0) return { id: -1, data: 0 } player._client.write('open_sign_entity', { - location: placedPosition + location: placedPosition, + isFrontText: true // 1.20 allows sign text both sides, unsupported atm }) if (direction === 1) { diff --git a/src/lib/plugins/spawn.js b/src/lib/plugins/spawn.js index 341779c0..5fa661e4 100644 --- a/src/lib/plugins/spawn.js +++ b/src/lib/plugins/spawn.js @@ -297,7 +297,8 @@ module.exports.entity = function (entity, serv) { entity.bornTime = Date.now() serv.entities[entity.id] = entity - if (entity.type === 'player') entity.spawnPacketName = 'named_entity_spawn' + if (serv.supportFeature('unifiedPlayerAndEntitySpawnPacket')) entity.spawnPacketName = 'spawn_entity' + else if (entity.type === 'player') entity.spawnPacketName = 'named_entity_spawn' else if (entity.type === 'object') entity.spawnPacketName = 'spawn_entity' else if (entity.type === 'mob') { if (serv.supportFeature('consolidatedEntitySpawnPacket')) entity.spawnPacketName = 'spawn_entity' diff --git a/src/lib/plugins/updatePositions.js b/src/lib/plugins/updatePositions.js index b647f822..344b0d27 100644 --- a/src/lib/plugins/updatePositions.js +++ b/src/lib/plugins/updatePositions.js @@ -46,7 +46,8 @@ module.exports.player = function (player) { sendLook(yaw, pitch, onGround) }) - player.sendSelfPosition = () => { + player.sendSelfPosition = (newPosition) => { + if (newPosition) player.position = newPosition // double position in all versions player._client.write('position', { x: player.position.x, diff --git a/src/lib/plugins/world.js b/src/lib/plugins/world.js index 0a1b4072..204df2a0 100644 --- a/src/lib/plugins/world.js +++ b/src/lib/plugins/world.js @@ -156,8 +156,9 @@ module.exports.player = function (player, serv, settings) { await playerDat.save(player, settings.worldFolder, serv.supportFeature('attributeSnakeCase'), serv.supportFeature('theFlattening')) } - player._unloadChunk = (chunkX, chunkZ) => { + player._unloadChunk = (chunkX, chunkZ, isBecausePlayerLeft) => { serv._worldUnloadPlayerChunk(chunkX, chunkZ, player) + if (isBecausePlayerLeft) return if (serv.supportFeature('unloadChunkByEmptyChunk')) { player._client.write('map_chunk', { @@ -289,11 +290,11 @@ module.exports.player = function (player, serv, settings) { player.worldSendRestOfChunks() }) - player._unloadAllChunks = () => { + player._unloadAllChunks = (isBecausePlayerLeft) => { if (!player?.loadedChunks) return Object.keys(player.loadedChunks) .map((key) => key.split(',').map(a => parseInt(a))) - .forEach(([x, z]) => player._unloadChunk(x, z)) + .forEach(([x, z]) => player._unloadChunk(x, z, isBecausePlayerLeft)) } player.changeWorld = async (world, opt) => { @@ -305,18 +306,7 @@ module.exports.player = function (player, serv, settings) { if (opt.gamemode !== player.gameMode) player.prevGameMode = player.gameMode player.gameMode = opt.gamemode } - player._client.write('respawn', { - previousGameMode: player.prevGameMode, - dimension: serv.registry.loginPacket?.dimension || 0, - worldName: serv.dimensionNames[opt.dimension || 0], - difficulty: opt.difficulty || serv.difficulty, - hashedSeed: serv.hashedSeed, - gamemode: opt.gamemode || player.gameMode, - levelType: 'default', - isDebug: false, - isFlat: false, - copyMetadata: true - }) + player._sendRespawn(opt.difficulty, opt.gamemode, opt.dimension) await player.findSpawnPoint() player.position = player.spawnPoint player.sendSpawnPosition() @@ -329,5 +319,7 @@ module.exports.player = function (player, serv, settings) { await player.waitPlayerLogin() player.worldSendRestOfChunks() + // Prevent player from falling through the world + player.sendSelfPosition(player.spawnPoint) } } diff --git a/src/lib/utils.js b/src/lib/utils.js index 280f9570..061d6cdd 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -9,18 +9,25 @@ function onceWithTimeout (emitter, event, timeout = 10000, checkCondition) { const timeoutId = setTimeout(() => { reject(new Error(`Timeout waiting for '${event}' event`)) }, timeout) - emitter.once(event, (data) => { - if (checkCondition && !checkCondition(data)) return + function onEvent (...data) { + if (checkCondition && !checkCondition(...data)) return clearTimeout(timeoutId) - resolve(data) - }) + resolve([...data]) + emitter.off(event, onEvent) + } + emitter.on(event, onEvent) }) } +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + const skipMcPrefix = (name) => typeof name === 'string' ? name.replace(/^minecraft:/, '') : name module.exports = { onceWithTimeout, skipMcPrefix, - emitAsync + emitAsync, + sleep } diff --git a/src/lib/version.js b/src/lib/version.js index fad473b4..adea7668 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', '1.18', '1.18.2', '1.19', '1.19.2', '1.19.3', '1.19.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', '1.18', '1.18.2', '1.19', '1.19.2', '1.19.3', '1.19.4', '1.20', '1.20.2'] module.exports = { testedVersions, latestSupportedVersion: testedVersions[testedVersions.length - 1], diff --git a/test/mineflayer.test.js b/test/mineflayer.test.js index 539a6912..212c5e17 100644 --- a/test/mineflayer.test.js +++ b/test/mineflayer.test.js @@ -9,12 +9,28 @@ const { Vec3 } = require('vec3') const { onceWithTimeout } = require('../src/lib/utils') const expect = require('expect').default +const DEBUG_PACKET_IO = false + function assertPosEqual (actual, expected, precision = 1) { expect(actual.distanceTo(expected)).toBeLessThan(precision) } -function sleep (ms) { - return new Promise(resolve => setTimeout(resolve, ms)) +function waitForPromises (map, timeout) { + const promises = Object.entries(map) + let count = promises.length + console.log('🤚 Waiting for', promises.map(([name]) => name)) + return new Promise((resolve, reject) => { + if (timeout) setTimeout(() => reject(new Error('Timeout waiting for promises')), timeout) + for (const [name, promise] of promises) { + promise.then(() => { + count-- + console.log('👍 Promise', name, 'resolved') + if (count === 0) { + resolve() + } + }) + } + }) } const { once } = require('events') @@ -46,8 +62,7 @@ squid.testedVersions.forEach((testedVersion, i) => { } async function waitMessage (bot, message) { - // const [msg1] = await once(bot, 'message') - // expect(msg1.extra[0].text).toEqual(message) + console.log('Waiting for message', [message]) onceWithTimeout(bot, 'message', 5000, (msg) => { console.log('*msg', msg) return msg.toString() === message @@ -90,6 +105,7 @@ squid.testedVersions.forEach((testedVersion, i) => { 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, @@ -103,7 +119,18 @@ squid.testedVersions.forEach((testedVersion, i) => { version: version.minecraftVersion }) - await Promise.all([once(bot, 'spawn'), once(bot2, 'spawn'), waitForReady(bot), waitForReady(bot2)]) + if (DEBUG_PACKET_IO) { + logClientboundEvents(serv, '') + logBotEvents(serv, bot, 1) + logBotEvents(serv, bot2, 2) + } + + await waitForPromises({ + 'bot spawn': once(bot, 'spawn'), + 'bot2 spawn': once(bot2, 'spawn'), + 'bot chunks': waitForReady(bot), + 'bot2 chunks': waitForReady(bot2) + }) bot.entity.onGround = false bot2.entity.onGround = false @@ -146,15 +173,17 @@ squid.testedVersions.forEach((testedVersion, i) => { const pos = bot.entity.position.offset(0, -1, 0).floored() // 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)) + await once(bot, `blockUpdate:${pos}`, 4000) + console.log('Block at', pos, bot.blockAt(pos)) - const p = once(bot2, 'blockUpdate') - bot.dig(bot.blockAt(pos)) + const p = onceWithTimeout(bot2, 'blockUpdate', 4000, (old, now) => { + return now.type === 0 + }) + await bot.dig(bot.blockAt(pos)) console.log('Digging...') const [, newBlock] = await p - console.log('Dug.') + console.log('Dug.', newBlock) assertPosEqual(newBlock.position, pos) expect(newBlock.type).toEqual(0) }) @@ -360,3 +389,51 @@ squid.testedVersions.forEach((testedVersion, i) => { }).timeout(120 * 1000) }).timeout(100 * 1000) }) + +BigInt.prototype.toJSON = function () { // eslint-disable-line no-extend-native + return this.toString() +} +const SKIP_PACKETS = ['update_time'] +function logBotEvents (serv, bot, prefix) { + bot._client.on('packet', (data, meta) => { + if (serv.isReady && !SKIP_PACKETS.includes(meta.name)) { + console.log(prefix, 'Packet', meta, JSON.stringify(data)?.slice(0, 60)) + } + }) + bot._client.on('state', (now, old) => { + console.log(prefix, '~ Client State Change', now, old) + }) + bot.on('kicked', (...a) => { + console.warn(prefix, '*Bot kicked', a) + }) + bot.on('error', (err) => { + console.error(prefix, '*Bot error', err) + process.exit(1) + }) + bot._client.socket.on('error', (err) => { + console.error(prefix, '*SOCKET Bot error', err) + process.exit(1) + }) + bot.on('end', (reason) => { + console.log(prefix, '*Bot END', reason) + }) + const oldWrite = bot._client.write + bot._client.write = (name, payload) => { + if (SKIP_PACKETS.includes(name)) return + console.log(prefix, 'C->S', [name], JSON.stringify(payload)?.slice(0, 60)) + return oldWrite.call(bot._client, name, payload) + } +} +function logClientboundEvents (serv, prefix = '') { + serv._server.on('connection', (client) => { + client.on('state', (now, old) => { + console.log(prefix, '~ Server State Change', now, old) + }) + const oldWrite = client.write + client.write = (name, param) => { + if (SKIP_PACKETS.includes(name)) return + console.log(prefix, 'S->C', client.username, [name], JSON.stringify(param)?.slice(0, 60)) + return oldWrite.call(client, name, param) + } + }) +}