diff --git a/lib/plugins/bed.js b/lib/plugins/bed.js index 9b526f698..513d70d2b 100644 --- a/lib/plugins/bed.js +++ b/lib/plugins/bed.js @@ -70,7 +70,7 @@ function inject (bot) { } else { bot._client.write('entity_action', { entityId: bot.entity.id, - actionId: 2, + actionId: bot.supportFeature('entityActionUsesStringMapper') ? 'leave_bed' : 2, jumpBoost: 0 }) } diff --git a/lib/plugins/creative.js b/lib/plugins/creative.js index 2b894e4c6..a398690b6 100644 --- a/lib/plugins/creative.js +++ b/lib/plugins/creative.js @@ -2,6 +2,7 @@ const assert = require('assert') const { Vec3 } = require('vec3') const { sleep, onceWithCleanup } = require('../promise_utils') const { once } = require('../promise_utils') +const { mapsIdsToNames } = require('../protocol_ids') module.exports = inject @@ -46,7 +47,7 @@ function inject (bot) { resolve() }, timeoutMs) pendingStatsRequests.push(request) - bot._client.write('client_command', bot.supportFeature('respawnIsPayload') ? { payload: 1 } : { actionId: 1 }) + bot._client.write('client_command', bot.supportFeature('respawnIsPayload') ? { payload: 1 } : { actionId: mapsIdsToNames(bot.registry, 'packet_client_command', 'actionId') ? 'request_stats' : 1 }) }) } diff --git a/lib/plugins/game.js b/lib/plugins/game.js index d3291b06e..63197f5d3 100644 --- a/lib/plugins/game.js +++ b/lib/plugins/game.js @@ -1,4 +1,5 @@ const nbt = require('prismarine-nbt') +const { mapsIdsToNames } = require('../protocol_ids') module.exports = inject const difficultyNames = ['peaceful', 'easy', 'normal', 'hard'] @@ -123,7 +124,7 @@ function inject (bot, options) { bot._client.on('game_state_change', (packet) => { if ((packet.reason === 4 || packet.reason === 'win_game') && packet.gameMode === 1) { - bot._client.write('client_command', { action: 0 }) + bot._client.write('client_command', bot.supportFeature('respawnIsPayload') ? { payload: 0 } : { actionId: mapsIdsToNames(bot.registry, 'packet_client_command', 'actionId') ? 'perform_respawn' : 0 }) } if ((packet.reason === 3) || (packet.reason === 'change_game_mode')) { bot.game.gameMode = parseGameMode(packet.gameMode) diff --git a/lib/plugins/health.js b/lib/plugins/health.js index 94ff32590..a600c2648 100644 --- a/lib/plugins/health.js +++ b/lib/plugins/health.js @@ -1,3 +1,4 @@ +const { mapsIdsToNames } = require('../protocol_ids') module.exports = inject function inject (bot, options) { @@ -41,7 +42,7 @@ function inject (bot, options) { const respawn = () => { if (bot.isAlive) return - bot._client.write('client_command', bot.supportFeature('respawnIsPayload') ? { payload: 0 } : { actionId: 0 }) + bot._client.write('client_command', bot.supportFeature('respawnIsPayload') ? { payload: 0 } : { actionId: mapsIdsToNames(bot.registry, 'packet_client_command', 'actionId') ? 'perform_respawn' : 0 }) } bot.respawn = respawn diff --git a/lib/plugins/inventory.js b/lib/plugins/inventory.js index bab0eef7b..dabde16a0 100644 --- a/lib/plugins/inventory.js +++ b/lib/plugins/inventory.js @@ -2,6 +2,7 @@ const assert = require('assert') const { Vec3 } = require('vec3') const { once, sleep, createDoneTask, createTask, withTimeout } = require('../promise_utils') const { toNotchianYaw, toNotchianPitch } = require('../conversions') +const { mapsIdsToNames } = require('../protocol_ids') module.exports = inject @@ -245,6 +246,8 @@ function inject (bot, { hideErrors }) { bot.swingArm() } + const mainHand = mapsIdsToNames(bot.registry, 'packet_use_entity', 'hand') ? 'main_hand' : 0 + async function activateEntity (entity) { // TODO: tell the server that we are not sneaking while doing this await bot.lookAt(entity.position.offset(0, 1, 0), false) @@ -252,7 +255,7 @@ function inject (bot, { hideErrors }) { target: entity.id, mouse: 0, // interact with entity sneaking: false, - hand: 0, // interact with the main hand + hand: mainHand, // main hand location: new Vec3(0, 0, 0) }) } @@ -264,7 +267,7 @@ function inject (bot, { hideErrors }) { target: entity.id, mouse: 2, // interact with entity at sneaking: false, - hand: 0, // interact with the main hand + hand: mainHand, // main hand x: position.x - entity.position.x, y: position.y - entity.position.y, z: position.z - entity.position.z, diff --git a/lib/protocol_ids.js b/lib/protocol_ids.js new file mode 100644 index 000000000..40e4a5015 --- /dev/null +++ b/lib/protocol_ids.js @@ -0,0 +1,12 @@ +/** + * Some serverbound action ids are described as a protodef `mapper` on newer protocols, where the + * field takes the name rather than the number: 26.1 does it for `client_command.actionId` and + * `use_entity.hand`. Writing the number there throws in the serializer and the packet is dropped, so + * every writer has to ask the schema which shape this version wants. + */ +function mapsIdsToNames (registry, packet, field) { + const type = registry.protocol?.play?.toServer?.types?.[packet]?.[1]?.find(f => f.name === field)?.type + return Array.isArray(type) && type[0] === 'mapper' +} + +module.exports = { mapsIdsToNames } diff --git a/test/internalTest.js b/test/internalTest.js index 068368f4d..b89d9ed76 100644 --- a/test/internalTest.js +++ b/test/internalTest.js @@ -1527,6 +1527,80 @@ for (const supportedVersion of mineflayer.testedVersions) { }) }) + describe('string mapped action ids', () => { + // 26.1 maps client_command's actionId and use_entity's hand to strings, and 1.21.6+ maps + // entity_action's actionId; a numeric id fails to serialize there and never reaches the server. + const fields = (name) => registry.protocol?.play?.toServer?.types?.[name]?.[1] ?? [] + const has = (name, field) => fields(name).some(f => f.name === field) + + // Resolves with the first matching packet the server sees. Asserting inside the packet listener + // would throw on the read path and wedge the connection instead of failing the test. + function packetFrom (name, act, want = () => true) { + return new Promise((resolve, reject) => { + server.on('playerJoin', async (client) => { + try { + client.on('packet', (data, meta) => { if (meta.name === name && want(data)) resolve(data) }) + await client.write('login', bot.test.generateLoginPacket()) + const chunk = bot.test.buildChunk() + chunk.setBlockType(vec3(0, 64, 0), registry.blocksByName.stone.id) + await client.write('map_chunk', generateChunkPacket(chunk)) + await client.write('position', { + x: 0, + y: 66, + z: 0, + dx: 0, + dy: 0, + dz: 0, + yaw: 0, + pitch: 0, + flags: bot.registry.version['>=']('1.21.3') ? {} : 0, + teleportId: 0 + }) + await sleep(200) + await act() + } catch (err) { + reject(err) + } + }) + }) + } + + it('respawns with an action id the protocol accepts', async () => { + const packet = await packetFrom('client_command', () => { + bot.isAlive = false + bot.respawn() + }) + const mapped = fields('packet_client_command').some(f => f.name === 'actionId' && f.type[0] === 'mapper') + assert.strictEqual(packet.actionId ?? packet.payload, mapped ? 'perform_respawn' : 0) + }) + + it('interacts with an entity using a hand the protocol accepts', async () => { + const packet = await packetFrom('use_entity', () => { + const Entity = require('prismarine-entity')(registry) + const target = new Entity(42) + target.position = vec3(1, 66, 0) + target.height = 1.8 + bot.entities[target.id] = target + return bot.activateEntity(target) + }) + // Before 1.9 the packet carries no hand at all; getting it at the server is the whole assertion. + if (has('packet_use_entity', 'hand')) { + const mapped = fields('packet_use_entity').some(f => f.name === 'hand' && f.type[0] === 'mapper') + assert.strictEqual(packet.hand, mapped ? 'main_hand' : 0) + } + }) + + it('leaves a bed with an action id the protocol accepts', async () => { + const mapped = registry.supportFeature('entityActionUsesStringMapper') + const sneakOrSprint = new Set(mapped ? ['start_sprinting', 'stop_sprinting'] : [0, 1, 3, 4]) + const packet = await packetFrom('entity_action', () => { + bot.isSleeping = true + return bot.wake() + }, (data) => !sneakOrSprint.has(data.actionId)) + assert.strictEqual(packet.actionId, mapped ? 'leave_bed' : 2) + }) + }) + describe('onceWithCleanup', () => { it('rejects instead of throwing out of emit when checkCondition throws', async () => { // A condition that throws used to unwind whatever was emitting. For a