spectra-physics 1.0.0 → 1.2.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 CHANGED
@@ -22,21 +22,46 @@ several places that are visible to the server:
22
22
  | Collision | legacy 1.8 algorithm | `Shapes.collide` with VoxelShape epsilons, Y → larger horizontal axis order |
23
23
  | Step-up | single step | pre-1.21 heuristic and 1.21+ candidate step heights |
24
24
  | First tick after a teleport | moves immediately | no movement, gravity applied after `move`, like vanilla |
25
- | Position packets | time based, last-sent values reset on teleport | `sendPosition` rules: 2·10⁻⁴ threshold, 20-tick reminder, last-sent values kept across teleports |
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
+ | Input | raw ±1 | per version: sneak/item scaling then 0.98 up to 1.21.4, normalized square movement from 1.21.5 |
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; 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 |
26
33
  | Rotation | continuous interpolation | whole mouse steps of 0.15°, accumulated in float |
27
- | Sprint | applied the same tick | speed attribute updated at the end of the tick, as in `Player#aiStep` |
34
+ | Sprint | applied the same tick | speed follows the sprint state in the tick it is sent, as the server applies it |
28
35
  | Tick loop | fixed interval | `Minecraft` timer: catches up at most 10 ticks |
29
36
  | 1.21.2+ | no `tick_end` | `tick_end` after every tick, `player_input` on key change |
30
37
  | 1.21.4+ | `player_loaded` on first health update | sent once the level around the player is loaded |
31
38
  | Teleport answer | one order for all versions | per version (1.21.2–1.21.3 send position before the confirmation) |
32
- | 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 |
33
40
  | Brand, 1.20.2+ | sent in play state | sent in the configuration phase, once |
41
+ | Open container | keeps moving | movement keys are ignored while a window is open, as in the game |
34
42
 
35
- It also fixes three mineflayer issues that leave a bot stuck:
43
+ It also works around mineflayer issues that break movement:
36
44
 
37
45
  - an empty full chunk (no sections) sent to a 1.16 client is treated as an unload, so the player never ticks in it;
38
46
  - a removed vehicle is never dismounted (`entityGone` is listened for on the wrong emitter);
39
- - an out-of-range hotbar slot from the server throws inside the packet handler.
47
+ - an out-of-range hotbar slot from the server throws inside the packet handler;
48
+ - player attributes are misread: stored under `undefined` on 1.16.5 – 1.20.4 and named from an outdated id table
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.
54
+
55
+ Before 1.18.2 the client only reports moves longer than 0.03. Airborne moves shorter than that are still reported,
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
57
+ cannot reconstruct the skipped vertical movement.
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.
40
65
 
41
66
  ## Installation
42
67
 
@@ -131,16 +156,19 @@ See [`test/helpers.js`](test/helpers.js) for a complete in-memory world.
131
156
  ## What is simulated
132
157
 
133
158
  - gravity, air drag and ground friction (slipperiness per block, supporting block on 1.20+)
134
- - collisions against block shapes, step-up, sneaking edge back-off (both the old and the 1.21.2+ rules)
135
- - walking, sprinting (double-tap and key), sprint-jumping, crouch pose and its bounding box
136
- - water and lava with flow pushing, climbing (ladders, vines, scaffolding, trapdoors over ladders)
137
- - cobweb, sweet berry bush, powder snow, honey (sliding), soul sand, slime and bed bouncing
138
- - levitation, slow falling, jump boost, speed / slowness via attributes, dolphin's grace
159
+ - collisions against block shapes, step-up, sneaking edge back-off
160
+ - walking, sprinting (double-tap and key), sprint-jumping, crouching, crawling and swimming poses
161
+ - water and lava with flow pushing, swimming, climbing (ladders, vines, scaffolding, trapdoors over ladders, powder snow in leather boots)
162
+ - cobweb (and weaving), sweet berry bush, powder snow, honey sliding, bubble columns, soul sand, slime and bed bouncing
163
+ - being pushed out of blocks and by overlapping entities
164
+ - levitation, slow falling, jump boost, dolphin's grace, depth strider and soul speed,
165
+ attributes: movement speed, gravity, jump strength, step height, sneaking speed, movement and water efficiency
166
+ - using items: eating, drinking, blocking with a shield, drawing a bow
139
167
  - creative flight, including toggling with a double jump and landing
140
168
  - knockback and explosion velocity, 1.21.2+ relative teleports with velocity
141
169
  - riding boats (client-controlled) and minecarts
142
170
 
