spectra-physics 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -6
- package/index.js +1 -0
- package/lib/client/clock.js +38 -0
- package/lib/client/connection.js +20 -0
- package/lib/client/environment.js +12 -43
- package/lib/client/index.js +24 -11
- package/lib/client/interact.js +79 -0
- package/lib/client/items.js +85 -0
- package/lib/client/protocol.js +42 -0
- package/lib/client/registries.js +86 -0
- package/lib/client/riding.js +7 -3
- package/lib/client/server.js +13 -0
- package/lib/client/simulation.js +35 -0
- package/lib/client/ticker.js +8 -23
- package/lib/engine/aabb.js +5 -3
- package/lib/engine/boat.js +57 -7
- package/lib/engine/player/effects.js +12 -49
- package/lib/engine/player/fluids.js +15 -6
- package/lib/engine/player/index.js +30 -1
- package/lib/engine/player/input.js +5 -2
- package/lib/engine/player/move.js +13 -7
- package/lib/engine/player/pose.js +1 -1
- package/lib/engine/player/traversal.js +290 -0
- package/lib/engine/version.js +3 -1
- package/lib/engine/world.js +82 -20
- package/lib/pathfinder/astar.js +167 -0
- package/lib/pathfinder/costs.js +21 -0
- package/lib/pathfinder/digger.js +132 -0
- package/lib/pathfinder/goals.js +236 -0
- package/lib/pathfinder/index.js +434 -0
- package/lib/pathfinder/moves.js +223 -0
- package/lib/pathfinder/terrain.js +169 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -25,14 +25,18 @@ several places that are visible to the server:
|
|
|
25
25
|
| Position packets | time based, last-sent values reset on teleport | `sendPosition` rules: 0.03 threshold before 1.18.2 and 2·10⁻⁴ after, 20-tick reminder, last-sent values kept across teleports |
|
|
26
26
|
| Input | raw ±1 | per version: sneak/item scaling then 0.98 up to 1.21.4, normalized square movement from 1.21.5 |
|
|
27
27
|
| Wall collisions | always zeroes the blocked axis | 1.14–1.18.1 keep X speed in corners, 1.18.2+ compare with an epsilon |
|
|
28
|
-
| Block effects | during the move | during the move before 1.21.2
|
|
28
|
+
| Block effects | during the move | during the move before 1.21.2; from 1.21.2 after travel, walking the path in each version's own block order |
|
|
29
|
+
| Powder snow | never solid | solid from above in leather boots (not while sneaking), 0.9 high when falling onto it |
|
|
30
|
+
| Item use | any `activateItem`, cleared by any entity's status | only items with a use animation (food when hungry, potions, shield, bow with arrows, …), ended by release, slot change or the server; movement slowed to a fifth |
|
|
31
|
+
| Actions | sent whenever called | use, dig, interact, attack and swing go out at the start of the next tick, before movement; `use_item` carries that tick's rotation |
|
|
32
|
+
| Entity clicks, mounting | `interact` only | aim, `interact_at` with the hit point, `interact`, swing; leaving a vehicle by holding sneak |
|
|
29
33
|
| Rotation | continuous interpolation | whole mouse steps of 0.15°, accumulated in float |
|
|
30
34
|
| Sprint | applied the same tick | speed follows the sprint state in the tick it is sent, as the server applies it |
|
|
31
35
|
| Tick loop | fixed interval | `Minecraft` timer: catches up at most 10 ticks |
|
|
32
36
|
| 1.21.2+ | no `tick_end` | `tick_end` after every tick, `player_input` on key change |
|
|
33
37
|
| 1.21.4+ | `player_loaded` on first health update | sent once the level around the player is loaded |
|
|
34
38
|
| Teleport answer | one order for all versions | per version (1.21.2–1.21.3 send position before the confirmation) |
|
|
35
|
-
| Vehicles | rotation packets only | boat simulation with paddle / input / vehicle move packets, minecart riding |
|
|
39
|
+
| Vehicles | rotation packets only | boat simulation with paddle / input / vehicle move packets (input applies a tick later, as in the game), minecart riding |
|
|
36
40
|
| Brand, 1.20.2+ | sent in play state | sent in the configuration phase, once |
|
|
37
41
|
| Open container | keeps moving | movement keys are ignored while a window is open, as in the game |
|
|
38
42
|
|
|
@@ -42,12 +46,23 @@ It also works around mineflayer issues that break movement:
|
|
|
42
46
|
- a removed vehicle is never dismounted (`entityGone` is listened for on the wrong emitter);
|
|
43
47
|
- an out-of-range hotbar slot from the server throws inside the packet handler;
|
|
44
48
|
- player attributes are misread: stored under `undefined` on 1.16.5 – 1.20.4 and named from an outdated id table
|
|
45
|
-
on 1.21+ (on 1.21.2 – 1.21.5 movement speed arrives as step height), so speed effects and step height go wrong
|
|
49
|
+
on 1.21+ (on 1.21.2 – 1.21.5 movement speed arrives as step height), so speed effects and step height go wrong;
|
|
50
|
+
- `set_passengers` without the bot never clears `bot.vehicle`, so after every dismount the bot keeps acting as a passenger;
|
|
51
|
+
- enchantments on 1.20.5+ items are not a list (reading boots throws), and from 1.21 their ids come from the server's registry;
|
|
52
|
+
- the 1.16 data numbers dolphin's grace wrong and marks every entity, arrows and items included, as a mob;
|
|
53
|
+
- recipe packets on 1.20.5 – 1.21.3 and advancements on 1.21.2 – 1.21.3 fail to parse; they arrive as raw buffers there.
|
|
46
54
|
|
|
47
55
|
Before 1.18.2 the client only reports moves longer than 0.03. Airborne moves shorter than that are still reported,
|
|
48
56
|
and on 1.17 – 1.18.1 the mouse holds a turn for up to two seconds while such moves last, because anti-cheats
|
|
49
57
|
cannot reconstruct the skipped vertical movement.
|
|
50
58
|
|
|
59
|
+
## Performance
|
|
60
|
+
|
|
61
|
+
All bots in a process share one 20 TPS clock. Inside a tick the engine memoises chunk columns and block data and
|
|
62
|
+
reuses its collision boxes, so a player tick costs about 3 µs (`node bench/tick.js`, 50 players on a course of
|
|
63
|
+
slabs, fences, water, cobwebs and honey). `node bench/trajectory.js --check` compares a fingerprint of every
|
|
64
|
+
simulated position with `bench/baseline.json`, which is how optimisations are checked to change nothing.
|
|
65
|
+
|
|
51
66
|
## Installation
|
|
52
67
|
|
|
53
68
|
```bash
|
|
@@ -114,6 +129,35 @@ Added:
|
|
|
114
129
|
- `bot.spectra.world` – block and fluid lookups used by the engine
|
|
115
130
|
- event `clientLoaded` – 1.21.4+, emitted when `player_loaded` is sent
|
|
116
131
|
|
|
132
|
+
### Pathfinder
|
|
133
|
+
|
|
134
|
+
A built-in pathfinder with the same API as mineflayer-pathfinder, driven by the engine itself:
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
const { createBot, goals } = require('spectra-physics')
|
|
138
|
+
|
|
139
|
+
const bot = createBot({ host: 'localhost', username: 'Steve', version: '1.21.4' })
|
|
140
|
+
|
|
141
|
+
bot.once('spawn', async () => {
|
|
142
|
+
await bot.pathfinder.goto(new goals.GoalBlock(120, 64, -40))
|
|
143
|
+
bot.pathfinder.setGoal(new goals.GoalFollow(bot.nearestEntity(e => e.name === 'cow'), 2), true)
|
|
144
|
+
})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- A* with costs in ticks, so it picks the fastest route: walking, sprinting, slabs and stairs without jumping,
|
|
148
|
+
jumps, drops, sprint-jumps over gaps, ladders and vines, swimming, and digging through, up and down
|
|
149
|
+
- keys are chosen by simulating the player a few ticks ahead with this engine: when to sprint, when to jump,
|
|
150
|
+
which corners can be cut
|
|
151
|
+
- turning goes through the mouse model; blocks are broken tick by tick like the game client (face in view,
|
|
152
|
+
progress and swing every tick, 5 ticks between blocks), also available as `bot.spectra.dig(block)`
|
|
153
|
+
- the route is planned again when blocks on it change, the goal moves or the bot gets stuck
|
|
154
|
+
- goals: `GoalBlock`, `GoalNear`, `GoalXZ`, `GoalNearXZ`, `GoalY`, `GoalGetToBlock`, `GoalLookAtBlock`,
|
|
155
|
+
`GoalFollow`, `GoalCompositeAny`, `GoalCompositeAll`, `GoalInvert`
|
|
156
|
+
- `bot.pathfinder.settings`: `canDig`, `allowParkour`, `allowLongParkour`, `allowSprinting`, `maxDropDown`,
|
|
157
|
+
`thinkTimeout`, `tickTimeout`; events `path_update`, `path_reset`, `path_stop`, `goal_reached`, `goal_updated`
|
|
158
|
+
|
|
159
|
+
mineflayer-pathfinder works too: `bot.physics.simulatePlayer` runs its look-ahead on this engine.
|
|
160
|
+
|
|
117
161
|
### Standalone engine
|
|
118
162
|
|
|
119
163
|
The engine can run without a connection, for prediction or tests:
|
|
@@ -148,6 +192,7 @@ See [`test/helpers.js`](test/helpers.js) for a complete in-memory world.
|
|
|
148
192
|
- being pushed out of blocks and by overlapping entities
|
|
149
193
|
- levitation, slow falling, jump boost, dolphin's grace, depth strider and soul speed,
|
|
150
194
|
attributes: movement speed, gravity, jump strength, step height, sneaking speed, movement and water efficiency
|
|
195
|
+
- using items: eating, drinking, blocking with a shield, drawing a bow
|
|
151
196
|
- creative flight, including toggling with a double jump and landing
|
|
152
197
|
- knockback and explosion velocity, 1.21.2+ relative teleports with velocity
|
|
153
198
|
- riding boats (client-controlled) and minecarts
|
|
@@ -162,14 +207,18 @@ npm test
|
|
|
162
207
|
|
|
163
208
|
Offline tests for 1.16.5 – 26.1 cover free fall, landing on blocks and slabs, walking and sprinting speed,
|
|
164
209
|
jump height, step-up, walls and corners, sneaking at an edge, input per version, swimming, cobwebs, bubble columns,
|
|
165
|
-
ice, soul sand, pushing, and the position reporting thresholds.
|
|
210
|
+
powder snow, ice, soul sand, pushing, item use, and the position reporting thresholds.
|
|
166
211
|
|
|
167
212
|
## Layout
|
|
168
213
|
|
|
169
214
|
```
|
|
170
215
|
lib/engine world access, collisions, version switches, boat
|
|
171
|
-
lib/engine/player the player tick: input, fluids, blocks under the player, move, travel, block effects
|
|
172
|
-
|
|
216
|
+
lib/engine/player the player tick: input, fluids, blocks under the player, move, travel, block effects and their
|
|
217
|
+
traversal order, pose, pushing
|
|
218
|
+
lib/pathfinder goals, terrain, moves, A*, block breaking and the path follower
|
|
219
|
+
lib/client mineflayer integration: controls, teleports, server packets, position reporting, riding, item use,
|
|
220
|
+
entity interaction, registry tables, protocol fixes, the shared clock
|
|
221
|
+
bench tick cost and bit-exact output fingerprints
|
|
173
222
|
```
|
|
174
223
|
|
|
175
224
|
## License
|
package/index.js
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// One 20 TPS timer for every bot in the process (Minecraft's Timer: catches up at most 10 ticks,
|
|
3
|
+
// gives up after falling a second behind).
|
|
4
|
+
|
|
5
|
+
const { performance } = require('perf_hooks')
|
|
6
|
+
|
|
7
|
+
const TICK_MS = 50
|
|
8
|
+
const MAX_CATCH_UP = 10
|
|
9
|
+
|
|
10
|
+
const tickers = new Set()
|
|
11
|
+
let timer = null
|
|
12
|
+
let nextAt = 0
|
|
13
|
+
|
|
14
|
+
function loop () {
|
|
15
|
+
const now = performance.now()
|
|
16
|
+
for (let n = 0; now >= nextAt && n < MAX_CATCH_UP; n++) {
|
|
17
|
+
for (const tick of tickers) tick()
|
|
18
|
+
nextAt += TICK_MS
|
|
19
|
+
}
|
|
20
|
+
if (now - nextAt > 1000) nextAt = now + TICK_MS
|
|
21
|
+
timer = tickers.size ? setTimeout(loop, Math.max(0, nextAt - performance.now())) : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function startTicking (tick) {
|
|
25
|
+
tickers.add(tick)
|
|
26
|
+
if (timer) return
|
|
27
|
+
nextAt = performance.now() + TICK_MS
|
|
28
|
+
timer = setTimeout(loop, TICK_MS)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stopTicking (tick) {
|
|
32
|
+
tickers.delete(tick)
|
|
33
|
+
if (tickers.size || !timer) return
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
timer = null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { startTicking, stopTicking, TICK_MS }
|
package/lib/client/connection.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// brand + client information in the configuration phase (1.20.2+), settings before brand on older versions,
|
|
4
4
|
// and player_loaded only when the level has actually loaded.
|
|
5
5
|
|
|
6
|
+
// actions the game sends from Minecraft#handleKeybinds, before the movement packet of the same tick
|
|
7
|
+
const TICK_ACTIONS = new Set(['use_item', 'block_dig', 'block_place', 'use_entity', 'attack', 'arm_animation', 'entity_action', 'spectate'])
|
|
8
|
+
|
|
6
9
|
const isBrand = (channel) => channel === 'minecraft:brand' || channel === 'MC|Brand'
|
|
7
10
|
|
|
8
11
|
function brandPayload (brand) {
|
|
@@ -25,6 +28,7 @@ function installConnection (ctx) {
|
|
|
25
28
|
let ours = false
|
|
26
29
|
|
|
27
30
|
const report = (name, params) => {
|
|
31
|
+
for (const watch of ctx.outgoing) watch(name, params)
|
|
28
32
|
if (ctx.onSend) ctx.onSend(name, params)
|
|
29
33
|
if (ctx.debug) ctx.debug('C->S', name, JSON.stringify(params))
|
|
30
34
|
}
|
|
@@ -35,7 +39,13 @@ function installConnection (ctx) {
|
|
|
35
39
|
try { rawWrite(name, params) } finally { ours = false }
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
const queued = []
|
|
43
|
+
|
|
38
44
|
client.write = function (name, params) {
|
|
45
|
+
if (!ours && TICK_ACTIONS.has(name) && client.state === 'play' && state.inLevel) {
|
|
46
|
+
queued.push([name, params])
|
|
47
|
+
return
|
|
48
|
+
}
|
|
39
49
|
if (!ours) {
|
|
40
50
|
if (name === 'player_loaded' && V.clientLoaded) return
|
|
41
51
|
if (name === 'custom_payload' && isBrand(params?.channel) && client.state === 'play') {
|
|
@@ -72,6 +82,16 @@ function installConnection (ctx) {
|
|
|
72
82
|
|
|
73
83
|
client.on('finish_configuration', () => { state.suppressPlaySettings = true })
|
|
74
84
|
|
|
85
|
+
// use_item carries the rotation of the tick it is sent in (checked by servers since 1.21)
|
|
86
|
+
ctx.flushActions = () => {
|
|
87
|
+
while (queued.length) {
|
|
88
|
+
const [name, params] = queued.shift()
|
|
89
|
+
if (name === 'use_item' && params.rotation) params.rotation = { x: ctx.player.yRot, y: ctx.player.xRot }
|
|
90
|
+
report(name, params)
|
|
91
|
+
rawWrite(name, params)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
ctx.flushHeldBrand = () => {
|
|
76
96
|
if (!state.heldBrand) return
|
|
77
97
|
const held = state.heldBrand
|
|
@@ -1,46 +1,16 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
// What the movement engine needs to know about the player besides the world.
|
|
3
3
|
|
|
4
|
-
const {
|
|
4
|
+
const { attributeKeyMapper, effectIdMapper, createEnchantmentReader, entityCategory } = require('./registries')
|
|
5
|
+
|
|
5
6
|
const SPRINT_UUID = '662a6b8d-da3e-4c1c-8813-96ea6097278d'
|
|
6
7
|
const OPERATIONS = { add_value: 0, add_multiplied_base: 1, add_multiplied_total: 2 }
|
|
7
8
|
const PUSH_RANGE = 4
|
|
8
9
|
const PUSHABLE_OBJECTS = /boat|raft|minecart/
|
|
9
10
|
const LIVING_TYPES = new Set(['player', 'mob', 'animal', 'hostile', 'passive', 'water_creature', 'ambient'])
|
|
11
|
+
const NEVER_PUSHES = new Set(['armor_stand', 'bat', 'parrot'])
|
|
10
12
|
const BOOTS_SLOT = 8
|
|
11
13
|
|
|
12
|
-
const normalizeKey = (key) => String(key)
|
|
13
|
-
.replace(/^minecraft:/, '')
|
|
14
|
-
.replace(/^(generic|player)\./, '')
|
|
15
|
-
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
|
16
|
-
.toLowerCase()
|
|
17
|
-
|
|
18
|
-
// attribute registry order from 1.21 on; the protocol tables in minecraft-data stop at the 1.20.5 list
|
|
19
|
-
function attributeRegistry (pv) {
|
|
20
|
-
if (pv < PV['1.21']) return null
|
|
21
|
-
return [
|
|
22
|
-
'armor', 'armor_toughness', 'attack_damage', 'attack_knockback', 'attack_speed', 'block_break_speed',
|
|
23
|
-
'block_interaction_range', 'burning_time', ...(pv >= PV['1.21.6'] ? ['camera_distance'] : []),
|
|
24
|
-
'explosion_knockback_resistance', 'entity_interaction_range', 'fall_damage_multiplier', 'flying_speed',
|
|
25
|
-
'follow_range', 'gravity', 'jump_strength', 'knockback_resistance', 'luck', 'max_absorption', 'max_health',
|
|
26
|
-
'mining_efficiency', 'movement_efficiency', 'movement_speed', 'oxygen_bonus', 'safe_fall_distance', 'scale',
|
|
27
|
-
'sneaking_speed', 'spawn_reinforcements', 'step_height', 'submerged_mining_speed', 'sweeping_damage_ratio',
|
|
28
|
-
...(pv >= PV['1.21.2'] ? ['tempt_range'] : []), 'water_movement_efficiency',
|
|
29
|
-
...(pv >= PV['1.21.6'] ? ['waypoint_receive_range', 'waypoint_transmit_range'] : [])
|
|
30
|
-
]
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// mineflayer names attributes through the outdated table; map those names back to ids and then to the real names
|
|
34
|
-
function attributeKeyMapper (registry) {
|
|
35
|
-
const names = attributeRegistry(registry.version.version)
|
|
36
|
-
const type = registry.protocol?.play?.toClient?.types?.packet_entity_update_attributes
|
|
37
|
-
const table = type && JSON.stringify(type).match(/"mappings":(\{[^}]*\})/)
|
|
38
|
-
if (!names || !table) return normalizeKey
|
|
39
|
-
const ids = {}
|
|
40
|
-
for (const [id, name] of Object.entries(JSON.parse(table[1]))) ids[name] = Number(id)
|
|
41
|
-
return (key) => ids[key] != null && names[ids[key]] ? names[ids[key]] : normalizeKey(key)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
14
|
const SPRINT_IDS = new Set([SPRINT_UUID, 'minecraft:sprinting', 'sprinting'])
|
|
45
15
|
const isSprintModifier = (m) => SPRINT_IDS.has(m.uuid) || SPRINT_IDS.has(m.id)
|
|
46
16
|
const operationOf = (m) => typeof m.operation === 'number' ? m.operation : (OPERATIONS[m.operation] ?? 0)
|
|
@@ -56,9 +26,9 @@ function attributeValue (property, extra = []) {
|
|
|
56
26
|
return value
|
|
57
27
|
}
|
|
58
28
|
|
|
59
|
-
function createEnvironment (bot) {
|
|
60
|
-
const
|
|
61
|
-
|
|
29
|
+
function createEnvironment (bot, state = {}) {
|
|
30
|
+
const effectId = effectIdMapper(bot.registry)
|
|
31
|
+
const enchantmentLevel = createEnchantmentReader(bot)
|
|
62
32
|
const attributeName = attributeKeyMapper(bot.registry)
|
|
63
33
|
|
|
64
34
|
// kept from the packets directly: mineflayer files 1.16 - 1.20.4 attributes under "undefined"
|
|
@@ -80,7 +50,9 @@ function createEnvironment (bot) {
|
|
|
80
50
|
function isPushable (e) {
|
|
81
51
|
if (!e || e === bot.entity || !e.position || e.isValid === false) return false
|
|
82
52
|
if (e === bot.vehicle || e.vehicle === bot.entity) return false
|
|
83
|
-
|
|
53
|
+
if (NEVER_PUSHES.has(e.name)) return false
|
|
54
|
+
// living entities, boats and minecarts push the player; items, projectiles and frames do not
|
|
55
|
+
return LIVING_TYPES.has(entityCategory(e)) || PUSHABLE_OBJECTS.test(e.name ?? '')
|
|
84
56
|
}
|
|
85
57
|
|
|
86
58
|
return {
|
|
@@ -93,18 +65,15 @@ function createEnvironment (bot) {
|
|
|
93
65
|
return attributeValue(property, sprinting ? [{ amount: 0.3, operation: 2 }] : [])
|
|
94
66
|
},
|
|
95
67
|
effect (name) {
|
|
96
|
-
const id =
|
|
68
|
+
const id = effectId(name)
|
|
97
69
|
if (id == null) return null
|
|
98
70
|
const effect = bot.entity?.effects?.[id]
|
|
99
71
|
return effect ? effect.amplifier : null
|
|
100
72
|
},
|
|
101
|
-
bootsEnchant (name)
|
|
102
|
-
const enchants = boots()?.enchants ?? []
|
|
103
|
-
return enchants.find(e => e.name === name)?.lvl ?? 0
|
|
104
|
-
},
|
|
73
|
+
bootsEnchant: (name) => enchantmentLevel(boots(), name),
|
|
105
74
|
bootsItem: () => boots()?.name ?? null,
|
|
106
75
|
food: () => bot.food ?? 20,
|
|
107
|
-
usingItem: () =>
|
|
76
|
+
usingItem: () => state.using != null,
|
|
108
77
|
gameMode: () => bot.game?.gameMode,
|
|
109
78
|
ultraWarm: () => /nether/.test(String(bot.game?.dimension ?? '')),
|
|
110
79
|
nearbyEntities () {
|
package/lib/client/index.js
CHANGED
|
@@ -12,6 +12,11 @@ const { installServerHandlers } = require('./server')
|
|
|
12
12
|
const { installReporter } = require('./reporter')
|
|
13
13
|
const { installRiding } = require('./riding')
|
|
14
14
|
const { installTicker } = require('./ticker')
|
|
15
|
+
const { installItemUse } = require('./items')
|
|
16
|
+
const { installInteract } = require('./interact')
|
|
17
|
+
const { installProtocolPatches } = require('./protocol')
|
|
18
|
+
const { installSimulation } = require('./simulation')
|
|
19
|
+
const { installPathfinder } = require('../pathfinder')
|
|
15
20
|
|
|
16
21
|
function debugLogger (option) {
|
|
17
22
|
if (typeof option === 'function') return option
|
|
@@ -23,7 +28,18 @@ function spectraPhysics (options = {}) {
|
|
|
23
28
|
const registry = bot.registry
|
|
24
29
|
const V = versionFlags(registry)
|
|
25
30
|
const world = new WorldView(bot)
|
|
26
|
-
const
|
|
31
|
+
const state = {
|
|
32
|
+
inLevel: false,
|
|
33
|
+
configBrandSent: false,
|
|
34
|
+
suppressPlaySettings: false,
|
|
35
|
+
playSettingsSent: false,
|
|
36
|
+
heldBrand: null,
|
|
37
|
+
selectedSlot: 0,
|
|
38
|
+
carriedSlot: 0,
|
|
39
|
+
lastRotationAt: 0,
|
|
40
|
+
using: null
|
|
41
|
+
}
|
|
42
|
+
const player = new PlayerPhysics(world, V, createEnvironment(bot, state))
|
|
27
43
|
|
|
28
44
|
const ctx = {
|
|
29
45
|
bot,
|
|
@@ -35,16 +51,8 @@ function spectraPhysics (options = {}) {
|
|
|
35
51
|
options,
|
|
36
52
|
debug: debugLogger(options.debug),
|
|
37
53
|
onSend: options.onSend ?? null,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
configBrandSent: false,
|
|
41
|
-
suppressPlaySettings: false,
|
|
42
|
-
playSettingsSent: false,
|
|
43
|
-
heldBrand: null,
|
|
44
|
-
selectedSlot: 0,
|
|
45
|
-
carriedSlot: 0,
|
|
46
|
-
lastRotationAt: 0
|
|
47
|
-
}
|
|
54
|
+
outgoing: [],
|
|
55
|
+
state
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
bot.physicsEnabled = true
|
|
@@ -59,13 +67,18 @@ function spectraPhysics (options = {}) {
|
|
|
59
67
|
set onSend (fn) { ctx.onSend = typeof fn === 'function' ? fn : null }
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
installProtocolPatches(ctx)
|
|
62
71
|
installConnection(ctx)
|
|
72
|
+
installItemUse(ctx)
|
|
63
73
|
installControls(ctx)
|
|
74
|
+
installInteract(ctx)
|
|
64
75
|
installReporter(ctx)
|
|
65
76
|
installTicker(ctx)
|
|
66
77
|
installTeleport(ctx)
|
|
67
78
|
installServerHandlers(ctx)
|
|
68
79
|
installRiding(ctx)
|
|
80
|
+
installSimulation(ctx)
|
|
81
|
+
installPathfinder(ctx)
|
|
69
82
|
}
|
|
70
83
|
}
|
|
71
84
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Right click on an entity the way Minecraft#startUseItem does it: aim, interact_at with the hit point,
|
|
3
|
+
// then interact (a single packet with the location from 26.1), and leaving a vehicle by holding sneak.
|
|
4
|
+
|
|
5
|
+
const { Vec3 } = require('vec3')
|
|
6
|
+
|
|
7
|
+
const DISMOUNT_TICKS = 3
|
|
8
|
+
|
|
9
|
+
// where the look ray enters the entity's box, relative to the entity position
|
|
10
|
+
function hitPoint (eye, look, entity) {
|
|
11
|
+
const w = (entity.width ?? 0.6) / 2
|
|
12
|
+
const h = entity.height ?? 1.8
|
|
13
|
+
const p = entity.position
|
|
14
|
+
const min = [p.x - w, p.y, p.z - w]
|
|
15
|
+
const max = [p.x + w, p.y + h, p.z + w]
|
|
16
|
+
const origin = [eye.x, eye.y, eye.z]
|
|
17
|
+
const dir = [look.x, look.y, look.z]
|
|
18
|
+
let near = -Infinity
|
|
19
|
+
let far = Infinity
|
|
20
|
+
for (let i = 0; i < 3; i++) {
|
|
21
|
+
if (Math.abs(dir[i]) < 1e-9) {
|
|
22
|
+
if (origin[i] < min[i] || origin[i] > max[i]) return new Vec3(0, h / 2, 0)
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
let t1 = (min[i] - origin[i]) / dir[i]
|
|
26
|
+
let t2 = (max[i] - origin[i]) / dir[i]
|
|
27
|
+
if (t1 > t2) [t1, t2] = [t2, t1]
|
|
28
|
+
near = Math.max(near, t1)
|
|
29
|
+
far = Math.min(far, t2)
|
|
30
|
+
}
|
|
31
|
+
if (near > far || far < 0) return new Vec3(0, h / 2, 0)
|
|
32
|
+
const t = Math.max(near, 0)
|
|
33
|
+
return new Vec3(eye.x + dir[0] * t - p.x, eye.y + dir[1] * t - p.y, eye.z + dir[2] * t - p.z)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function installInteract (ctx) {
|
|
37
|
+
const { bot, client, registry, player, state } = ctx
|
|
38
|
+
const useEntityType = JSON.stringify(registry.protocol?.play?.toServer?.types?.packet_use_entity ?? '')
|
|
39
|
+
const singleInteract = !useEntityType.includes('"mouse"')
|
|
40
|
+
state.dismountTicks = 0
|
|
41
|
+
|
|
42
|
+
function lookVector () {
|
|
43
|
+
const yaw = player.yRot * Math.PI / 180
|
|
44
|
+
const pitch = player.xRot * Math.PI / 180
|
|
45
|
+
return { x: -Math.sin(yaw) * Math.cos(pitch), y: -Math.sin(pitch), z: Math.cos(yaw) * Math.cos(pitch) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function rightClick (entity, swing) {
|
|
49
|
+
if (!entity?.position) throw new Error('entity is gone')
|
|
50
|
+
await bot.lookAt(entity.position.offset(0, (entity.height ?? 1) / 2, 0))
|
|
51
|
+
const eye = { x: player.pos.x, y: player.pos.y + player.eyeHeight(), z: player.pos.z }
|
|
52
|
+
const hit = hitPoint(eye, lookVector(), entity)
|
|
53
|
+
const sneaking = !!ctx.keys().sneak
|
|
54
|
+
if (singleInteract) {
|
|
55
|
+
client.write('use_entity', { target: entity.id, hand: 'main_hand', location: { x: hit.x, y: hit.y, z: hit.z }, sneaking })
|
|
56
|
+
} else {
|
|
57
|
+
client.write('use_entity', { target: entity.id, mouse: 2, x: hit.x, y: hit.y, z: hit.z, hand: 0, sneaking })
|
|
58
|
+
client.write('use_entity', { target: entity.id, mouse: 0, hand: 0, sneaking })
|
|
59
|
+
}
|
|
60
|
+
if (swing) client.write('arm_animation', { hand: 0 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
bot.useOn = (entity) => rightClick(entity, false)
|
|
64
|
+
bot.mount = (entity) => rightClick(entity, true)
|
|
65
|
+
|
|
66
|
+
bot.dismount = () => {
|
|
67
|
+
if (!bot.vehicle) {
|
|
68
|
+
bot.emit('error', new Error('dismount: not mounted'))
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
state.dismountTicks = DISMOUNT_TICKS
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const readKeys = ctx.keys
|
|
75
|
+
ctx.keys = () => state.dismountTicks > 0 ? { ...readKeys(), sneak: true } : readKeys()
|
|
76
|
+
bot.on('physicsTick', () => { if (state.dismountTicks > 0) state.dismountTicks-- })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { installInteract, hitPoint }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// LocalPlayer#startedUsingItem: whether a held item is being used, which slows movement to a fifth.
|
|
3
|
+
// mineflayer's usingHeldItem is set for any item and cleared by any entity's status packet, so the state is
|
|
4
|
+
// rebuilt here from what the client sends and from the server's own view of the player.
|
|
5
|
+
|
|
6
|
+
const ALWAYS_EDIBLE = new Set(['golden_apple', 'enchanted_golden_apple', 'chorus_fruit', 'honey_bottle', 'suspicious_stew'])
|
|
7
|
+
const DRINKS = new Set(['potion', 'milk_bucket', 'ominous_bottle'])
|
|
8
|
+
const HELD_USE = new Set(['shield', 'trident', 'spyglass', 'goat_horn'])
|
|
9
|
+
const ARROWS = new Set(['arrow', 'spectral_arrow', 'tipped_arrow'])
|
|
10
|
+
const OFFHAND_SLOT = 45
|
|
11
|
+
const FINISHED_USING = 9
|
|
12
|
+
|
|
13
|
+
function isCharged (item) {
|
|
14
|
+
const projectiles = item.components?.find?.(c => c.type === 'charged_projectiles')
|
|
15
|
+
if (projectiles) return (projectiles.data?.projectiles ?? projectiles.data ?? []).length > 0
|
|
16
|
+
return item.nbt?.value?.Charged?.value === 1
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function installItemUse (ctx) {
|
|
20
|
+
const { bot, client, V, state } = ctx
|
|
21
|
+
const flagsIndex = V.protocol >= 755 ? 8 : 7
|
|
22
|
+
const cooldowns = new Map()
|
|
23
|
+
state.using = null
|
|
24
|
+
|
|
25
|
+
const itemIn = (hand) => hand === 1 ? bot.inventory.slots[OFFHAND_SLOT] : bot.heldItem
|
|
26
|
+
const creative = () => bot.game?.gameMode === 'creative'
|
|
27
|
+
const hasAmmo = () => creative() || bot.inventory.slots.some(i => i && ARROWS.has(i.name))
|
|
28
|
+
|
|
29
|
+
// Item#use returning CONSUME / startUsingItem for the items that have a use animation
|
|
30
|
+
function canStartUsing (item) {
|
|
31
|
+
if (!item || (cooldowns.get(item.name) ?? 0) > Date.now()) return false
|
|
32
|
+
const name = item.name
|
|
33
|
+
if (DRINKS.has(name) || HELD_USE.has(name)) return true
|
|
34
|
+
if (name === 'bow') return hasAmmo()
|
|
35
|
+
if (name === 'crossbow') return !isCharged(item) && (hasAmmo() || bot.inventory.slots[OFFHAND_SLOT]?.name === 'firework_rocket')
|
|
36
|
+
if (bot.registry.foodsByName?.[name]) return ALWAYS_EDIBLE.has(name) || creative() || (bot.food ?? 20) < 20
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const start = (hand) => {
|
|
41
|
+
const item = itemIn(hand)
|
|
42
|
+
state.using = item ? { hand, name: item.name } : null
|
|
43
|
+
}
|
|
44
|
+
const stop = () => { state.using = null }
|
|
45
|
+
|
|
46
|
+
ctx.outgoing.push((name, params) => {
|
|
47
|
+
if (name === 'use_item') {
|
|
48
|
+
const hand = params.hand ?? 0
|
|
49
|
+
if (canStartUsing(itemIn(hand))) start(hand)
|
|
50
|
+
} else if (name === 'block_place' && params.direction === -1) {
|
|
51
|
+
if (canStartUsing(bot.heldItem)) start(0)
|
|
52
|
+
} else if (name === 'block_dig' && params.status === 5) {
|
|
53
|
+
stop()
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
client.on('entity_status', (packet) => {
|
|
58
|
+
if (packet.entityId === bot.entity?.id && packet.entityStatus === FINISHED_USING) stop()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// the server's living entity flags override the client state (LocalPlayer#onSyncedDataUpdated)
|
|
62
|
+
client.on('entity_metadata', (packet) => {
|
|
63
|
+
if (packet.entityId !== bot.entity?.id) return
|
|
64
|
+
const entry = packet.metadata?.find(m => m.key === flagsIndex)
|
|
65
|
+
if (!entry || typeof entry.value !== 'number') return
|
|
66
|
+
const active = (entry.value & 1) !== 0
|
|
67
|
+
if (active && !state.using) start((entry.value & 2) !== 0 ? 1 : 0)
|
|
68
|
+
else if (!active && state.using) stop()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
client.on('set_cooldown', (packet) => {
|
|
72
|
+
const key = packet.cooldownGroup ?? bot.registry.items[packet.itemID]?.name
|
|
73
|
+
if (key) cooldowns.set(String(key).replace(/^minecraft:/, ''), Date.now() + (packet.cooldownTicks ?? 0) * 50)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
client.on('respawn', stop)
|
|
77
|
+
bot.on('death', stop)
|
|
78
|
+
|
|
79
|
+
// LivingEntity#updatingUsingItem: switching or losing the item stops the use
|
|
80
|
+
ctx.updateItemUse = () => {
|
|
81
|
+
if (state.using && itemIn(state.using.hand)?.name !== state.using.name) stop()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { installItemUse }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Fixes for packet layouts minecraft-data gets wrong. They are merged into the client's custom packets
|
|
3
|
+
// before the play state starts, so the parser is built with them.
|
|
4
|
+
|
|
5
|
+
const { PV } = require('../engine/version')
|
|
6
|
+
|
|
7
|
+
// recipe and advancement packets whose layout in minecraft-data does not match the game; mineflayer reads neither
|
|
8
|
+
function brokenPackets (pv) {
|
|
9
|
+
if (pv >= PV['1.20.5'] && pv < PV['1.21.2']) return ['declare_recipes']
|
|
10
|
+
if (pv === PV['1.21.2']) return ['declare_recipes', 'recipe_book_add', 'advancements']
|
|
11
|
+
return []
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// minecraft-protocol merges custom packets field by field, so every original field has to be overridden:
|
|
15
|
+
// the first one takes the whole payload, the others read nothing
|
|
16
|
+
function rawLayout (original) {
|
|
17
|
+
const fields = Array.isArray(original?.[1]) ? original[1] : [{}]
|
|
18
|
+
return ['container', fields.map((field, i) => i === 0
|
|
19
|
+
? { name: 'data', type: 'restBuffer' }
|
|
20
|
+
: { name: field.name ?? `unused${i}`, type: 'void' })]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function installProtocolPatches (ctx) {
|
|
24
|
+
const { client, registry } = ctx
|
|
25
|
+
const names = brokenPackets(registry.version.version)
|
|
26
|
+
if (!names.length) return
|
|
27
|
+
const types = registry.protocol?.play?.toClient?.types ?? {}
|
|
28
|
+
const patch = {}
|
|
29
|
+
for (const name of names) patch[`packet_${name}`] = rawLayout(types[`packet_${name}`])
|
|
30
|
+
|
|
31
|
+
const major = registry.version.majorVersion
|
|
32
|
+
const custom = client.customPackets ?? {}
|
|
33
|
+
const own = custom[major] ?? {}
|
|
34
|
+
const userTypes = own.play?.toClient?.types ?? {}
|
|
35
|
+
custom[major] = {
|
|
36
|
+
...own,
|
|
37
|
+
play: { ...own.play, toClient: { ...own.play?.toClient, types: { ...patch, ...userTypes } } }
|
|
38
|
+
}
|
|
39
|
+
client.customPackets = custom
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { installProtocolPatches }
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Ids the server uses for attributes, effects and enchantments. minecraft-data is outdated or wrong for
|
|
3
|
+
// several of them, so the tables here follow the game registries instead.
|
|
4
|
+
|
|
5
|
+
const { PV } = require('../engine/version')
|
|
6
|
+
|
|
7
|
+
const normalizeKey = (key) => String(key)
|
|
8
|
+
.replace(/^minecraft:/, '')
|
|
9
|
+
.replace(/^(generic|player|zombie)\./, '')
|
|
10
|
+
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
|
|
13
|
+
// attribute registry order from 1.21 on; the protocol tables in minecraft-data stop at the 1.20.5 list
|
|
14
|
+
function attributeRegistry (pv) {
|
|
15
|
+
if (pv < PV['1.21']) return null
|
|
16
|
+
return [
|
|
17
|
+
'armor', 'armor_toughness', 'attack_damage', 'attack_knockback', 'attack_speed', 'block_break_speed',
|
|
18
|
+
'block_interaction_range', 'burning_time', ...(pv >= PV['1.21.6'] ? ['camera_distance'] : []),
|
|
19
|
+
'explosion_knockback_resistance', 'entity_interaction_range', 'fall_damage_multiplier', 'flying_speed',
|
|
20
|
+
'follow_range', 'gravity', 'jump_strength', 'knockback_resistance', 'luck', 'max_absorption', 'max_health',
|
|
21
|
+
'mining_efficiency', 'movement_efficiency', 'movement_speed', 'oxygen_bonus', 'safe_fall_distance', 'scale',
|
|
22
|
+
'sneaking_speed', 'spawn_reinforcements', 'step_height', 'submerged_mining_speed', 'sweeping_damage_ratio',
|
|
23
|
+
...(pv >= PV['1.21.2'] ? ['tempt_range'] : []), 'water_movement_efficiency',
|
|
24
|
+
...(pv >= PV['1.21.6'] ? ['waypoint_receive_range', 'waypoint_transmit_range'] : [])
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// mineflayer names attributes through the outdated table; map those names back to ids and then to the real names
|
|
29
|
+
function attributeKeyMapper (registry) {
|
|
30
|
+
const names = attributeRegistry(registry.version.version)
|
|
31
|
+
const type = registry.protocol?.play?.toClient?.types?.packet_entity_update_attributes
|
|
32
|
+
const table = type && JSON.stringify(type).match(/"mappings":(\{[^}]*\})/)
|
|
33
|
+
if (!names || !table) return normalizeKey
|
|
34
|
+
const ids = {}
|
|
35
|
+
for (const [id, name] of Object.entries(JSON.parse(table[1]))) ids[name] = Number(id)
|
|
36
|
+
return (key) => ids[key] != null && names[ids[key]] ? names[ids[key]] : normalizeKey(key)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// MobEffects registration order (stable for the effects movement cares about); 1-based before 1.20.2
|
|
40
|
+
const EFFECTS = [
|
|
41
|
+
'speed', 'slowness', 'haste', 'mining_fatigue', 'strength', 'instant_health', 'instant_damage', 'jump_boost',
|
|
42
|
+
'nausea', 'regeneration', 'resistance', 'fire_resistance', 'water_breathing', 'invisibility', 'blindness',
|
|
43
|
+
'night_vision', 'hunger', 'weakness', 'poison', 'wither', 'health_boost', 'absorption', 'saturation', 'glowing',
|
|
44
|
+
'levitation', 'luck', 'unluck', 'slow_falling', 'conduit_power', 'dolphins_grace', 'bad_omen',
|
|
45
|
+
'hero_of_the_village', 'darkness'
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
function effectIdMapper (registry) {
|
|
49
|
+
const zeroBased = registry.version.version >= PV['1.20.2']
|
|
50
|
+
return (name) => {
|
|
51
|
+
const index = EFFECTS.indexOf(name)
|
|
52
|
+
return index < 0 ? null : index + (zeroBased ? 0 : 1)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// enchantments: item NBT names before 1.20.5, built-in ids in 1.20.5 - 1.20.6, the server's registry from 1.21
|
|
57
|
+
function createEnchantmentReader (bot) {
|
|
58
|
+
const registry = bot.registry
|
|
59
|
+
let synced = null
|
|
60
|
+
bot._client.on('registry_data', (packet) => {
|
|
61
|
+
if (!/enchantment$/.test(String(packet.id ?? ''))) return
|
|
62
|
+
synced = (packet.entries ?? []).map(e => normalizeKey(e.key ?? e.id ?? ''))
|
|
63
|
+
})
|
|
64
|
+
const nameOf = (id) => synced?.[id] ?? registry.enchantments?.[id]?.name ?? null
|
|
65
|
+
|
|
66
|
+
return (item, name) => {
|
|
67
|
+
if (!item) return 0
|
|
68
|
+
const component = item.components?.find?.(c => c.type === 'enchantments')
|
|
69
|
+
if (component) {
|
|
70
|
+
const list = component.data?.enchantments ?? []
|
|
71
|
+
return list.find(e => nameOf(e.id) === name)?.level ?? 0
|
|
72
|
+
}
|
|
73
|
+
let legacy
|
|
74
|
+
try { legacy = item.enchants } catch { legacy = null }
|
|
75
|
+
return Array.isArray(legacy) ? (legacy.find(e => normalizeKey(e.name) === name)?.lvl ?? 0) : 0
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// entity categories; the 1.16 tables mark every entity as a mob, so a later table is used by name
|
|
80
|
+
let entityTable = null
|
|
81
|
+
function entityCategory (entity) {
|
|
82
|
+
entityTable ??= require('minecraft-data')('1.20.4').entitiesByName
|
|
83
|
+
return entityTable[entity.name]?.type ?? entity.type
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { normalizeKey, attributeKeyMapper, effectIdMapper, createEnchantmentReader, entityCategory }
|