diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deaedc4a9..9bf82ba3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,30 +9,39 @@ on: - master jobs: - test: + Lint: runs-on: ubuntu-latest - strategy: - matrix: - mcVersion: ['1.7', '1.8', '1.9', '1.10', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17', '1.17.1', '1.18.2', '1.19', '1.19.2', '1.19.3', '1.19.4', '1.20', '1.20.1'] - fail-fast: false steps: - uses: actions/checkout@v2 - name: Use Node.js 18.x - uses: actions/setup-node@v1 + uses: actions/setup-node@v1.4.4 with: node-version: 18.x - - name: Setup Java JDK - uses: actions/setup-java@v1.4.3 + - run: npm i && npm run lint + PrepareSupportedVersions: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 18.x + uses: actions/setup-node@v1.4.4 with: - java-version: '17' - distribution: 'adopt' - - name: Install dependencies - run: npm install - - name: Run tests - run: npm test -- -g ${{ matrix.mcVersion }} - packet-cycle-test: + node-version: 18.x + - id: set-matrix + run: | + node -e " + const supportedVersions = require('./src/version').supportedVersions; + console.log('matrix='+JSON.stringify({'include': supportedVersions.map(mcVersion => ({mcVersion}))})) + " >> $GITHUB_OUTPUT + test: + needs: PrepareSupportedVersions runs-on: ubuntu-latest + strategy: + matrix: ${{fromJson(needs.PrepareSupportedVersions.outputs.matrix)}} + fail-fast: false steps: - uses: actions/checkout@v2 @@ -43,6 +52,9 @@ jobs: - name: Setup Java JDK uses: actions/setup-java@v1.4.3 with: - java-version: '16' + java-version: '17' distribution: 'adopt' - - run: npm install && npm run test-non-par + - name: Install dependencies + run: npm install + - name: Run tests + run: npm run mochaTest -- -g ${{ matrix.mcVersion }}v diff --git a/.gitignore b/.gitignore index 9afc68587..f6816b81a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules test/npm-debug.log -test/server* +test/server_* package-lock.json versions/ src/client/*.json \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 85af2d650..88067bcaf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,7 +12,7 @@ automatically logged in and validated against mojang's auth. * kickTimeout : default to `10*1000` (10s), kick client that doesn't answer to keepalive after that time * checkTimeoutInterval : default to `4*1000` (4s), send keepalive packet at that period * online-mode : default to true - * beforePing : allow customisation of the answer to ping the server does. + * beforePing : allow customisation of the answer to ping the server does. It takes a function with argument response and client, response is the default json response, and client is client who sent a ping. It can take as third argument a callback. If the callback is passed, the function should pass its result to the callback, if not it should return. If the result is `false` instead of a response object then the connection is terminated and no ping is returned to the client. @@ -34,7 +34,8 @@ automatically logged in and validated against mojang's auth. * enforceSecureProfile (optional) : Kick clients that do not have chat signing keys from Mojang (1.19+) * generatePreview (optional) : Function to generate chat previews. Takes the raw message string and should return the message preview as a string. (1.19-1.19.2) * socketType (optional) : either `tcp` or `ipc`. Switches from a tcp connection to a ipc socket connection (or named pipes on windows). With the `ipc` option `host` becomes the path off the ipc connection on the local filesystem. Example: `\\.\pipe\minecraft-ipc` (Windows) `/tmp/minecraft-ipc.sock` (unix based systems). See the ipcConnection example for an example. - + * Server : You can pass a custom server class to use instead of the default one. + ## mc.Server(version,[customPackets]) Create a server instance for `version` of minecraft. @@ -88,6 +89,11 @@ Called when a client connects, but before any login has happened. Takes a Called when a client is logged in against server. Takes a `Client` parameter. +### `playerJoin` event + +Emitted after a player joins and enters the PLAY protocol state and can send and recieve game packets. This is emitted after the `login` event. On 1.20.2 and above after we emit the `login` event, the player will enter a CONFIG state, as opposed to the PLAY state (where game packets can be sent), so you must instead now wait for `playerJoin`. + + ### `listening` event Called when the server is listening for connections. This means that the server is ready to accept incoming connections. @@ -112,7 +118,7 @@ Returns a `Client` instance and perform login. is blank, and `profilesFolder` is specified, we auth with the tokens there instead. If neither `password` or `profilesFolder` are specified, we connect in offline mode. * host : default to localhost - * session : An object holding clientToken, accessToken and selectedProfile. Generated after logging in using username + password with mojang auth or after logging in using microsoft auth. `clientToken`, `accessToken` and `selectedProfile: {name: '', id: ''}` can be set inside of `session` when using createClient to login with a client and access Token instead of a password. `session` is also emitted by the `Client` instance with the event 'session' after successful authentication. + * session : An object holding clientToken, accessToken and selectedProfile. Generated after logging in using username + password with mojang auth or after logging in using microsoft auth. `clientToken`, `accessToken` and `selectedProfile: {name: '', id: ''}` can be set inside of `session` when using createClient to login with a client and access Token instead of a password. `session` is also emitted by the `Client` instance with the event 'session' after successful authentication. * clientToken : generated if a password is given or can be set when when using createClient * accessToken : generated if a password or microsoft account is given or can be set when using createBot * selectedProfile : generated if a password or microsoft account is given. Can be set as a object with property `name` and `id` that specifies the selected profile. @@ -129,21 +135,22 @@ Returns a `Client` instance and perform login. * hideErrors : do not display errors, default to false * skipValidation : do not try to validate given session, defaults to false * stream : a stream to use as connection - * connect : a function taking the client as parameter and that should client.setSocket(socket) + * connect : a function taking the client as parameter and that should client.setSocket(socket) and client.emit('connect') when appropriate (see the proxy examples for an example of use) - * agent : a http agent that can be used to set proxy settings for yggdrasil authentication (see proxy-agent on npm) + * agent : a http agent that can be used to set proxy settings for yggdrasil authentication (see proxy-agent on npm) * fakeHost : (optional) hostname to send to the server in the set_protocol packet * profilesFolder : optional - * (mojang account) the path to the folder that contains your `launcher_profiles.json`. defaults to your minecraft folder if it exists, otherwise the local directory. set to `false` to disable managing profiles + * (mojang account) the path to the folder that contains your `launcher_profiles.json`. defaults to your minecraft folder if it exists, otherwise the local directory. set to `false` to disable managing profiles * (microsoft account) the path to store authentication caches, defaults to .minecraft * onMsaCode(data) : (optional) callback called when signing in with a microsoft account with device code auth. `data` is an object documented [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#device-authorization-response) * id : a numeric client id used for referring to multiple clients in a server * validateChannelProtocol (optional) : whether or not to enable protocol validation for custom protocols using plugin channels. Defaults to true * disableChatSigning (optional) : Don't try obtaining chat signing keys from Mojang (1.19+) - * realms : An object which should contain one of the following properties: `realmId` or `pickRealm`. When defined will attempt to join a Realm without needing to specify host/port. **The authenticated account must either own the Realm or have been invited to it** - * realmId : The id of the Realm to join. - * pickRealm(realms) : A function which will have an array of the user Realms (joined/owned) passed to it. The function should return a Realm. + * realms : An object which should contain one of the following properties: `realmId` or `pickRealm`. When defined will attempt to join a Realm without needing to specify host/port. **The authenticated account must either own the Realm or have been invited to it** + * realmId : The id of the Realm to join. + * pickRealm(realms) : A function which will have an array of the user Realms (joined/owned) passed to it. The function should return a Realm. + * Client : You can pass a custom client class to use instead of the default one, which would allow you to create completely custom communication. Also note that you can use the `stream` option instead where you can supply custom duplex, but this will still use serialization/deserialization of packets. ## mc.Client(isServer,version,[customPackets]) @@ -231,11 +238,11 @@ The client's protocol version ### client.version -The client's version +The client's version, as a string ### `packet` event -Called with every packet parsed. Takes four paramaters, the JSON data we parsed, the packet metadata (name, state), the buffer (raw data) and the full buffer (includes surplus data and may include the data of following packets on versions below 1.8) +Called with every packet parsed. Takes four paramaters, the JSON data we parsed, the packet metadata (name, state), the buffer (raw data) and the full buffer (includes surplus data and may include the data of following packets on versions below 1.8) ### `raw` event @@ -259,6 +266,10 @@ Called when user authentication is resolved. Takes session data as parameter. Called when the protocol changes state. Takes the new state and old state as parameters. +### `playerJoin` event + +Emitted after the player enters the PLAY protocol state and can send and recieve game packets + ### `error` event Called when an error occurs within the client. Takes an Error as parameter. @@ -272,7 +283,7 @@ Called when a chat message from another player arrives. The emitted object conta * type -- the message type - on 1.19, which format string to use to render message ; below, the place where the message is displayed (for example chat or action bar) * sender -- the UUID of the player sending the message * senderTeam -- scoreboard team of the player (pre 1.19) -* senderName -- Name of the sender +* senderName -- Name of the sender * targetName -- Name of the target (for outgoing commands like /tell). Only in 1.19.2+ * verified -- true if message is signed, false if not signed, undefined on versions prior to 1.19 diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 410d9d0e8..5e8aa5449 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -1,5 +1,24 @@ # History +## 1.47.0 +* [1.20.3 / 1.20.4 support (#1275)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/1d9a38253a28a515d82fffa13806cb0874c5b36c) (thanks @wgaylord) + +## 1.46.0 +* [Ensure `onReady` in client is called once (#1287)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/85a26a52944c89af273bc974380b438073280981) (thanks @extremeheat) +* [Acknowledge returning to configuration state if in play state. (#1284)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/092e10c53d33a7b9be52b5cbb67b1e3e55ac2690) (thanks @wgaylord) +* [Allow commands not to be signed (#1277)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/21240f8ab2fd41c76f50b64e3b3a945f50b25b5e) (thanks @forester302) +* [Add test to make sure version that are tested are mentioned in the RE… (#1276)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/80d038bd61d1933daa1e5e3251635be9ce2116b6) (thanks @rom1504) +* [Print if there is a diff in packets in the cycle packet test (#1273)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/614be919d0f20a43e238751c829a6d584ae636cd) (thanks @rom1504) +* [Align supported versions with mineflayer (#1272)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/ccaf538ffd2ab1e25dabd752d721f97bd8bd188f) (thanks @rom1504) + +## 1.45.0 +* [Pc1.20.2 (#1265)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/112926da0cb2490934d122dd8ed7b79f3f6de8eb) (thanks @extremeheat) +* [Improve CI setup for per version tests (#1267)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/1740124c4722c2c49f8aed0d708ff5ebecc7743c) (thanks @rom1504) +* [Allow to create custom client & communication between clients (#1254)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/9e991094761d51243cb28a33bb45630f3064511d) (thanks @zardoy) +* [Fixed 'unsignedContent' field using nonexistent 'packet.unsignedContent' when emitting 'playerChat' event. (#1263)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/066a2b3646cb8bef6be1fa974597b975aaf08d42) (thanks @Ynfuien) +* [Add chat typing to client (#1260)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/788bff289030fa66c980de82d82cb953bf76332b) (thanks @IceTank) +* [chat: Only sign command args when profile keys defined (#1257)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/0ac8c087a28b3ccc73f8eea5941e4902e33c494e) (thanks @evan-goode) + ## 1.44.0 * [Send chat commands as chat commands instead of chat messages for 1.19.3-1.20.1 (#1241)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/41f9e4ac4a35b0ce241264a3f964c4874d96a119) (thanks @lkwilson) * [Fix end bundle bundle_delimiter packet not being emitted (#1248)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/35b2aa536a4739c11fe78f6e8e5c591abd0b0498) (thanks @PondWader) diff --git a/docs/README.md b/docs/README.md index 1a5e7a792..94bf117a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ Parse and serialize minecraft packets, plus authentication and encryption. * Supports Minecraft PC version 1.7.10, 1.8.8, 1.9 (15w40b, 1.9, 1.9.1-pre2, 1.9.2, 1.9.4), 1.10 (16w20a, 1.10-pre1, 1.10, 1.10.1, 1.10.2), 1.11 (16w35a, 1.11, 1.11.2), 1.12 (17w15a, 17w18b, 1.12-pre4, 1.12, 1.12.1, 1.12.2), and 1.13 (17w50a, 1.13, 1.13.1, 1.13.2-pre1, 1.13.2-pre2, 1.13.2), 1.14 (1.14, 1.14.1, 1.14.3, 1.14.4) - , 1.15 (1.15, 1.15.1, 1.15.2) and 1.16 (20w13b, 20w14a, 1.16-rc1, 1.16, 1.16.1, 1.16.2, 1.16.3, 1.16.4), 1.17 (21w07a, 1.17, 1.17.1), 1.18 (1.18, 1.18.1 and 1.18.2), 1.19 (1.19, 1.19.1, 1.19.2, 1.19.3, 1.19.4), 1.20 (1.20, 1.20.1) + , 1.15 (1.15, 1.15.1, 1.15.2) and 1.16 (20w13b, 20w14a, 1.16-rc1, 1.16, 1.16.1, 1.16.2, 1.16.3, 1.16.4, 1.16.5), 1.17 (21w07a, 1.17, 1.17.1), 1.18 (1.18, 1.18.1 and 1.18.2), 1.19 (1.19, 1.19.1, 1.19.2, 1.19.3, 1.19.4), 1.20 (1.20, 1.20.1, 1.20.2, 1.20.3 and 1.20.4) * Parses all packets and emits events with packet fields as JavaScript objects. * Send a packet by supplying fields as a JavaScript object. @@ -22,7 +22,8 @@ Parse and serialize minecraft packets, plus authentication and encryption. - Encryption - Compression - Both online and offline mode - - Respond to keep-alive packets. + - Respond to keep-alive packets + - Follow DNS service records (SRV) - Ping a server for status * Server - Online/Offline mode @@ -75,29 +76,30 @@ node-minecraft-protocol is pluggable. const mc = require('minecraft-protocol'); const client = mc.createClient({ host: "localhost", // optional - port: 25565, // optional - username: "email@example.com", - password: "12345678", - auth: 'microsoft' // optional; by default uses offline mode, if using a microsoft account, set to 'microsoft' + port: 25565, // set if you need a port that isn't 25565 + username: 'Bot', // username to join as if auth is `offline`, else a unique identifier for this account. Switch if you want to change accounts + // version: false, // only set if you need a specific version or snapshot (ie: "1.8.9" or "1.16.5"), otherwise it's set automatically + // password: '12345678' // set if you want to use password-based auth (may be unreliable). If specified, the `username` must be an email }); -client.on('chat', function(packet) { +client.on('playerChat', function (ev) { // Listen for chat messages and echo them back. - const jsonMsg = JSON.parse(packet.message); - - if (jsonMsg.translate == 'chat.type.announcement' || jsonMsg.translate == 'chat.type.text') { - const username = jsonMsg.with[0].text; - const msg = jsonMsg.with[1]; - - if (username === client.username) return; - - client.write('chat', {message: msg.text}); - } + const content = ev.formattedMessage + ? JSON.parse(ev.formattedMessage) + : ev.unsignedChat + ? JSON.parse(ev.unsignedContent) + : ev.plainMessage + const jsonMsg = JSON.parse(packet.message) + if (ev.senderName === client.username) return + client.chat(JSON.stringify(content)) }); ``` -If the server is in offline mode, you may leave out the `password` option and switch auth to `offline`. -You can also leave out `password` when using a Microsoft account. If provided, password based auth will be attempted first which may fail. *Note:* if using a Microsoft account, your account age must be >= 18 years old. +Set `auth` to `offline` if the server is in offline mode. If `auth` is set to `microsoft`, you will be prompted to login to microsoft.com with a code in your browser. After signing in on your browser, the client will automatically obtain and cache authentication tokens (under your specified username) so you don't have to sign-in again. + +To switch the account, update the supplied username. By default, cached tokens will be stored in your user's .minecraft folder, or if profilesFolder is specified, they'll instead be stored there. For more information on bot options see the [API doc](./API.md). + +Note: SRV records will only be looked up if the port is unspecified or set to 25565 and if the `host` is a valid non-local domain name. ### Client example joining a Realm @@ -115,29 +117,32 @@ const client = mc.createClient({ ### Hello World server example +For a more up to date example, see examples/server/server.js. + ```js -const mc = require('minecraft-protocol'); +const mc = require('minecraft-protocol') +const nbt = require('prismarine-nbt') const server = mc.createServer({ 'online-mode': true, // optional encryption: true, // optional host: '0.0.0.0', // optional port: 25565, // optional - version: '1.16.3' -}); + version: '1.18' +}) const mcData = require('minecraft-data')(server.version) -server.on('login', function(client) { +function chatText (text) { + return mcData.supportFeature('chatPacketsUseNbtComponents') + ? nbt.comp({ text: nbt.string(text) }) + : JSON.stringify({ text }) +} + +server.on('playerJoin', function(client) { const loginPacket = mcData.loginPacket client.write('login', { + ...loginPacket, entityId: client.id, - isHardcore: false, - gameMode: 0, - previousGameMode: 255, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, - worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, viewDistance: 10, @@ -145,7 +150,7 @@ server.on('login', function(client) { enableRespawnScreen: true, isDebug: false, isFlat: false - }); + }) client.write('position', { x: 0, @@ -154,18 +159,35 @@ server.on('login', function(client) { yaw: 0, pitch: 0, flags: 0x00 - }); + }) - const msg = { + const message = { translate: 'chat.type.announcement', - "with": [ + with: [ 'Server', 'Hello, world!' ] - }; - - client.write("chat", { message: JSON.stringify(msg), position: 0, sender: '0' }); -}); + } + if (mcData.supportFeature('signedChat')) { + client.write('player_chat', { + plainMessage: message, + signedChatContent: '', + unsignedChatContent: chatText(message), + type: 0, + senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random + senderName: JSON.stringify({ text: 'me' }), + senderTeam: undefined, + timestamp: Date.now(), + salt: 0n, + signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), + previousMessages: [], + filterType: 0, + networkName: JSON.stringify({ text: 'me' }) + }) + } else { + client.write('chat', { message: JSON.stringify({ text: message }), position: 0, sender: 'me' }) + } +}) ``` ## Testing diff --git a/examples/server/server.js b/examples/server/server.js index 4604c87a2..bf639702a 100644 --- a/examples/server/server.js +++ b/examples/server/server.js @@ -1,4 +1,5 @@ const mc = require('minecraft-protocol') +const nbt = require('prismarine-nbt') const options = { motd: 'Vox Industries', @@ -10,8 +11,13 @@ const options = { const server = mc.createServer(options) const mcData = require('minecraft-data')(server.version) const loginPacket = mcData.loginPacket +function chatText (text) { + return mcData.supportFeature('chatPacketsUseNbtComponents') + ? nbt.comp({ text: nbt.string(text) }) + : JSON.stringify({ text }) +} -server.on('login', function (client) { +server.on('playerJoin', function (client) { broadcast(client.username + ' joined the game.') const addr = client.socket.remoteAddress + ':' + client.socket.remotePort console.log(client.username + ' connected', '(' + addr + ')') @@ -23,14 +29,11 @@ server.on('login', function (client) { // send init data so client will start rendering world client.write('login', { + ...loginPacket, entityId: client.id, isHardcore: false, gameMode: 0, previousGameMode: 1, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, - worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, viewDistance: 10, @@ -48,11 +51,13 @@ server.on('login', function (client) { flags: 0x00 }) - client.on('chat', function (data) { + function handleChat (data) { const message = '<' + client.username + '>' + ' ' + data.message broadcast(message, null, client.username) console.log(message) - }) + } + client.on('chat', handleChat) // pre-1.19 + client.on('chat_message', handleChat) // post 1.19 }) server.on('error', function (error) { @@ -63,27 +68,28 @@ server.on('listening', function () { console.log('Server listening on port', server.socketServer.address().port) }) -function broadcast (message, exclude, username) { - let client - const translate = username ? 'chat.type.announcement' : 'chat.type.text' - username = username || 'Server' - for (const clientId in server.clients) { - if (server.clients[clientId] === undefined) continue - - client = server.clients[clientId] - if (client !== exclude) { - const msg = { - translate, - with: [ - username, - message - ] - } - client.write('chat', { - message: JSON.stringify(msg), - position: 0, - sender: '0' - }) - } +function sendBroadcastMessage (server, clients, message, sender) { + if (mcData.supportFeature('signedChat')) { + server.writeToClients(clients, 'player_chat', { + plainMessage: message, + signedChatContent: '', + unsignedChatContent: chatText(message), + type: 0, + senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random + senderName: JSON.stringify({ text: sender }), + senderTeam: undefined, + timestamp: Date.now(), + salt: 0n, + signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), + previousMessages: [], + filterType: 0, + networkName: JSON.stringify({ text: sender }) + }) + } else { + server.writeToClients(clients, 'chat', { message: JSON.stringify({ text: message }), position: 0, sender: sender || '0' }) } } + +function broadcast (message, exclude, username) { + sendBroadcastMessage(server, Object.values(server.clients).filter(client => client !== exclude), message) +} diff --git a/examples/server_channel/server_channel.js b/examples/server_channel/server_channel.js index 28e842939..72f00cf01 100644 --- a/examples/server_channel/server_channel.js +++ b/examples/server_channel/server_channel.js @@ -8,18 +8,16 @@ const server = mc.createServer({ const mcData = require('minecraft-data')(server.version) const loginPacket = mcData.loginPacket -server.on('login', function (client) { +server.on('playerJoin', function (client) { client.registerChannel('minecraft:brand', ['string', []]) client.on('minecraft:brand', console.log) client.write('login', { + ...loginPacket, entityId: client.id, isHardcore: false, gameMode: 0, previousGameMode: 1, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, diff --git a/examples/server_custom_channel/server_custom_channel.js b/examples/server_custom_channel/server_custom_channel.js index 040fe80c0..69f2b1014 100644 --- a/examples/server_custom_channel/server_custom_channel.js +++ b/examples/server_custom_channel/server_custom_channel.js @@ -8,15 +8,13 @@ const server = mc.createServer({ const mcData = require('minecraft-data')(server.version) const loginPacket = mcData.loginPacket -server.on('login', function (client) { +server.on('playerJoin', function (client) { client.write('login', { + ...loginPacket, entityId: client.id, isHardcore: false, gameMode: 0, previousGameMode: 1, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, diff --git a/examples/server_helloworld/server_helloworld.js b/examples/server_helloworld/server_helloworld.js index d752a3010..3391717e2 100644 --- a/examples/server_helloworld/server_helloworld.js +++ b/examples/server_helloworld/server_helloworld.js @@ -8,8 +8,15 @@ const options = { const server = mc.createServer(options) const mcData = require('minecraft-data')(server.version) const loginPacket = mcData.loginPacket +const nbt = require('prismarine-nbt') -server.on('login', function (client) { +function chatText (text) { + return mcData.supportFeature('chatPacketsUseNbtComponents') + ? nbt.comp({ text: nbt.string(text) }) + : JSON.stringify({ text }) +} + +server.on('playerJoin', function (client) { const addr = client.socket.remoteAddress console.log('Incoming connection', '(' + addr + ')') @@ -49,14 +56,32 @@ server.on('login', function (client) { flags: 0x00 }) - const msg = { + const message = { translate: 'chat.type.announcement', with: [ 'Server', 'Hello, world!' ] } - client.write('chat', { message: JSON.stringify(msg), position: 0, sender: '0' }) + if (mcData.supportFeature('signedChat')) { + client.write('player_chat', { + plainMessage: message, + signedChatContent: '', + unsignedChatContent: chatText(message), + type: 0, + senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random + senderName: JSON.stringify({ text: 'me' }), + senderTeam: undefined, + timestamp: Date.now(), + salt: 0n, + signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), + previousMessages: [], + filterType: 0, + networkName: JSON.stringify({ text: 'me' }) + }) + } else { + client.write('chat', { message: JSON.stringify({ text: message }), position: 0, sender: 'me' }) + } }) server.on('error', function (error) { diff --git a/examples/server_world/mc.js b/examples/server_world/mc.js index 7acf27ca0..eb0975ee9 100644 --- a/examples/server_world/mc.js +++ b/examples/server_world/mc.js @@ -22,15 +22,13 @@ for (let x = 0; x < 16; x++) { } } -server.on('login', function (client) { +server.on('playerJoin', function (client) { client.write('login', { + ...loginPacket, entityId: client.id, isHardcore: false, gameMode: 0, previousGameMode: 1, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, diff --git a/package.json b/package.json index 8dcff3d15..f705215b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-protocol", - "version": "1.44.0", + "version": "1.47.0", "description": "Parse and serialize minecraft packets, plus authentication and encryption.", "main": "src/index.js", "types": "src/index.d.ts", @@ -9,8 +9,8 @@ "url": "git://github.com/PrismarineJS/node-minecraft-protocol.git" }, "scripts": { - "test": "mocha --recursive --reporter spec --exit --exclude \"non-par-test.js\"", - "test-non-par": "mocha --recursive --reporter spec --exit \"test/non-par-test.js\"", + "test": "npm run mochaTest", + "mochaTest": "mocha --recursive --reporter spec --exit", "lint": "standard", "fix": "standard --fix", "pretest": "npm run lint", @@ -51,12 +51,13 @@ "endian-toggle": "^0.0.0", "lodash.get": "^4.1.2", "lodash.merge": "^4.3.0", - "minecraft-data": "^3.37.0", + "minecraft-data": "^3.55.0", "minecraft-folder-path": "^1.2.0", "node-fetch": "^2.6.1", "node-rsa": "^0.4.2", "prismarine-auth": "^2.2.0", - "prismarine-nbt": "^2.0.0", + "prismarine-chat": "^1.10.0", + "prismarine-nbt": "^2.5.0", "prismarine-realms": "^1.2.0", "protodef": "^1.8.0", "readable-stream": "^4.1.0", diff --git a/src/client.js b/src/client.js index 6f1182337..c89375e32 100644 --- a/src/client.js +++ b/src/client.js @@ -93,7 +93,7 @@ class Client extends EventEmitter { const s = JSON.stringify(parsed.data, null, 2) debug(s && s.length > 10000 ? parsed.data : s) } - if (parsed.metadata.name === 'bundle_delimiter') { + if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') { if (this._mcBundle.length) { // End bundle this._mcBundle.forEach(emitPacket) emitPacket(parsed) @@ -103,6 +103,11 @@ class Client extends EventEmitter { } } else if (this._mcBundle.length) { this._mcBundle.push(parsed) + if (this._mcBundle.length > 32) { + this._mcBundle.forEach(emitPacket) + this._mcBundle = [] + this._hasBundlePacket = false + } } else { emitPacket(parsed) } diff --git a/src/client/chat.js b/src/client/chat.js index 5674f1f1c..5cad9954d 100644 --- a/src/client/chat.js +++ b/src/client/chat.js @@ -1,5 +1,6 @@ const crypto = require('crypto') const concat = require('../transforms/binaryStream').concat +const { processNbtMessage } = require('prismarine-chat') const messageExpireTime = 420000 // 7 minutes (ms) function isFormatted (message) { @@ -25,6 +26,10 @@ module.exports = function (client, options) { // This stores the last n (5 or 20) messages that the player has seen, from unique players if (mcData.supportFeature('chainedChatWithHashing')) client._lastSeenMessages = new LastSeenMessages() else client._lastSeenMessages = new LastSeenMessagesWithInvalidation() + // 1.20.3+ serializes chat components in either NBT or JSON. If the chat is sent as NBT, then the structure read will differ + // from the normal JSON structure, so it needs to be normalized. prismarine-chat processNbtMessage will do that by default + // on a fromNotch call. Since we don't call fromNotch here (done in mineflayer), we manually call processNbtMessage + const processMessage = (msg) => mcData.supportFeature('chatPacketsUseNbtComponents') ? processNbtMessage(msg) : msg // This stores the last 128 inbound (signed) messages for 1.19.3 chat validation client._signatureCache = new SignatureCache() @@ -139,12 +144,11 @@ module.exports = function (client, options) { client.on('profileless_chat', (packet) => { // Profileless chat is parsed as an unsigned player chat message but logged as a system message - client.emit('playerChat', { - formattedMessage: packet.message, + formattedMessage: processMessage(packet.message), type: packet.type, - senderName: packet.name, - targetName: packet.target, + senderName: processMessage(packet.name), + targetName: processMessage(packet.target), verified: false }) @@ -160,7 +164,7 @@ module.exports = function (client, options) { client.on('system_chat', (packet) => { client.emit('systemChat', { positionId: packet.isActionBar ? 2 : 1, - formattedMessage: packet.content + formattedMessage: processMessage(packet.content) }) client._lastChatHistory.push({ @@ -198,11 +202,11 @@ module.exports = function (client, options) { if (verified) client._signatureCache.push(packet.signature) client.emit('playerChat', { plainMessage: packet.plainMessage, - unsignedContent: packet.unsignedContent, + unsignedContent: processMessage(packet.unsignedChatContent), type: packet.type, sender: packet.senderUuid, - senderName: packet.networkName, - targetName: packet.networkTargetName, + senderName: processMessage(packet.networkName), + targetName: processMessage(packet.networkTargetName), verified }) @@ -371,7 +375,7 @@ module.exports = function (client, options) { command, timestamp: options.timestamp, salt: options.salt, - argumentSignatures: signaturesForCommand(command, options.timestamp, options.salt, options.preview, acknowledgements), + argumentSignatures: (client.profileKeys && client._session) ? signaturesForCommand(command, options.timestamp, options.salt, options.preview, acknowledgements) : [], messageCount: client._lastSeenMessages.pending, acknowledged }) @@ -381,7 +385,7 @@ module.exports = function (client, options) { command, timestamp: options.timestamp, salt: options.salt, - argumentSignatures: signaturesForCommand(command, options.timestamp, options.salt), + argumentSignatures: client.profileKeys ? signaturesForCommand(command, options.timestamp, options.salt) : [], signedPreview: options.didPreview, previousMessages: client._lastSeenMessages.map((e) => ({ messageSender: e.sender, diff --git a/src/client/play.js b/src/client/play.js index 14d280433..246556e0f 100644 --- a/src/client/play.js +++ b/src/client/play.js @@ -32,30 +32,58 @@ module.exports = function (client, options) { function onLogin (packet) { const mcData = require('minecraft-data')(client.version) - client.state = states.PLAY client.uuid = packet.uuid client.username = packet.username - if (mcData.supportFeature('signedChat')) { - if (options.disableChatSigning && client.serverFeatures.enforcesSecureChat) { - throw new Error('"disableChatSigning" was enabled in client options, but server is enforcing secure chat') - } - signedChatPlugin(client, options) + if (mcData.supportFeature('hasConfigurationState')) { + client.write('login_acknowledged', {}) + enterConfigState(onReady) + // Server can tell client to re-enter config state + client.on('start_configuration', () => enterConfigState()) } else { - client.on('chat', (packet) => { - client.emit(packet.position === 0 ? 'playerChat' : 'systemChat', { - formattedMessage: packet.message, - sender: packet.sender, - positionId: packet.position, - verified: false - }) - }) + client.state = states.PLAY + onReady() } - function unsignedChat (message) { - client.write('chat', { message }) + function enterConfigState (finishCb) { + if (client.state === states.CONFIGURATION) return + // If we are returning to the configuration state from the play state, we ahve to acknowledge it. + if (client.state === states.PLAY) { + client.write('configuration_acknowledged', {}) + } + client.state = states.CONFIGURATION + // Server should send finish_configuration on its own right after sending the client a dimension codec + // for login (that has data about world height, world gen, etc) after getting a login success from client + client.once('finish_configuration', () => { + client.write('finish_configuration', {}) + client.state = states.PLAY + finishCb?.() + }) } - client.chat = client._signedChat || unsignedChat + function onReady () { + client.emit('playerJoin') + if (mcData.supportFeature('signedChat')) { + if (options.disableChatSigning && client.serverFeatures.enforcesSecureChat) { + throw new Error('"disableChatSigning" was enabled in client options, but server is enforcing secure chat') + } + signedChatPlugin(client, options) + } else { + client.on('chat', (packet) => { + client.emit(packet.position === 0 ? 'playerChat' : 'systemChat', { + formattedMessage: packet.message, + sender: packet.sender, + positionId: packet.position, + verified: false + }) + }) + } + + function unsignedChat (message) { + client.write('chat', { message }) + } + + client.chat = client._signedChat || unsignedChat + } } } diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js index dfcb2ffc8..671eb452f 100644 --- a/src/client/pluginChannels.js +++ b/src/client/pluginChannels.js @@ -1,11 +1,13 @@ const ProtoDef = require('protodef').ProtoDef const minecraft = require('../datatypes/minecraft') const debug = require('debug')('minecraft-protocol') +const nbt = require('prismarine-nbt') module.exports = function (client, options) { const mcdata = require('minecraft-data')(options.version || require('../version').defaultVersion) const channels = [] const proto = new ProtoDef(options.validateChannelProtocol ?? true) + nbt.addTypesToInterpreter('big', proto) proto.addTypes(mcdata.protocol.types) proto.addTypes(minecraft) proto.addType('registerarr', [readDumbArr, writeDumbArr, sizeOfDumbArr]) diff --git a/src/client/setProtocol.js b/src/client/setProtocol.js index e656744b4..3842f45ae 100644 --- a/src/client/setProtocol.js +++ b/src/client/setProtocol.js @@ -37,7 +37,7 @@ module.exports = function (client, options) { : client.profileKeys.signature } : null, - playerUUID: client.session?.selectedProfile?.id + playerUUID: client.session?.selectedProfile?.id ?? client.uuid }) } } diff --git a/src/client/versionChecking.js b/src/client/versionChecking.js index 9d27955ae..ab9833fd5 100644 --- a/src/client/versionChecking.js +++ b/src/client/versionChecking.js @@ -1,6 +1,11 @@ +const states = require('../states') + module.exports = function (client, options) { client.on('disconnect', message => { if (!message.reason) { return } + // Prevent the disconnect packet handler in the versionChecking code from triggering on PLAY or CONFIGURATION state disconnects + // Since version checking only happens during that HANDSHAKE / LOGIN state. + if (client.state === states.PLAY || client.state === states.CONFIGURATION) { return } let parsed try { parsed = JSON.parse(message.reason) @@ -11,7 +16,9 @@ module.exports = function (client, options) { let text = parsed.text ? parsed.text : parsed let versionRequired - if (text.translate && text.translate.startsWith('multiplayer.disconnect.outdated_')) { versionRequired = text.with[0] } else { + if (text.translate && (text.translate.startsWith('multiplayer.disconnect.outdated_') || text.translate.startsWith('multiplayer.disconnect.incompatible'))) { + versionRequired = text.with[0] + } else { if (text.extra) text = text.extra[0].text versionRequired = /(?:Outdated client! Please use|Outdated server! I'm still on) (.+)/.exec(text) versionRequired = versionRequired ? versionRequired[1] : null diff --git a/src/createClient.js b/src/createClient.js index 1c4f0330a..10cacc070 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -1,6 +1,6 @@ 'use strict' -const Client = require('./client') +const DefaultClientImpl = require('./client') const assert = require('assert') const encrypt = require('./client/encrypt') @@ -14,6 +14,7 @@ const tcpDns = require('./client/tcp_dns') const autoVersion = require('./client/autoVersion') const pluginChannels = require('./client/pluginChannels') const versionChecking = require('./client/versionChecking') +const uuid = require('./datatypes/uuid') module.exports = createClient @@ -31,6 +32,7 @@ function createClient (options) { options.majorVersion = version.majorVersion options.protocolVersion = version.version const hideErrors = options.hideErrors || false + const Client = options.Client || DefaultClientImpl const client = new Client(false, version.minecraftVersion, options.customPackets, hideErrors) @@ -53,6 +55,8 @@ function createClient (options) { case 'offline': default: client.username = options.username + client.uuid = uuid.nameToMcOfflineUUID(client.username) + options.auth = 'offline' options.connect(client) break } diff --git a/src/createServer.js b/src/createServer.js index 4fa3477ee..4a56c0ad9 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,6 +1,6 @@ 'use strict' -const Server = require('./server') +const DefaultServerImpl = require('./server') const NodeRSA = require('node-rsa') const plugins = [ require('./server/handshake'), @@ -20,6 +20,7 @@ function createServer (options = {}) { motd = 'A Minecraft server', 'max-players': maxPlayersOld = 20, maxPlayers: maxPlayersNew = 20, + Server = DefaultServerImpl, version, favicon, customPackets, @@ -45,6 +46,7 @@ function createServer (options = {}) { server.onlineModeExceptions = Object.create(null) server.favicon = favicon server.options = options + options.registryCodec = options.registryCodec || mcData.registryCodec || mcData.loginPacket?.dimensionCodec // The RSA keypair can take some time to generate // and is only needed for online-mode diff --git a/src/datatypes/compiler-minecraft.js b/src/datatypes/compiler-minecraft.js index cac252609..89cacde04 100644 --- a/src/datatypes/compiler-minecraft.js +++ b/src/datatypes/compiler-minecraft.js @@ -16,8 +16,6 @@ module.exports = { size: buffer.length - offset } }], - nbt: ['native', minecraft.nbt[0]], - optionalNbt: ['native', minecraft.optionalNbt[0]], compressedNbt: ['native', minecraft.compressedNbt[0]], entityMetadataLoop: ['parametrizable', (compiler, { type, endVal }) => { let code = 'let cursor = offset\n' @@ -55,8 +53,6 @@ module.exports = { value.copy(buffer, offset) return offset + value.length }], - nbt: ['native', minecraft.nbt[1]], - optionalNbt: ['native', minecraft.optionalNbt[1]], compressedNbt: ['native', minecraft.compressedNbt[1]], entityMetadataLoop: ['parametrizable', (compiler, { type, endVal }) => { let code = 'for (const i in value) {\n' @@ -84,8 +80,6 @@ module.exports = { restBuffer: ['native', (value) => { return value.length }], - nbt: ['native', minecraft.nbt[2]], - optionalNbt: ['native', minecraft.optionalNbt[2]], compressedNbt: ['native', minecraft.compressedNbt[2]], entityMetadataLoop: ['parametrizable', (compiler, { type }) => { let code = 'let size = 1\n' diff --git a/src/datatypes/minecraft.js b/src/datatypes/minecraft.js index eb9ef2498..09e90355a 100644 --- a/src/datatypes/minecraft.js +++ b/src/datatypes/minecraft.js @@ -8,8 +8,6 @@ const [readVarInt, writeVarInt, sizeOfVarInt] = require('protodef').types.varint module.exports = { varlong: [readVarLong, writeVarLong, sizeOfVarLong], UUID: [readUUID, writeUUID, 16], - nbt: [readNbt, writeNbt, sizeOfNbt], - optionalNbt: [readOptionalNbt, writeOptionalNbt, sizeOfOptionalNbt], compressedNbt: [readCompressedNbt, writeCompressedNbt, sizeOfCompressedNbt], restBuffer: [readRestBuffer, writeRestBuffer, sizeOfRestBuffer], entityMetadataLoop: [readEntityMetadata, writeEntityMetadata, sizeOfEntityMetadata], @@ -43,35 +41,8 @@ function writeUUID (value, buffer, offset) { return offset + 16 } -function readNbt (buffer, offset) { - return nbt.proto.read(buffer, offset, 'nbt') -} - -function writeNbt (value, buffer, offset) { - return nbt.proto.write(value, buffer, offset, 'nbt') -} - -function sizeOfNbt (value) { - return nbt.proto.sizeOf(value, 'nbt') -} - -function readOptionalNbt (buffer, offset) { - if (offset + 1 > buffer.length) { throw new PartialReadError() } - if (buffer.readInt8(offset) === 0) return { size: 1 } - return nbt.proto.read(buffer, offset, 'nbt') -} - -function writeOptionalNbt (value, buffer, offset) { - if (value === undefined) { - buffer.writeInt8(0, offset) - return offset + 1 - } - return nbt.proto.write(value, buffer, offset, 'nbt') -} - -function sizeOfOptionalNbt (value) { - if (value === undefined) { return 1 } - return nbt.proto.sizeOf(value, 'nbt') +function sizeOfNbt (value, { tagType } = { tagType: 'nbt' }) { + return nbt.proto.sizeOf(value, tagType) } // Length-prefixed compressed NBT, see differences: http://wiki.vg/index.php?title=Slot_Data&diff=6056&oldid=4753 @@ -111,7 +82,7 @@ function writeCompressedNbt (value, buffer, offset) { function sizeOfCompressedNbt (value) { if (value === undefined) { return 2 } - const nbtBuffer = Buffer.alloc(sizeOfNbt(value, 'nbt')) + const nbtBuffer = Buffer.alloc(sizeOfNbt(value, { tagType: 'nbt' })) nbt.proto.write(value, nbtBuffer, 0, 'nbt') const compressedNbt = zlib.gzipSync(nbtBuffer) // TODO: async diff --git a/src/datatypes/uuid.js b/src/datatypes/uuid.js new file mode 100644 index 000000000..23e9bbd8e --- /dev/null +++ b/src/datatypes/uuid.js @@ -0,0 +1,24 @@ +const crypto = require('crypto') +const UUID = require('uuid-1345') + +// https://github.com/openjdk-mirror/jdk7u-jdk/blob/f4d80957e89a19a29bb9f9807d2a28351ed7f7df/src/share/classes/java/util/UUID.java#L163 +function javaUUID (s) { + const hash = crypto.createHash('md5') + hash.update(s, 'utf8') + const buffer = hash.digest() + buffer[6] = (buffer[6] & 0x0f) | 0x30 + buffer[8] = (buffer[8] & 0x3f) | 0x80 + return buffer +} + +function nameToMcOfflineUUID (name) { + return (new UUID(javaUUID('OfflinePlayer:' + name))).toString() +} + +function fromIntArray (arr) { + const buf = Buffer.alloc(16) + arr.forEach((num, index) => { buf.writeInt32BE(num, index * 4) }) + return buf.toString('hex') +} + +module.exports = { nameToMcOfflineUUID, fromIntArray } diff --git a/src/index.d.ts b/src/index.d.ts index 7135cbb5a..70b86cc4d 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -5,7 +5,7 @@ import { Socket } from 'net' import * as Stream from 'stream' import { Agent } from 'http' import { Transform } from "readable-stream"; -import { KeyObject } from 'crypto'; +import { BinaryLike, KeyObject } from 'crypto'; import { Realm } from "prismarine-realms" type PromiseLike = Promise | void @@ -39,6 +39,7 @@ declare module 'minecraft-protocol' { signMessage(message: string, timestamp: BigInt, salt?: number, preview?: string, acknowledgements?: Buffer[]): Buffer verifyMessage(publicKey: Buffer | KeyObject, packet: object): boolean reportPlayer(uuid: string, reason: 'FALSE_REPORTING' | 'HATE_SPEECH' | 'TERRORISM_OR_VIOLENT_EXTREMISM' | 'CHILD_SEXUAL_EXPLOITATION_OR_ABUSE' | 'IMMINENT_HARM' | 'NON_CONSENSUAL_INTIMATE_IMAGERY' | 'HARASSMENT_OR_BULLYING' | 'DEFAMATION_IMPERSONATION_FALSE_INFORMATION' | 'SELF_HARM_OR_SUICIDE' | 'ALCOHOL_TOBACCO_DRUGS', signatures: Buffer[], comment?: string): Promise + chat(message: string, options?: { timestamp?: BigInt, salt?: BigInt, preview?: BinaryLike, didPreview?: boolean }): void on(event: 'error', listener: (error: Error) => PromiseLike): this on(event: 'packet', handler: (data: any, packetMeta: PacketMeta, buffer: Buffer, fullBuffer: Buffer) => PromiseLike): this on(event: 'raw', handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this @@ -48,8 +49,10 @@ declare module 'minecraft-protocol' { on(event: 'connect', handler: () => PromiseLike): this on(event: string, handler: (data: any, packetMeta: PacketMeta) => PromiseLike): this on(event: `raw.${string}`, handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this - on(event: 'playerChat', handler: (data: { formattedMessage: string, message: string, type: string, sender: string, senderName: string, senderTeam: string, verified?: boolean }) => PromiseLike): this + on(event: 'playerChat', handler: (data: { formattedMessage: string, plainMessage: string, type: string, sender: string, senderName: string, senderTeam: string, verified?: boolean }) => PromiseLike): this on(event: 'systemChat', handler: (data: { positionId: number, formattedMessage: string }) => PromiseLike): this + // Emitted after the player enters the PLAY state and can send and recieve game packets + on(event: 'playerJoin', handler: () => void): this once(event: 'error', listener: (error: Error) => PromiseLike): this once(event: 'packet', handler: (data: any, packetMeta: PacketMeta, buffer: Buffer, fullBuffer: Buffer) => PromiseLike): this once(event: 'raw', handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this @@ -117,7 +120,7 @@ declare module 'minecraft-protocol' { authTitle?: string sessionServer?: string keepAlive?: boolean - closeTimeout?: number + closeTimeout?: number noPongTimeout?: number checkTimeoutInterval?: number version?: string @@ -136,6 +139,8 @@ declare module 'minecraft-protocol' { realms?: RealmsOptions // 1.19+ disableChatSigning?: boolean + /** Pass custom client implementation if needed. */ + Client?: Client } export class Server extends EventEmitter { @@ -153,6 +158,8 @@ declare module 'minecraft-protocol' { on(event: 'error', listener: (error: Error) => PromiseLike): this on(event: 'login', handler: (client: ServerClient) => PromiseLike): this on(event: 'listening', listener: () => PromiseLike): this + // Emitted after the player enters the PLAY state and can send and recieve game packets + on(event: 'playerJoin', handler: (client: ServerClient) => void): this once(event: 'connection', handler: (client: ServerClient) => PromiseLike): this once(event: 'error', listener: (error: Error) => PromiseLike): this once(event: 'login', handler: (client: ServerClient) => PromiseLike): this @@ -161,9 +168,9 @@ declare module 'minecraft-protocol' { export interface ServerClient extends Client { id: number - // You must call this function when the server receives a message from a player and that message gets - // broadcast to other players in player_chat packets. This function stores these packets so the server - // can then verify a player's lastSeenMessages field in inbound chat packets to ensure chain integrity. + /** You must call this function when the server receives a message from a player and that message gets + broadcast to other players in player_chat packets. This function stores these packets so the server + can then verify a player's lastSeenMessages field in inbound chat packets to ensure chain integrity. */ logSentMessageFromPeer(packet: object): boolean } @@ -187,12 +194,12 @@ declare module 'minecraft-protocol' { hideErrors?: boolean agent?: Agent validateChannelProtocol?: boolean - // 1.19+ - // Require connecting clients to have chat signing support enabled + /** (1.19+) Require connecting clients to have chat signing support enabled */ enforceSecureProfile?: boolean - // 1.19.1 & 1.19.2 only: If client should send previews of messages they are typing to the server + /** 1.19.1 & 1.19.2 only: If client should send previews of messages they are typing to the server */ enableChatPreview?: boolean socketType?: 'tcp' | 'ipc' + Server?: Server } export interface SerializerOptions { @@ -201,7 +208,7 @@ declare module 'minecraft-protocol' { state?: States version: string } - + export interface MicrosoftDeviceAuthorizationResponse { device_code: string user_code: string diff --git a/src/server/login.js b/src/server/login.js index df52a3630..68dc27a86 100644 --- a/src/server/login.js +++ b/src/server/login.js @@ -1,4 +1,4 @@ -const UUID = require('uuid-1345') +const uuid = require('../datatypes/uuid') const crypto = require('crypto') const pluginChannels = require('../client/pluginChannels') const states = require('../states') @@ -166,37 +166,28 @@ module.exports = function (client, server, options) { } } - // https://github.com/openjdk-mirror/jdk7u-jdk/blob/f4d80957e89a19a29bb9f9807d2a28351ed7f7df/src/share/classes/java/util/UUID.java#L163 - function javaUUID (s) { - const hash = crypto.createHash('md5') - hash.update(s, 'utf8') - const buffer = hash.digest() - buffer[6] = (buffer[6] & 0x0f) | 0x30 - buffer[8] = (buffer[8] & 0x3f) | 0x80 - return buffer - } - - function nameToMcOfflineUUID (name) { - return (new UUID(javaUUID('OfflinePlayer:' + name))).toString() - } - function loginClient () { const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] if (onlineMode === false || isException) { - client.uuid = nameToMcOfflineUUID(client.username) + client.uuid = uuid.nameToMcOfflineUUID(client.username) } options.beforeLogin?.(client) if (client.protocolVersion >= 27) { // 14w28a (27) added whole-protocol compression (http://wiki.vg/Protocol_History#14w28a), earlier versions per-packet compressed TODO: refactor into minecraft-data client.write('compress', { threshold: 256 }) // Default threshold is 256 client.compressionThreshold = 256 } + // TODO: find out what properties are on 'success' packet client.write('success', { uuid: client.uuid, username: client.username, properties: [] }) - // TODO: find out what properties are on 'success' packet - client.state = states.PLAY + if (client.supportFeature('hasConfigurationState')) { + client.once('login_acknowledged', onClientLoginAck) + } else { + client.state = states.PLAY + server.emit('playerJoin', client) + } client.settings = {} if (client.supportFeature('chainedChatWithHashing')) { // 1.19.1+ @@ -217,6 +208,16 @@ module.exports = function (client, server, options) { if (client.supportFeature('signedChat')) chatPlugin(client, server, options) server.emit('login', client) } + + function onClientLoginAck () { + client.state = states.CONFIGURATION + client.write('registry_data', { codec: options.registryCodec || {} }) + client.once('finish_configuration', () => { + client.state = states.PLAY + server.emit('playerJoin', client) + }) + client.write('finish_configuration', {}) + } } function mcPubKeyToPem (mcPubKeyBuffer) { diff --git a/src/states.js b/src/states.js index ba4792fda..34bf360d0 100644 --- a/src/states.js +++ b/src/states.js @@ -4,6 +4,7 @@ const states = { HANDSHAKING: 'handshaking', STATUS: 'status', LOGIN: 'login', + CONFIGURATION: 'configuration', PLAY: 'play' } diff --git a/src/transforms/serializer.js b/src/transforms/serializer.js index 22a6b7447..7cc127e51 100644 --- a/src/transforms/serializer.js +++ b/src/transforms/serializer.js @@ -5,6 +5,7 @@ const Serializer = require('protodef').Serializer const Parser = require('protodef').FullPacketParser const { ProtoDefCompiler } = require('protodef').Compiler +const nbt = require('prismarine-nbt') const minecraft = require('../datatypes/minecraft') const states = require('../states') const merge = require('lodash.merge') @@ -30,6 +31,7 @@ function createProtocol (state, direction, version, customPackets, compiled = tr const compiler = new ProtoDefCompiler() compiler.addTypes(require('../datatypes/compiler-minecraft')) compiler.addProtocol(merge(mcData.protocol, get(customPackets, [mcData.version.majorVersion])), [state, direction]) + nbt.addTypesToCompiler('big', compiler) const proto = compiler.compileProtoDefSync() protocols[key] = proto return proto @@ -38,6 +40,7 @@ function createProtocol (state, direction, version, customPackets, compiled = tr const proto = new ProtoDef(false) proto.addTypes(minecraft) proto.addProtocol(merge(mcData.protocol, get(customPackets, [mcData.version.majorVersion])), [state, direction]) + nbt.addTypesToInterperter('big', proto) protocols[key] = proto return proto } diff --git a/src/version.js b/src/version.js index 5b98d2753..f4707bdf4 100644 --- a/src/version.js +++ b/src/version.js @@ -1,6 +1,6 @@ 'use strict' module.exports = { - defaultVersion: '1.20.1', - supportedVersions: ['1.7', '1.8', '1.9', '1.10', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1', '1.18.2', '1.19', '1.19.2', '1.19.3', '1.19.4', '1.20', '1.20.1'] + defaultVersion: '1.20.4', + supportedVersions: ['1.7', '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.2', '1.19', '1.19.2', '1.19.3', '1.19.4', '1.20', '1.20.1', '1.20.2', '1.20.4'] } diff --git a/test/benchmark.js b/test/benchmark.js index 4dc87f68b..a4610371d 100644 --- a/test/benchmark.js +++ b/test/benchmark.js @@ -16,7 +16,7 @@ const testDataWrite = [ for (const supportedVersion of mc.supportedVersions) { const mcData = require('minecraft-data')(supportedVersion) const version = mcData.version - describe('benchmark ' + version.minecraftVersion, function () { + describe('benchmark ' + supportedVersion + 'v', function () { this.timeout(60 * 1000) const inputData = [] it('bench serializing', function (done) { diff --git a/test/clientTest.js b/test/clientTest.js index 91796264c..891988b38 100644 --- a/test/clientTest.js +++ b/test/clientTest.js @@ -2,6 +2,7 @@ const mc = require('../') const os = require('os') +const fs = require('fs') const path = require('path') const assert = require('power-assert') const util = require('util') @@ -20,7 +21,8 @@ for (const supportedVersion of mc.supportedVersions) { const version = mcData.version const MC_SERVER_JAR_DIR = process.env.MC_SERVER_JAR_DIR || os.tmpdir() const MC_SERVER_JAR = MC_SERVER_JAR_DIR + '/minecraft_server.' + version.minecraftVersion + '.jar' - const wrap = new Wrap(MC_SERVER_JAR, MC_SERVER_PATH + '_' + supportedVersion, { + const MC_SERVER_DIR = MC_SERVER_PATH + '_' + supportedVersion + const wrap = new Wrap(MC_SERVER_JAR, MC_SERVER_DIR, { minMem: 1024, maxMem: 1024 }) @@ -28,7 +30,7 @@ for (const supportedVersion of mc.supportedVersions) { console.log(line) }) - describe('client ' + version.minecraftVersion, function () { + describe('client ' + supportedVersion + 'v', function () { this.timeout(10 * 60 * 1000) before(async function () { @@ -57,6 +59,7 @@ for (const supportedVersion of mc.supportedVersions) { 'server-port': PORT, motd: 'test1234', 'max-players': 120, + // 'level-type': 'flat', 'use-native-transport': 'false' // java 16 throws errors without this, https://www.spigotmc.org/threads/unable-to-access-address-of-buffer.311602 }, (err) => { if (err) reject(err) @@ -118,7 +121,23 @@ for (const supportedVersion of mc.supportedVersions) { assert.strictEqual(packet.gameMode, 0) client.chat('hello everyone; I have logged in.') }) - + // Dump some data for easier debugging + client.on('raw.registry_data', (buffer) => { + fs.writeFileSync(MC_SERVER_DIR + '_registry_data.bin', buffer) + }) + client.on('registry_data', (json) => { + fs.writeFileSync(MC_SERVER_DIR + '_registry_data.json', JSON.stringify(json)) + }) + client.on('login', (packet) => { + fs.writeFileSync(MC_SERVER_DIR + '_login.json', JSON.stringify(packet)) + if (fs.existsSync(MC_SERVER_DIR + '_registry_data.json')) { + // generate a loginPacket.json for minecraft-data + fs.writeFileSync(MC_SERVER_DIR + '_loginPacket.json', JSON.stringify({ + ...packet, + dimensionCodec: JSON.parse(fs.readFileSync(MC_SERVER_DIR + '_registry_data.json')).codec + }, null, 2)) + } + }) client.on('playerChat', function (data) { chatCount += 1 assert.ok(chatCount <= 2) @@ -147,21 +166,22 @@ for (const supportedVersion of mc.supportedVersions) { } } else { // 1.19+ - - const message = JSON.parse(data.formattedMessage || JSON.stringify({ text: data.plainMessage })) + console.log('Chat Message', data) + const sender = JSON.parse(data.senderName) + const msgPayload = data.formattedMessage ? JSON.parse(data.formattedMessage) : data.plainMessage + const plainMessage = client.parseMessage(msgPayload).toString() if (chatCount === 1) { - assert.strictEqual(message.text, 'hello everyone; I have logged in.') - const sender = JSON.parse(data.senderName) + assert.strictEqual(plainMessage, 'hello everyone; I have logged in.') assert.deepEqual(sender.clickEvent, { action: 'suggest_command', value: '/tell Player ' }) assert.strictEqual(sender.text, 'Player') } else if (chatCount === 2) { - assert.strictEqual(message.text, 'hello') - const sender = JSON.parse(data.senderName) - assert.strictEqual(sender.text, 'Server') + const plainSender = client.parseMessage(sender).toString() + assert.strictEqual(plainMessage, 'hello') + assert.strictEqual(plainSender, 'Server') wrap.removeListener('line', lineListener) client.end() done() diff --git a/test/common/clientHelpers.js b/test/common/clientHelpers.js index 01253780a..2fb78d2d6 100644 --- a/test/common/clientHelpers.js +++ b/test/common/clientHelpers.js @@ -1,3 +1,4 @@ +const Registry = require('prismarine-registry') module.exports = client => { client.nextMessage = (containing) => { return new Promise((resolve) => { @@ -20,5 +21,23 @@ module.exports = client => { }) } + client.on('login', (packet) => { + client.registry ??= Registry(client.version) + if (packet.dimensionCodec) { + client.registry.loadDimensionCodec(packet.dimensionCodec) + } + }) + client.on('registry_data', (data) => { + client.registry ??= Registry(client.version) + client.registry.loadDimensionCodec(data.codec) + }) + + client.on('playerJoin', () => { + const ChatMessage = require('prismarine-chat')(client.registry || client.version) + client.parseMessage = (comp) => { + return new ChatMessage(comp) + } + }) + return client } diff --git a/test/cyclePacketTest.js b/test/cyclePacketTest.js new file mode 100644 index 000000000..cd8693e97 --- /dev/null +++ b/test/cyclePacketTest.js @@ -0,0 +1,57 @@ +/* eslint-env mocha */ +// Tests packet serialization/deserialization from with raw binary from minecraft-packets +const { createSerializer, createDeserializer, states, supportedVersions } = require('minecraft-protocol') +const mcPackets = require('minecraft-packets') +const assert = require('assert') + +const makeClientSerializer = version => createSerializer({ state: states.PLAY, version, isServer: true }) +const makeClientDeserializer = version => createDeserializer({ state: states.PLAY, version }) + +for (const supportedVersion of supportedVersions) { + let serializer, deserializer, data + const mcData = require('minecraft-data')(supportedVersion) + const version = mcData.version + + function convertBufferToObject (buffer) { + return deserializer.parsePacketBuffer(buffer) + } + + function convertObjectToBuffer (object) { + return serializer.createPacketBuffer(object) + } + + function testBuffer (buffer, [packetName, packetIx]) { + const parsed = convertBufferToObject(buffer).data + const parsedBuffer = convertObjectToBuffer(parsed) + const areEq = buffer.equals(parsedBuffer) + if (!areEq) { + console.log('original buffer', buffer.toString('hex')) + console.log('cycled buffer', parsedBuffer.toString('hex')) + } + assert.strictEqual(areEq, true, `Error when testing ${+packetIx + 1} ${packetName} packet`) + } + describe(`Test cycle packet for version ${supportedVersion}v`, () => { + before(() => { + serializer = makeClientSerializer(version.minecraftVersion) + deserializer = makeClientDeserializer(version.minecraftVersion) + }) + data = mcPackets.pc[version.minecraftVersion] + it('Has packet data', () => { + if (data === undefined) { + // many version do not have data, so print a log for now + // assert when most versions have packet data + console.log(`Version ${version.minecraftVersion} has no packet dump.`) + } + }) + // server -> client + if (data !== undefined) { + Object.entries(data['from-server']).forEach(([packetName, packetData]) => { + it(`${packetName} packet`, () => { + for (const i in packetData) { + testBuffer(packetData[i].raw, [packetName, i]) + } + }) + }) + } + }) +} diff --git a/test/docTest.js b/test/docTest.js new file mode 100644 index 000000000..1b3d80fea --- /dev/null +++ b/test/docTest.js @@ -0,0 +1,16 @@ +/* eslint-env mocha */ + +const mc = require('../') +const fs = require('fs') +const assert = require('assert') +const path = require('path') + +const readmeContent = fs.readFileSync(path.join(__dirname, '/../docs/README.md'), { encoding: 'utf8', flag: 'r' }) + +for (const supportedVersion of mc.supportedVersions) { + describe('doc ' + supportedVersion + 'v', function () { + it('mentions the supported version in the readme', () => { + assert.ok(readmeContent.includes(supportedVersion), `${supportedVersion} should be mentionned in the README.md but it is not`) + }) + }) +} diff --git a/test/non-par-test.js b/test/non-par-test.js deleted file mode 100644 index 81c420cb0..000000000 --- a/test/non-par-test.js +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-env mocha */ -// Tests packet serialization/deserialization from with raw binary from minecraft-packets -const { createSerializer, createDeserializer, states } = require('minecraft-protocol') -const mcPackets = require('minecraft-packets') -const assert = require('assert') - -const makeClientSerializer = version => createSerializer({ state: states.PLAY, version, isServer: true }) -const makeClientDeserializer = version => createDeserializer({ state: states.PLAY, version }) - -Object.entries(mcPackets.pc).forEach(([ver, data]) => { - let serializer, deserializer - - function convertBufferToObject (buffer) { - return deserializer.parsePacketBuffer(buffer) - } - - function convertObjectToBuffer (object) { - return serializer.createPacketBuffer(object) - } - - function testBuffer (buffer, [packetName, packetIx]) { - const parsed = convertBufferToObject(buffer).data - const parsedBuffer = convertObjectToBuffer(parsed) - const areEq = buffer.equals(parsedBuffer) - assert.strictEqual(areEq, true, `Error when testing ${+packetIx + 1} ${packetName} packet`) - } - describe(`Test version ${ver}`, () => { - serializer = makeClientSerializer(ver) - deserializer = makeClientDeserializer(ver) - // server -> client - Object.entries(data['from-server']).forEach(([packetName, packetData]) => { - it(`${packetName} packet`, () => { - for (const i in packetData) { - testBuffer(packetData[i].raw, [packetName, i]) - } - }) - }) - }) -}) diff --git a/test/packetTest.js b/test/packetTest.js index 0fc81af07..893d3ab56 100644 --- a/test/packetTest.js +++ b/test/packetTest.js @@ -35,6 +35,20 @@ const slotValue = { } } +const nbtValue = { + type: 'compound', + name: 'test', + value: { + test1: { type: 'int', value: 4 }, + test2: { type: 'long', value: [12, 42] }, + test3: { type: 'byteArray', value: [32] }, + test4: { type: 'string', value: 'ohi' }, + test5: { type: 'list', value: { type: 'int', value: [4] } }, + test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, + test7: { type: 'intArray', value: [12, 42] } + } +} + const values = { i32: 123456, i16: -123, @@ -110,46 +124,12 @@ const values = { f64: 99999.2222, f32: -333.444, slot: slotValue, - nbt: { - type: 'compound', - name: 'test', - value: { - test1: { type: 'int', value: 4 }, - test2: { type: 'long', value: [12, 42] }, - test3: { type: 'byteArray', value: [32] }, - test4: { type: 'string', value: 'ohi' }, - test5: { type: 'list', value: { type: 'int', value: [4] } }, - test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, - test7: { type: 'intArray', value: [12, 42] } - } - }, - optionalNbt: { - type: 'compound', - name: 'test', - value: { - test1: { type: 'int', value: 4 }, - test2: { type: 'long', value: [12, 42] }, - test3: { type: 'byteArray', value: [32] }, - test4: { type: 'string', value: 'ohi' }, - test5: { type: 'list', value: { type: 'int', value: [4] } }, - test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, - test7: { type: 'intArray', value: [12, 42] } - } - }, + nbt: nbtValue, + optionalNbt: nbtValue, + compressedNbt: nbtValue, + anonymousNbt: nbtValue, + anonOptionalNbt: nbtValue, previousMessages: [], - compressedNbt: { - type: 'compound', - name: 'test', - value: { - test1: { type: 'int', value: 4 }, - test2: { type: 'long', value: [12, 42] }, - test3: { type: 'byteArray', value: [32] }, - test4: { type: 'string', value: 'ohi' }, - test5: { type: 'list', value: { type: 'int', value: [4] } }, - test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, - test7: { type: 'intArray', value: [12, 42] } - } - }, i64: [0, 1], u64: [0, 1], entityMetadata: [ @@ -223,6 +203,15 @@ const values = { }, suggestionType: 'minecraft:summonable_entities' } + }, + soundSource: 'master', + packedChunkPos: { + x: 10, + z: 12 + }, + particle: { + particleId: 0, + data: null } } @@ -244,7 +233,7 @@ for (const supportedVersion of mc.supportedVersions) { const version = mcData.version const packets = mcData.protocol - describe('packets ' + version.minecraftVersion, function () { + describe('packets ' + supportedVersion + 'v', function () { let client, server, serverClient before(async function () { PORT = await getPort() diff --git a/test/serverTest.js b/test/serverTest.js index 7a8a5142a..b2f671058 100644 --- a/test/serverTest.js +++ b/test/serverTest.js @@ -94,9 +94,9 @@ for (const supportedVersion of mc.supportedVersions) { } } - describe('mc-server ' + version.minecraftVersion, function () { + describe('mc-server ' + supportedVersion + 'v', function () { this.timeout(5000) - this.beforeAll(async function () { + this.beforeEach(async function () { PORT = await getPort() console.log(`Using port for tests: ${PORT}`) }) @@ -299,7 +299,8 @@ for (const supportedVersion of mc.supportedVersions) { const username = ['player1', 'player2'] let index = 0 - server.on('login', function (client) { + server.on('playerJoin', function (client) { + console.log('ChatTest: Player has joined') assert.notEqual(client.id, null) assert.strictEqual(client.username, username[index++]) broadcast(client.username + ' joined the game.') @@ -322,8 +323,10 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT })) + console.log('ChatTest: Player1 is joining...') player1.on('login', async function (packet) { + console.log('ChatTest: Player 1 has joined') assert.strictEqual(packet.gameMode, 1) const player2 = applyClientHelpers(mc.createClient({ username: 'player2', @@ -332,14 +335,16 @@ for (const supportedVersion of mc.supportedVersions) { port: PORT })) + console.log('ChatTest: waiting for next message from P2') const p1Join = await player1.nextMessage('player2') assert.strictEqual(p1Join, '{"text":"player2 joined the game."}') - + console.log('ChatTest: Got message from P2') player2.chat('hi') const p2hi = await player1.nextMessage('player2') assert.strictEqual(p2hi, '{"text":" hi"}') + console.log('ChatTest: Waiting again for next message from P2') player1.chat('hello') const p1hello = await player2.nextMessage('player1') assert.strictEqual(p1hello, '{"text":" hello"}') @@ -389,7 +394,7 @@ for (const supportedVersion of mc.supportedVersions) { port: PORT }) let count = 2 - server.on('login', function (client) { + server.on('playerJoin', function (client) { client.on('end', function (reason) { assert.strictEqual(reason, 'ServerShutdown') resolve() @@ -404,7 +409,7 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT }) - client.on('login', function () { + client.on('playerJoin', function () { server.close() }) }) @@ -420,7 +425,7 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT }) - server.on('login', function (client) { + server.on('playerJoin', function (client) { client.write('login', loginPacket(client, server)) }) server.on('close', done) @@ -459,7 +464,7 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT }) - server.on('login', function (client) { + server.on('playerJoin', function (client) { client.on('end', function (reason) { assert.strictEqual(reason, 'ServerShutdown') })