143
- Not simulated yet: elytra flight, swimming pose, bubble columns, entity collisions, world border.
171
+ Not simulated yet: elytra flight, riptide, world border.
144
172
 
145
173
  ## Tests
146
174
 
@@ -148,20 +176,20 @@ Not simulated yet: elytra flight, swimming pose, bubble columns, entity collisio
148
176
  npm test
149
177
  ```
150
178
 
151
- Offline tests for 1.16.5, 1.18.2, 1.20.4, 1.21.4 and 26.1 cover free fall, landing on full blocks and slabs,
152
- walking and sprinting speed, jump height, step-up, wall collision, sneaking at an edge, ice and cobwebs.
153
-
154
- ## Releasing
179
+ Offline tests for 1.16.5 – 26.1 cover free fall, landing on blocks and slabs, walking and sprinting speed,
180
+ jump height, step-up, walls and corners, sneaking at an edge, input per version, swimming, cobwebs, bubble columns,
181
+ powder snow, ice, soul sand, pushing, item use, and the position reporting thresholds.
155
182
 
156
- Releases are published to [npm](https://www.npmjs.com/package/spectra-physics) by GitHub Actions.
183
+ ## Layout
157
184
 
158
- 1. Bump `version` in `package.json` and push to `main`.
159
- 2. Create a GitHub release with the tag `v<version>` (for example `v1.0.1`).
160
-
161
- The *Publish to npm* workflow then runs the tests, checks that the tag matches `package.json`, and runs
162
- `npm publish --provenance --access public`. Publishing needs the repository secret `NPM_TOKEN`
163
- (an npm granular access token with read and write access to packages and two-factor bypass enabled;
164
- set it with `gh secret set NPM_TOKEN`). npm limits the lifetime of such tokens, so rotate it before it expires.
185
+ ```
186
+ lib/engine world access, collisions, version switches, boat
187
+ lib/engine/player the player tick: input, fluids, blocks under the player, move, travel, block effects and their
188
+ traversal order, pose, pushing
189
+ lib/client mineflayer integration: controls, teleports, server packets, position reporting, riding, item use,
190
+ entity interaction, registry tables, protocol fixes, the shared clock
191
+ bench tick cost and bit-exact output fingerprints
192
+ ```
165
193
 
166
194
  ## License
167
195
 
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
- const spectraPhysics = require('./lib/plugin')
4
- const { PlayerPhysics } = require('./lib/engine/physics')
3
+ const spectraPhysics = require('./lib/client')
4
+ const { PlayerPhysics } = require('./lib/engine/player')
5
5
  const BoatPhysics = require('./lib/engine/boat')
6
6
  const WorldView = require('./lib/engine/world')
7
7
  const AABB = require('./lib/engine/aabb')
@@ -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 }
@@ -0,0 +1,103 @@
1
+ 'use strict'
2
+ // Outgoing packets. Also reorders what mineflayer sends on login so it matches the game client:
3
+ // brand + client information in the configuration phase (1.20.2+), settings before brand on older versions,
4
+ // and player_loaded only when the level has actually loaded.
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
+
9
+ const isBrand = (channel) => channel === 'minecraft:brand' || channel === 'MC|Brand'
10
+
11
+ function brandPayload (brand) {
12
+ const text = Buffer.from(brand, 'utf8')
13
+ const length = []
14
+ let n = text.length
15
+ do {
16
+ let byte = n & 0x7f
17
+ n >>>= 7
18
+ if (n) byte |= 0x80
19
+ length.push(byte)
20
+ } while (n)
21
+ return { channel: 'minecraft:brand', data: Buffer.concat([Buffer.from(length), text]) }
22
+ }
23
+
24
+ function installConnection (ctx) {
25
+ const { client, V, state } = ctx
26
+ const rawWrite = client.write.bind(client)
27
+ const brand = ctx.options.brand ?? client.options?.brand ?? 'vanilla'
28
+ let ours = false
29
+
30
+ const report = (name, params) => {
31
+ for (const watch of ctx.outgoing) watch(name, params)
32
+ if (ctx.onSend) ctx.onSend(name, params)
33
+ if (ctx.debug) ctx.debug('C->S', name, JSON.stringify(params))
34
+ }
35
+
36
+ ctx.send = (name, params) => {
37
+ report(name, params)
38
+ ours = true
39
+ try { rawWrite(name, params) } finally { ours = false }
40
+ }
41
+
42
+ const queued = []
43
+
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
+ }
49
+ if (!ours) {
50
+ if (name === 'player_loaded' && V.clientLoaded) return
51
+ if (name === 'custom_payload' && isBrand(params?.channel) && client.state === 'play') {
52
+ if (V.configPhase && state.configBrandSent) return
53
+ if (!V.configPhase && !state.playSettingsSent) {
54
+ state.heldBrand = params
55
+ return
56
+ }
57
+ }
58
+ if (name === 'settings') {
59
+ if (client.state === 'configuration' && V.configPhase && !state.configBrandSent) {
60
+ ctx.send('custom_payload', brandPayload(brand))
61
+ state.configBrandSent = true
62
+ } else if (client.state === 'play') {
63
+ if (state.suppressPlaySettings) {
64
+ state.suppressPlaySettings = false
65
+ return
66
+ }
67
+ report(name, params)
68
+ rawWrite(name, params)
69
+ state.playSettingsSent = true
70
+ if (state.heldBrand) {
71
+ const held = state.heldBrand
72
+ state.heldBrand = null
73
+ rawWrite('custom_payload', held)
74
+ }
75
+ return
76
+ }
77
+ }
78
+ report(name, params)
79
+ }
80
+ return rawWrite(name, params)
81
+ }
82
+
83
+ client.on('finish_configuration', () => { state.suppressPlaySettings = true })
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
+
95
+ ctx.flushHeldBrand = () => {
96
+ if (!state.heldBrand) return
97
+ const held = state.heldBrand
98
+ state.heldBrand = null
99
+ ctx.send('custom_payload', held)
100
+ }
101
+ }
102
+
103
+ module.exports = { installConnection }
@@ -0,0 +1,78 @@
1
+ 'use strict'
2
+ // mineflayer movement API on top of the simulated player: control states and mouse-like turning.
3
+
4
+ const conv = require('mineflayer/lib/conversions')
5
+ const { f32, clamp, wrapDegrees } = require('../engine/mth')
6
+
7
+ const MOUSE_STEP = f32(0.15) // degrees per mouse pixel at default sensitivity
8
+ const RELEASED = Object.freeze({ forward: false, back: false, left: false, right: false, jump: false, sprint: false, sneak: false })
9
+
10
+ function installControls (ctx) {
11
+ const { bot, player } = ctx
12
+ const keys = { ...RELEASED }
13
+ const turnSpeed = ctx.options.turnSpeed ?? 35
14
+ let lookWaiters = []
15
+
16
+ bot.setControlState = (control, value) => {
17
+ if (!(control in keys)) throw new Error(`invalid control: ${control}`)
18
+ keys[control] = !!value
19
+ }
20
+ bot.getControlState = (control) => keys[control]
21
+ bot.clearControlStates = () => { for (const k in keys) keys[k] = false }
22
+ bot.controlState = {}
23
+ for (const k of Object.keys(keys)) {
24
+ Object.defineProperty(bot.controlState, k, { get: () => keys[k], set: (v) => { keys[k] = !!v } })
25
+ }
26
+
27
+ // with a container open the game client stops reading movement keys
28
+ ctx.keys = () => bot.currentWindow ? RELEASED : keys
29
+
30
+ ctx.turn = (maxDegrees = turnSpeed) => {
31
+ const targetYaw = conv.toNotchianYaw(bot.entity.yaw)
32
+ const targetPitch = conv.toNotchianPitch(bot.entity.pitch)
33
+ const limit = maxDegrees / MOUSE_STEP
34
+ const dx = clamp(Math.round(wrapDegrees(targetYaw - player.yRot) / MOUSE_STEP), -limit, limit)
35
+ const dy = clamp(Math.round((targetPitch - player.xRot) / MOUSE_STEP), -limit, limit)
36
+ if (dx !== 0) player.yRot = f32(player.yRot + f32(f32(dx) * MOUSE_STEP))
37
+ if (dy !== 0) player.xRot = f32(clamp(f32(player.xRot + f32(f32(dy) * MOUSE_STEP)), -90, 90))
38
+ const reached = Math.abs(wrapDegrees(targetYaw - player.yRot)) < MOUSE_STEP && Math.abs(targetPitch - player.xRot) < MOUSE_STEP
39
+ if (reached && lookWaiters.length) {
40
+ const waiters = lookWaiters
41
+ lookWaiters = []
42
+ for (const resolve of waiters) resolve()
43
+ }
44
+ }
45
+
46
+ bot.look = async (yaw, pitch, force) => {
47
+ bot.entity.yaw = yaw
48
+ bot.entity.pitch = pitch
49
+ if (force) {
50
+ ctx.turn(Infinity)
51
+ return
52
+ }
53
+ await new Promise(resolve => lookWaiters.push(resolve))
54
+ }
55
+
56
+ bot.lookAt = async (point, force) => {
57
+ const delta = point.minus(bot.entity.position.offset(0, player.eyeHeight(), 0))
58
+ const yaw = Math.atan2(-delta.x, -delta.z)
59
+ const pitch = Math.atan2(delta.y, Math.sqrt(delta.x * delta.x + delta.z * delta.z))
60
+ await bot.look(yaw, pitch, force)
61
+ }
62
+
63
+ bot.waitForTicks = async (ticks) => {
64
+ if (ticks <= 0) return
65
+ await new Promise(resolve => {
66
+ const onTick = () => {
67
+ if (--ticks > 0) return
68
+ bot.removeListener('physicsTick', onTick)
69
+ resolve()
70
+ }
71
+ bot.on('physicsTick', onTick)
72
+ })
73
+ }
74
+
75
+ bot.elytraFly = async () => { throw new Error('elytra flight is not simulated') }
76
+ }
77
+
78
+ module.exports = { installControls }
@@ -0,0 +1,94 @@
1
+ 'use strict'
2
+ // What the movement engine needs to know about the player besides the world.
3
+
4
+ const { attributeKeyMapper, effectIdMapper, createEnchantmentReader, entityCategory } = require('./registries')
5
+
6
+ const SPRINT_UUID = '662a6b8d-da3e-4c1c-8813-96ea6097278d'
7
+ const OPERATIONS = { add_value: 0, add_multiplied_base: 1, add_multiplied_total: 2 }
8
+ const PUSH_RANGE = 4
9
+ const PUSHABLE_OBJECTS = /boat|raft|minecart/
10
+ const LIVING_TYPES = new Set(['player', 'mob', 'animal', 'hostile', 'passive', 'water_creature', 'ambient'])
11
+ const NEVER_PUSHES = new Set(['armor_stand', 'bat', 'parrot'])
12
+ const BOOTS_SLOT = 8
13
+
14
+ const SPRINT_IDS = new Set([SPRINT_UUID, 'minecraft:sprinting', 'sprinting'])
15
+ const isSprintModifier = (m) => SPRINT_IDS.has(m.uuid) || SPRINT_IDS.has(m.id)
16
+ const operationOf = (m) => typeof m.operation === 'number' ? m.operation : (OPERATIONS[m.operation] ?? 0)
17
+
18
+ // AttributeInstance#calculateValue, with the sprint modifier owned by the client
19
+ function attributeValue (property, extra = []) {
20
+ const modifiers = property.modifiers.filter(m => !isSprintModifier(m)).concat(extra)
21
+ let base = property.value
22
+ for (const m of modifiers) if (operationOf(m) === 0) base += m.amount
23
+ let value = base
24
+ for (const m of modifiers) if (operationOf(m) === 1) value += base * m.amount
25
+ for (const m of modifiers) if (operationOf(m) === 2) value *= 1 + m.amount
26
+ return value
27
+ }
28
+
29
+ function createEnvironment (bot, state = {}) {
30
+ const effectId = effectIdMapper(bot.registry)
31
+ const enchantmentLevel = createEnchantmentReader(bot)
32
+ const attributeName = attributeKeyMapper(bot.registry)
33
+
34
+ // kept from the packets directly: mineflayer files 1.16 - 1.20.4 attributes under "undefined"
35
+ let attributes = {}
36
+ const onAttributes = (packet) => {
37
+ if (packet.entityId !== bot.entity?.id) return
38
+ for (const p of packet.properties) attributes[attributeName(p.key ?? p.name)] = { value: p.value, modifiers: p.modifiers ?? [] }
39
+ }
40
+ bot._client.on('entity_update_attributes', onAttributes)
41
+ bot._client.on('update_attributes', onAttributes)
42
+ bot._client.on('login', () => { attributes = {} })
43
+
44
+ const findAttribute = (name) => attributes[name] ?? null
45
+
46
+ function boots () {
47
+ return bot.inventory?.slots?.[BOOTS_SLOT] ?? null
48
+ }
49
+
50
+ function isPushable (e) {
51
+ if (!e || e === bot.entity || !e.position || e.isValid === false) return false
52
+ if (e === bot.vehicle || e.vehicle === bot.entity) return false
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 ?? '')
56
+ }
57
+
58
+ return {
59
+ attribute (name, fallback) {
60
+ const property = findAttribute(name)
61
+ return property ? attributeValue(property) : fallback
62
+ },
63
+ movementSpeed (sprinting) {
64
+ const property = findAttribute('movement_speed') ?? { value: 0.1, modifiers: [] }
65
+ return attributeValue(property, sprinting ? [{ amount: 0.3, operation: 2 }] : [])
66
+ },
67
+ effect (name) {
68
+ const id = effectId(name)
69
+ if (id == null) return null
70
+ const effect = bot.entity?.effects?.[id]
71
+ return effect ? effect.amplifier : null
72
+ },
73
+ bootsEnchant: (name) => enchantmentLevel(boots(), name),
74
+ bootsItem: () => boots()?.name ?? null,
75
+ food: () => bot.food ?? 20,
76
+ usingItem: () => state.using != null,
77
+ gameMode: () => bot.game?.gameMode,
78
+ ultraWarm: () => /nether/.test(String(bot.game?.dimension ?? '')),
79
+ nearbyEntities () {
80
+ const me = bot.entity?.position
81
+ if (!me) return []
82
+ const out = []
83
+ for (const e of Object.values(bot.entities)) {
84
+ if (!isPushable(e)) continue
85
+ const p = e.position
86
+ if (Math.abs(p.x - me.x) > PUSH_RANGE || Math.abs(p.y - me.y) > PUSH_RANGE || Math.abs(p.z - me.z) > PUSH_RANGE) continue
87
+ out.push({ x: p.x, y: p.y, z: p.z, width: e.width ?? 0.6, height: e.height ?? 1.8 })
88
+ }
89
+ return out
90
+ }
91
+ }
92
+ }
93
+
94
+ module.exports = { createEnvironment }
@@ -0,0 +1,81 @@
1
+ 'use strict'
2
+ // SpectraPhysics: replacement for mineflayer's `physics` plugin that ticks and reports movement like the game client.
3
+
4
+ const { versionFlags } = require('../engine/version')
5
+ const WorldView = require('../engine/world')
6
+ const { PlayerPhysics } = require('../engine/player')
7
+ const { createEnvironment } = require('./environment')
8
+ const { installConnection } = require('./connection')
9
+ const { installControls } = require('./controls')
10
+ const { installTeleport } = require('./teleport')
11
+ const { installServerHandlers } = require('./server')
12
+ const { installReporter } = require('./reporter')
13
+ const { installRiding } = require('./riding')
14
+ const { installTicker } = require('./ticker')
15
+ const { installItemUse } = require('./items')
16
+ const { installInteract } = require('./interact')
17
+ const { installProtocolPatches } = require('./protocol')
18
+
19
+ function debugLogger (option) {
20
+ if (typeof option === 'function') return option
21
+ return option ? (...args) => console.log('[spectra]', ...args) : null
22
+ }
23
+
24
+ function spectraPhysics (options = {}) {
25
+ return function inject (bot) {
26
+ const registry = bot.registry
27
+ const V = versionFlags(registry)
28
+ const world = new WorldView(bot)
29
+ const state = {
30
+ inLevel: false,
31
+ configBrandSent: false,
32
+ suppressPlaySettings: false,
33
+ playSettingsSent: false,
34
+ heldBrand: null,
35
+ selectedSlot: 0,
36
+ carriedSlot: 0,
37
+ lastRotationAt: 0,
38
+ using: null
39
+ }
40
+ const player = new PlayerPhysics(world, V, createEnvironment(bot, state))
41
+
42
+ const ctx = {
43
+ bot,
44
+ client: bot._client,
45
+ registry,
46
+ V,
47
+ world,
48
+ player,
49
+ options,
50
+ debug: debugLogger(options.debug),
51
+ onSend: options.onSend ?? null,
52
+ outgoing: [],
53
+ state
54
+ }
55
+
56
+ bot.physicsEnabled = true
57
+ bot.physics = { gravity: 0.08, yawSpeed: 3, pitchSpeed: 3, stepHeight: 0.6, engine: 'spectra' }
58
+ bot.spectra = {
59
+ player,
60
+ flags: V,
61
+ world,
62
+ state: ctx.state,
63
+ get lastRotationAt () { return ctx.state.lastRotationAt },
64
+ get onSend () { return ctx.onSend },
65
+ set onSend (fn) { ctx.onSend = typeof fn === 'function' ? fn : null }
66
+ }
67
+
68
+ installProtocolPatches(ctx)
69
+ installConnection(ctx)
70
+ installItemUse(ctx)
71
+ installControls(ctx)
72
+ installInteract(ctx)
73
+ installReporter(ctx)
74
+ installTicker(ctx)
75
+ installTeleport(ctx)
76
+ installServerHandlers(ctx)
77
+ installRiding(ctx)
78
+ }
79
+ }
80
+
81
+ module.exports = spectraPhysics
@@ -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 }