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.
@@ -0,0 +1,434 @@
1
+ 'use strict'
2
+ // Pathfinder that walks with the engine itself: A* over moves costed in ticks, and a follower that picks
3
+ // keys by simulating the player a few ticks ahead, turns like a mouse and breaks blocks tick by tick.
4
+ // The API mirrors mineflayer-pathfinder (setGoal, goto, stop, goals, path events).
5
+
6
+ const { Vec3 } = require('vec3')
7
+ const { AStar } = require('./astar')
8
+ const { Terrain } = require('./terrain')
9
+ const { installDigger, bestTool } = require('./digger')
10
+ const { f32, wrapDegrees } = require('../engine/mth')
11
+
12
+ const MOUSE_STEP = f32(0.15)
13
+ const STUCK_TICKS = 40
14
+ const LOOKAHEAD = 4
15
+ const KEYS = ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']
16
+
17
+ const conv = require('mineflayer/lib/conversions')
18
+ const toDegrees = (yaw) => conv.toNotchianYaw(yaw)
19
+
20
+ function installPathfinder (ctx) {
21
+ const { bot, player, world, state } = ctx
22
+ installDigger(ctx)
23
+
24
+ const settings = {
25
+ canDig: true,
26
+ allowParkour: true,
27
+ allowLongParkour: false,
28
+ allowSprinting: true,
29
+ maxDropDown: 3,
30
+ maxDigTicks: 400,
31
+ thinkTimeout: 5000,
32
+ tickTimeout: 25,
33
+ maxNodes: 200000
34
+ }
35
+ const terrain = new Terrain(world, settings)
36
+
37
+ let goal = null
38
+ let dynamic = false
39
+ let search = null
40
+ let path = []
41
+ let index = 0
42
+ let needPlan = false
43
+ let digging = false
44
+ let stuck = { ticks: 0, pos: null }
45
+ let owned = false
46
+
47
+ // ---- keys and looking -------------------------------------------------------------------------
48
+
49
+ function keys (k = {}) {
50
+ if (process.env.SPECTRA_PATH_DEBUG) {
51
+ const n = path[index]
52
+ const p = player.pos
53
+ console.log(`[path] ${index}/${path.length} ${n ? n.kind + '@' + n.x + ',' + n.y + ',' + n.z : '-'} pos ${p.x.toFixed(2)},${p.y.toFixed(2)},${p.z.toFixed(2)} yaw ${player.yRot.toFixed(1)} keys ${KEYS.filter(key => k[key]).join('+')}`)
54
+ }
55
+ owned = true
56
+ for (const key of KEYS) bot.setControlState(key, !!k[key])
57
+ }
58
+
59
+ function release () {
60
+ if (!owned) return
61
+ owned = false
62
+ for (const key of KEYS) bot.setControlState(key, false)
63
+ }
64
+
65
+ // where the mouse will be after a tick of turning toward yaw (game degrees), as controls.turn does it
66
+ function turnStep (from, to) {
67
+ const max = (ctx.options.turnSpeed ?? 35) / MOUSE_STEP
68
+ const steps = Math.max(-max, Math.min(max, Math.round(wrapDegrees(to - from) / MOUSE_STEP)))
69
+ return steps === 0 ? from : f32(from + f32(f32(steps) * MOUSE_STEP))
70
+ }
71
+
72
+ function yawTo (x, z) {
73
+ return Math.atan2(-(x - player.pos.x), -(z - player.pos.z))
74
+ }
75
+
76
+ // ---- looking ahead with the engine -------------------------------------------------------------
77
+
78
+ function simulate (k, yaw, ticks, until) {
79
+ const sim = player.clone()
80
+ const target = toDegrees(yaw)
81
+ const input = { forward: false, back: false, left: false, right: false, jump: false, sprint: false, sneak: false, ...k }
82
+ for (let i = 0; i < ticks; i++) {
83
+ sim.yRot = turnStep(sim.yRot, target)
84
+ sim.tick(input)
85
+ const r = until(sim, i)
86
+ if (r !== undefined) return r ? sim : null
87
+ }
88
+ return null
89
+ }
90
+
91
+ function reachedBy (sim, n) {
92
+ const dx = sim.pos.x - (n.x + 0.5)
93
+ const dz = sim.pos.z - (n.z + 0.5)
94
+ if (Math.abs(dx) > 0.35 || Math.abs(dz) > 0.35) return false
95
+ if (n.water) return sim.pos.y >= n.y - 0.3 && sim.pos.y <= n.y + 1.2
96
+ if (n.climb) return sim.pos.y >= n.floor - 0.2 && sim.pos.y < n.floor + 1
97
+ return Math.abs(sim.pos.y - n.floor) < 0.1 && sim.onGround
98
+ }
99
+
100
+ function reached (n) { return reachedBy(player, n) }
101
+
102
+ // the run toward n with these keys gets there without falling below it or stalling
103
+ function canRun (k, n, ticks = 30) {
104
+ const yaw = yawTo(n.x + 0.5, n.z + 0.5)
105
+ return !!simulate(k, yaw, ticks, (sim) => {
106
+ if (reachedBy(sim, n)) return true
107
+ if (!n.water && sim.pos.y < Math.min(n.floor, player.pos.y) - 0.6) return false
108
+ if (sim.horizontalCollision && sim.onGround && !k.jump) return false
109
+ return undefined
110
+ })
111
+ }
112
+
113
+ // ---- planning ----------------------------------------------------------------------------------
114
+
115
+ function currentNode () {
116
+ const p = player.pos
117
+ const x = Math.floor(p.x)
118
+ const z = Math.floor(p.z)
119
+ if (player.isInWater()) {
120
+ const y = Math.floor(p.y + 0.1)
121
+ return { x, y, z, floor: y, water: true, climb: false, dig: [] }
122
+ }
123
+ const feetY = Math.floor(p.y + 0.2)
124
+ const feet = terrain.at(x, Math.floor(p.y), z)
125
+ if (feet?.climbable && !player.onGround) return { x, y: Math.floor(p.y), z, floor: Math.floor(p.y), water: false, climb: true, dig: [] }
126
+ if (player.onGround) return { x, y: feetY, z, floor: p.y, water: false, climb: !!feet?.climbable, dig: [] }
127
+ // in the air: plan from the floor right below, unless still falling far
128
+ for (let y = feetY; y >= feetY - 2; y--) {
129
+ const floor = terrain.floorAt(x, y, z)
130
+ if (floor != null && p.y - floor < 1.5) return { x, y, z, floor, water: false, climb: false, dig: [] }
131
+ }
132
+ return null
133
+ }
134
+
135
+ function plan () {
136
+ const start = currentNode()
137
+ if (!start) return false
138
+ needPlan = false
139
+ terrain.prepare(bot)
140
+ terrain.traits.length = 0
141
+ search = new AStar(start, goal, terrain, settings)
142
+ return true
143
+ }
144
+
145
+ function adopt (result) {
146
+ path = result.path
147
+ index = 0
148
+ aimed = -1
149
+ stuck = { ticks: 0, pos: null }
150
+ bot.emit('path_update', result)
151
+ if (result.status === 'noPath' && !path.length) bot.emit('path_reset', 'no_path')
152
+ }
153
+
154
+ function reset (reason) {
155
+ path = []
156
+ index = 0
157
+ search = null
158
+ needPlan = true
159
+ bot.emit('path_reset', reason)
160
+ }
161
+
162
+ // ---- following ---------------------------------------------------------------------------------
163
+
164
+ const flat = (n) => n.kind === 'walk' && !n.dig.length && !n.water && !n.climb
165
+ const dist2 = (ax, az, bx, bz) => (ax - bx) ** 2 + (az - bz) ** 2
166
+
167
+ function advance () {
168
+ for (let i = Math.min(path.length - 1, index + LOOKAHEAD); i >= index; i--) {
169
+ if (reached(path[i]) && !path.slice(index, i + 1).some(n => pendingDig(n).length)) {
170
+ index = i + 1
171
+ break
172
+ }
173
+ }
174
+ // cutting corners on level ground: the nearest of the next nodes is where we are on the route
175
+ const p = player.pos
176
+ if (player.onGround && index < path.length && flat(path[index])) {
177
+ let best = index
178
+ let bestD = Infinity
179
+ for (let i = index; i < path.length && i <= index + LOOKAHEAD; i++) {
180
+ const n = path[i]
181
+ if (!flat(n) || Math.abs(p.y - n.floor) > 0.6) break
182
+ const d = dist2(p.x, p.z, n.x + 0.5, n.z + 0.5)
183
+ if (d < bestD) { bestD = d; best = i }
184
+ }
185
+ index = best
186
+ }
187
+ // a node is behind us once the next one is closer than it is
188
+ while (index + 1 < path.length && player.onGround) {
189
+ const a = path[index]
190
+ const b = path[index + 1]
191
+ if (!flat(a) || !flat(b) || Math.abs(a.floor - b.floor) > 0.6 || Math.abs(p.y - a.floor) > 0.6) break
192
+ if (dist2(p.x, p.z, b.x + 0.5, b.z + 0.5) >= dist2(a.x + 0.5, a.z + 0.5, b.x + 0.5, b.z + 0.5)) break
193
+ index++
194
+ }
195
+ }
196
+
197
+ function pendingDig (n) {
198
+ return n.dig.filter(([x, y, z]) => {
199
+ const b = bot.blockAt(new Vec3(x, y, z))
200
+ return b && b.boundingBox !== 'empty' && b.name !== 'air'
201
+ })
202
+ }
203
+
204
+ // the furthest walk node straight ahead that a sprint reaches without detours; kept while it still works
205
+ let aimed = -1
206
+ function aimIndex (sprint) {
207
+ if (aimed > index && aimed < path.length && aimed <= index + LOOKAHEAD && canRun({ forward: true, sprint }, path[aimed], 40)) return aimed
208
+ aimed = pickAim(sprint)
209
+ return aimed
210
+ }
211
+
212
+ function pickAim (sprint) {
213
+ let target = index
214
+ const first = path[index]
215
+ if (first.kind !== 'walk' || first.dig.length || first.water || first.climb) return target
216
+ for (let i = index + 1; i < path.length && i <= index + LOOKAHEAD; i++) {
217
+ const n = path[i]
218
+ if (n.kind !== 'walk' || n.dig.length || n.water || n.climb || Math.abs(n.floor - first.floor) > 0.6) break
219
+ if (!canRun({ forward: true, sprint }, n, 40)) break
220
+ target = i
221
+ }
222
+ return target
223
+ }
224
+
225
+ function moveToward (n, i) {
226
+ const yaw = yawTo(n.x + 0.5, n.z + 0.5)
227
+ // only the yaw follows the route; the pitch stays where the player left it
228
+ bot.look(yaw, bot.entity.pitch).catch(() => {})
229
+ const off = Math.abs(wrapDegrees(toDegrees(yaw) - player.yRot))
230
+ const facing = off < 90
231
+ const sprintOk = settings.allowSprinting && off < 30 && !player.isInWater()
232
+ const dxz = Math.hypot(n.x + 0.5 - player.pos.x, n.z + 0.5 - player.pos.z)
233
+
234
+ if (n.water || player.isInWater()) {
235
+ const rise = n.y >= Math.floor(player.pos.y) || n.kind === 'jump'
236
+ // holding sneak sinks four times faster than drifting down
237
+ const sink = n.water && n.y < Math.floor(player.pos.y)
238
+ return keys({ forward: dxz > 0.2, jump: rise && !sink, sneak: sink })
239
+ }
240
+
241
+ if (n.climb && n.kind === 'climb') {
242
+ if (n.floor > player.pos.y + 0.1) return keys({ jump: true, forward: dxz > 0.25 })
243
+ return keys({ forward: dxz > 0.25 })
244
+ }
245
+
246
+ if (n.kind === 'climb' || (n.kind === 'jump' && path[i - 1]?.climb)) {
247
+ return keys({ forward: true, jump: true })
248
+ }
249
+
250
+ if (n.kind === 'jump' || n.kind === 'parkour') {
251
+ const sprint = sprintOk && (n.kind === 'parkour' || dxz > 1.5)
252
+ const jumpNow = player.onGround && canRun({ forward: true, sprint, jump: true }, n, 25)
253
+ if (jumpNow) return keys({ forward: true, sprint, jump: true })
254
+ // about to run out of floor before the jump would work: jump anyway
255
+ const next = simulate({ forward: true, sprint }, yaw, 1, (sim) => sim.onGround)
256
+ if (n.kind === 'parkour' && !next && player.onGround) return keys({ forward: true, sprint, jump: true })
257
+ const blocked = player.horizontalCollision && player.onGround
258
+ return keys({ forward: facing || dxz < 0.5, sprint, jump: blocked })
259
+ }
260
+
261
+ if (n.kind === 'dig-down') return keys({ forward: dxz > 0.3 && facing })
262
+
263
+ // walk / drop
264
+ const sprint = sprintOk && dxz > 0.8 && canRun({ forward: true, sprint: true }, n, 30)
265
+ return keys({ forward: facing || dxz < 0.3, sprint })
266
+ }
267
+
268
+ function follow () {
269
+ advance()
270
+ if (index >= path.length) {
271
+ path = []
272
+ const here = currentNode()
273
+ if (here && goal.isEnd(here)) return arrive()
274
+ needPlan = true
275
+ return
276
+ }
277
+
278
+ const n = path[index]
279
+ const dig = pendingDig(n)
280
+ if (dig.length) {
281
+ if (Math.hypot(player.vel.x, player.vel.z) > 0.03 || (!player.onGround && !n.climb && !player.isInWater())) return release()
282
+ release()
283
+ const [x, y, z] = dig[0]
284
+ const block = bot.blockAt(new Vec3(x, y, z))
285
+ digging = true
286
+ ctx.dig(block).catch(() => reset('dig_failed')).then(() => { digging = false })
287
+ return
288
+ }
289
+
290
+ const aim = n.kind === 'walk' ? aimIndex(settings.allowSprinting) : index
291
+ moveToward(path[aim], aim)
292
+
293
+ // not moving for a while, or pushed far off the route: plan again from here
294
+ const p = player.pos
295
+ if (!stuck.pos || Math.hypot(p.x - stuck.pos.x, p.y - stuck.pos.y, p.z - stuck.pos.z) > 0.15) {
296
+ stuck = { ticks: 0, pos: { x: p.x, y: p.y, z: p.z } }
297
+ } else if (++stuck.ticks > STUCK_TICKS) return reset('stuck')
298
+ const target = path[index]
299
+ if (Math.hypot(target.x + 0.5 - p.x, target.floor - p.y, target.z + 0.5 - p.z) > 4) reset('off_path')
300
+ }
301
+
302
+ function arrive () {
303
+ release()
304
+ if (dynamic) return
305
+ const reachedGoal = goal
306
+ goal = null
307
+ bot.emit('goal_reached', reachedGoal)
308
+ }
309
+
310
+ function tick () {
311
+ if (!goal || !bot.entity) return
312
+ if (!goal.isValid()) return pf.stop()
313
+ if (goal.hasChanged()) needPlan = true
314
+ if (digging || ctx.isDigging()) return
315
+
316
+ if (needPlan && !search) {
317
+ if (!path.length) release()
318
+ plan()
319
+ }
320
+ if (search) {
321
+ const result = search.compute()
322
+ if (result.status === 'partial') {
323
+ if (!path.length) release()
324
+ else follow()
325
+ return
326
+ }
327
+ search = null
328
+ adopt(result)
329
+ }
330
+
331
+ if (!path.length) {
332
+ const here = currentNode()
333
+ if (here && goal.isEnd(here)) return arrive()
334
+ if (!needPlan) needPlan = true
335
+ return release()
336
+ }
337
+ follow()
338
+ }
339
+
340
+ bot.on('physicsTick', () => {
341
+ try { tick() } catch (err) { reset('error'); bot.emit('error', err) }
342
+ })
343
+
344
+ // blocks changing on the planned route make it stale
345
+ bot.on('blockUpdate', (oldBlock, newBlock) => {
346
+ if (!path.length || !newBlock || oldBlock?.type === newBlock.type) return
347
+ const p = newBlock.position
348
+ // blocks we are digging ourselves are expected to change
349
+ for (const n of path) if (n.dig.some(([x, y, z]) => x === p.x && y === p.y && z === p.z)) return
350
+ for (let i = index; i < path.length && i < index + 20; i++) {
351
+ const n = path[i]
352
+ if (p.x === n.x && p.z === n.z && p.y >= Math.floor(n.floor) - 1 && p.y <= Math.floor(n.floor) + 2) return reset('block_updated')
353
+ }
354
+ })
355
+
356
+ bot.on('death', () => pf.stop())
357
+
358
+ const pf = {
359
+ settings,
360
+ goals: require('./goals'),
361
+
362
+ setGoal (newGoal, isDynamic = false) {
363
+ goal = newGoal
364
+ dynamic = isDynamic
365
+ path = []
366
+ index = 0
367
+ search = null
368
+ needPlan = !!newGoal
369
+ if (!newGoal) {
370
+ if (ctx.isDigging()) ctx.stopDigging()
371
+ release()
372
+ }
373
+ bot.emit('goal_updated', newGoal, isDynamic)
374
+ },
375
+
376
+ goto (newGoal) {
377
+ return new Promise((resolve, reject) => {
378
+ const cleanup = () => {
379
+ bot.removeListener('goal_reached', onReached)
380
+ bot.removeListener('path_update', onUpdate)
381
+ bot.removeListener('path_stop', onStop)
382
+ bot.removeListener('end', onEnd)
383
+ }
384
+ const onReached = (g) => { if (g === newGoal) { cleanup(); resolve() } }
385
+ const onUpdate = (r) => {
386
+ if (r.status === 'noPath' && r.path.length === 0) { cleanup(); pf.setGoal(null); reject(new Error('No path to the goal')) }
387
+ if (r.status === 'timeout' && r.path.length === 0) { cleanup(); pf.setGoal(null); reject(new Error('Took to long to decide path to goal')) }
388
+ }
389
+ const onStop = () => { cleanup(); reject(new Error('Path was stopped before it could be completed')) }
390
+ const onEnd = () => { cleanup(); reject(new Error('Bot disconnected')) }
391
+ bot.on('goal_reached', onReached)
392
+ bot.on('path_update', onUpdate)
393
+ bot.on('path_stop', onStop)
394
+ bot.on('end', onEnd)
395
+ pf.setGoal(newGoal, false)
396
+ })
397
+ },
398
+
399
+ stop () {
400
+ if (!goal) return
401
+ pf.setGoal(null)
402
+ bot.emit('path_stop')
403
+ },
404
+
405
+ // a full search right now, without moving (for checks and tests)
406
+ getPathTo (target, startNode = currentNode()) {
407
+ if (!startNode) return { status: 'noPath', path: [] }
408
+ terrain.prepare(bot)
409
+ const s = new AStar(startNode, target, terrain, { ...settings, tickTimeout: settings.thinkTimeout })
410
+ return s.compute()
411
+ },
412
+
413
+ setMovements (movements) {
414
+ for (const key of ['canDig', 'allowParkour', 'allowSprinting', 'maxDropDown']) {
415
+ if (movements && key in movements) settings[key] = movements[key]
416
+ }
417
+ },
418
+
419
+ bestHarvestTool: (block) => bestTool(bot, block),
420
+ isMoving: () => path.length > 0 && index < path.length,
421
+ isMining: () => digging || ctx.isDigging(),
422
+ isBuilding: () => false,
423
+ isThinking: () => !!search,
424
+ get goal () { return goal },
425
+ get path () { return path.slice(index) }
426
+ }
427
+
428
+ bot.spectra.pathfinder = pf
429
+ bot.spectra.dig = (block) => ctx.dig(block)
430
+ if (!bot.pathfinder) bot.pathfinder = pf
431
+ state.pathfinder = pf
432
+ }
433
+
434
+ module.exports = { installPathfinder, goals: require('./goals') }
@@ -0,0 +1,223 @@
1
+ 'use strict'
2
+ // Neighbours of a node for A*. A node is the block the feet are in plus the exact floor height, so slabs,
3
+ // stairs and carpets are walked over rather than jumped. Costs are in ticks (see costs.js).
4
+
5
+ const { COST } = require('./costs')
6
+ const { PLAYER_HEIGHT, STEP } = require('./terrain')
7
+
8
+ const JUMP = 1.25 // highest floor a jump reaches
9
+ const CARDINAL = [[1, 0], [-1, 0], [0, 1], [0, -1]]
10
+ const DIAGONAL = [[1, 1], [1, -1], [-1, 1], [-1, -1]]
11
+
12
+ function node (x, y, z, floor, kind, cost, extra) {
13
+ return { x, y, z, floor, kind, cost, dig: extra?.dig ?? [], water: !!extra?.water, climb: !!extra?.climb, gap: extra?.gap ?? 0 }
14
+ }
15
+
16
+ function digCost (terrain, dig) {
17
+ let ticks = 0
18
+ for (const d of dig) ticks += terrain.digCost(d[3]) + COST.digSetup
19
+ return ticks
20
+ }
21
+
22
+ // blocks between two heights in a column are free for the body (nothing dug)
23
+ function columnClear (terrain, x, z, from, to) {
24
+ for (let y = Math.floor(from); y <= Math.floor(to - 1e-6); y++) {
25
+ const t = terrain.at(x, y, z)
26
+ if (!t || t.danger || !t.empty) return false
27
+ }
28
+ return true
29
+ }
30
+
31
+ // headroom above the current position for a jump of `rise`
32
+ function jumpRoom (terrain, n, rise, dig) {
33
+ const top = n.floor + PLAYER_HEIGHT + rise
34
+ for (let y = Math.floor(n.floor + PLAYER_HEIGHT); y <= Math.floor(top - 1e-6); y++) {
35
+ const t = terrain.at(n.x, y, n.z)
36
+ if (!t || t.danger) return false
37
+ if (t.empty) continue
38
+ if (!dig || !terrain.canDigAt(n.x, y, n.z, t)) return false
39
+ dig.push([n.x, y, n.z, t])
40
+ }
41
+ return true
42
+ }
43
+
44
+ function waterNode (terrain, x, y, z) {
45
+ const feet = terrain.at(x, y, z)
46
+ if (!feet || !feet.water) return false
47
+ const head = terrain.at(x, y + 1, z)
48
+ return !!head && (head.water || head.empty) && !head.danger
49
+ }
50
+
51
+ function climbNode (terrain, x, y, z) {
52
+ const feet = terrain.at(x, y, z)
53
+ if (!feet || !feet.climbable) return false
54
+ const head = terrain.at(x, y + 1, z)
55
+ return !!head && (head.empty || head.climbable) && !head.danger
56
+ }
57
+
58
+ // where a player falling down column x/z from `fromFloor` ends up: a floor or water
59
+ function landing (terrain, x, startY, z, fromFloor, maxDrop) {
60
+ for (let y = startY; y >= startY - 64; y--) {
61
+ const t = terrain.at(x, y, z)
62
+ if (!t || t.danger) return null
63
+ if (t.water) return { y, floor: y, water: true }
64
+ const floor = terrain.floorAt(x, y, z)
65
+ if (floor != null) {
66
+ if (fromFloor - floor > maxDrop + 1e-6) return null
67
+ return { y, floor }
68
+ }
69
+ if (!t.empty && !t.climbable) return null
70
+ if (t.climbable) return { y, floor: y, climb: true }
71
+ }
72
+ return null
73
+ }
74
+
75
+ function neighbours (n, terrain, opts, out = []) {
76
+ const inWater = n.water
77
+ for (const [dx, dz] of CARDINAL) step(n, dx, dz, terrain, opts, out, false)
78
+ for (const [dx, dz] of DIAGONAL) step(n, dx, dz, terrain, opts, out, true)
79
+ if (opts.allowParkour && !inWater && !n.climb) for (const [dx, dz] of CARDINAL) parkour(n, dx, dz, terrain, opts, out)
80
+ vertical(n, terrain, opts, out)
81
+ return out
82
+ }
83
+
84
+ // the column between two heights is free for the body; blocks in the way go to `dig` when allowed
85
+ function clearColumn (terrain, x, z, from, to, dig) {
86
+ const y0 = Math.floor(from)
87
+ for (let y = y0; y <= Math.floor(to - 1e-6); y++) {
88
+ const t = terrain.at(x, y, z)
89
+ if (!t || t.danger) return false
90
+ if (t.empty) continue
91
+ if (y === y0 && y + t.top <= from + 1e-6) continue
92
+ if (!dig || !terrain.canDigAt(x, y, z, t)) return false
93
+ if (!dig.some(d => d[0] === x && d[1] === y && d[2] === z)) dig.push([x, y, z, t])
94
+ }
95
+ return true
96
+ }
97
+
98
+ function step (n, dx, dz, terrain, opts, out, diagonal) {
99
+ const x = n.x + dx
100
+ const z = n.z + dz
101
+ const base = diagonal ? COST.diagonal : COST.walk
102
+
103
+ if (diagonal) {
104
+ // the box clips the corner unless both sides are open
105
+ const lo = n.floor
106
+ if (!columnClear(terrain, n.x + dx, n.z, lo, lo + PLAYER_HEIGHT)) return
107
+ if (!columnClear(terrain, n.x, n.z + dz, lo, lo + PLAYER_HEIGHT)) return
108
+ }
109
+
110
+ // into water
111
+ for (const y of [n.y, n.y + 1]) {
112
+ if (!waterNode(terrain, x, y, z)) continue
113
+ if (y > n.y && !jumpRoom(terrain, n, 1, null)) continue
114
+ if (!terrain.bodyFits(x, y, z, null)) continue
115
+ out.push(node(x, y, z, y, 'swim', COST.swim * (diagonal ? Math.SQRT2 : 1), { water: true }))
116
+ return
117
+ }
118
+
119
+ // up a block, level, or down a block; with digging the floor may be the block under a wall
120
+ const canDig = opts.canDig && !diagonal && !n.water
121
+ for (const y of [n.y + 1, n.y, n.y - 1]) {
122
+ let floor = terrain.floorAt(x, y, z)
123
+ if (floor == null && canDig) floor = terrain.floorUnder(x, y, z)
124
+ else if (y === n.y - 1) continue // a natural lower floor is a drop, handled below
125
+ if (floor == null) continue
126
+ const rise = floor - n.floor
127
+ if (rise > JUMP + 1e-6 || rise < -1.5) continue
128
+ if (rise < -STEP - 1e-6 && y !== n.y - 1) continue
129
+ const jump = rise > STEP + 1e-6
130
+ if (jump && diagonal) continue
131
+ if (jump && n.water && rise > 1 + 1e-6) continue
132
+ const dig = canDig ? [] : null
133
+ if (jump && !n.climb && !jumpRoom(terrain, n, rise, dig)) continue
134
+ if (!clearColumn(terrain, x, z, floor, Math.max(floor, n.floor) + PLAYER_HEIGHT, dig)) continue
135
+ let cost = jump ? COST.jumpUp : (rise < -STEP ? COST.dropDown : base)
136
+ if (n.water) cost += COST.swim
137
+ cost += terrain.floorPenalty(x, floor, z)
138
+ if (dig) cost += digCost(terrain, dig)
139
+ const kind = jump ? (n.climb ? 'climb' : 'jump') : (rise < -STEP ? 'drop' : 'walk')
140
+ out.push(node(x, y, z, floor, kind, cost, { dig: (dig ?? []).sort((a, b) => b[1] - a[1]) }))
141
+ if (!dig || dig.length === 0) return
142
+ }
143
+
144
+ // a climbable column next to us
145
+ if (climbNode(terrain, x, n.y, z) && terrain.bodyFits(x, n.y, z, null)) {
146
+ out.push(node(x, n.y, z, n.y, 'walk', base + COST.climbDown, { climb: true }))
147
+ return
148
+ }
149
+
150
+ // walking off an edge
151
+ if (n.water) return
152
+ if (!columnClear(terrain, x, z, n.floor, n.floor + PLAYER_HEIGHT)) return
153
+ const land = landing(terrain, x, n.y - 1, z, n.floor, opts.maxDropDown)
154
+ if (!land) return
155
+ if (!land.water && !terrain.bodyFits(x, land.floor, z, null)) return
156
+ const fall = n.floor - land.floor
157
+ if (fall <= STEP + 1e-6) return
158
+ out.push(node(x, land.y, z, land.floor, 'drop', base + COST.fallTick * fall + 1, { water: land.water, climb: land.climb }))
159
+ }
160
+
161
+ // sprint-jump over a gap of one or two blocks (three with allowLongParkour)
162
+ function parkour (n, dx, dz, terrain, opts, out) {
163
+ const maxGap = opts.allowLongParkour ? 3 : 2
164
+ const top = n.floor + PLAYER_HEIGHT + 1.25
165
+ for (let g = 1; g <= maxGap; g++) {
166
+ const gx = n.x + dx * g
167
+ const gz = n.z + dz * g
168
+ // the gap column must be open from below the feet to above the jump
169
+ if (terrain.floorAt(gx, n.y, gz) != null || terrain.floorAt(gx, n.y + 1, gz) != null) return
170
+ if (!columnClear(terrain, gx, gz, n.floor, top)) return
171
+ if (!columnClear(terrain, n.x, n.z, n.floor + PLAYER_HEIGHT, top)) return
172
+ const lx = n.x + dx * (g + 1)
173
+ const lz = n.z + dz * (g + 1)
174
+ for (const y of [n.y, n.y - 1, n.y + 1]) {
175
+ const floor = terrain.floorAt(lx, y, lz)
176
+ if (floor == null) continue
177
+ const rise = floor - n.floor
178
+ if (rise > 1 + 1e-6 && g > 1) continue
179
+ if (rise > JUMP - 0.25 || rise < -2) continue
180
+ if (!terrain.bodyFits(lx, floor, lz, null) || !columnClear(terrain, lx, lz, floor, top)) continue
181
+ // only worth it when walking around or dropping in is not an option: the gap is deep or dangerous
182
+ const below = landing(terrain, gx, n.y - 1, gz, n.floor, opts.maxDropDown)
183
+ if (below && !below.water && n.floor - below.floor <= 1) return
184
+ out.push(node(lx, y, lz, floor, 'parkour', COST.walk * (g + 1) + COST.parkourGap * g, { gap: g }))
185
+ return
186
+ }
187
+ }
188
+ }
189
+
190
+ function vertical (n, terrain, opts, out) {
191
+ // climbing
192
+ if (climbNode(terrain, n.x, n.y + 1, n.z) || (n.climb && terrain.at(n.x, n.y + 1, n.z)?.climbable)) {
193
+ if (terrain.bodyFits(n.x, n.y + 1, n.z, null)) out.push(node(n.x, n.y + 1, n.z, n.y + 1, 'climb', COST.climbUp, { climb: true }))
194
+ }
195
+ if (n.climb || climbNode(terrain, n.x, n.y - 1, n.z)) {
196
+ if (climbNode(terrain, n.x, n.y - 1, n.z)) {
197
+ out.push(node(n.x, n.y - 1, n.z, n.y - 1, 'climb', COST.climbDown, { climb: true }))
198
+ } else if (n.climb) {
199
+ const floor = terrain.floorAt(n.x, n.y - 1, n.z)
200
+ if (floor != null) out.push(node(n.x, n.y - 1, n.z, floor, 'climb', COST.climbDown))
201
+ }
202
+ }
203
+
204
+ // swimming up and down
205
+ if (n.water) {
206
+ if (waterNode(terrain, n.x, n.y + 1, n.z)) out.push(node(n.x, n.y + 1, n.z, n.y + 1, 'swim', COST.swim, { water: true }))
207
+ if (waterNode(terrain, n.x, n.y - 1, n.z)) out.push(node(n.x, n.y - 1, n.z, n.y - 1, 'swim', COST.swim, { water: true }))
208
+ return
209
+ }
210
+
211
+ // digging the floor away and dropping onto the block below it
212
+ if (opts.canDig && !n.climb && n.floor === Math.floor(n.floor)) {
213
+ const below = terrain.at(n.x, n.y - 1, n.z)
214
+ if (!below || below.empty || !terrain.canDigAt(n.x, n.y - 1, n.z, below)) return
215
+ const floor = terrain.floorAt(n.x, n.y - 1, n.z)
216
+ const under = terrain.at(n.x, n.y - 2, n.z)
217
+ if (floor != null || !under || under.empty || under.danger || under.water || under.top < 1) return
218
+ const dig = [[n.x, n.y - 1, n.z, below]]
219
+ out.push(node(n.x, n.y - 1, n.z, n.y - 1, 'dig-down', COST.dropDown + digCost(terrain, dig), { dig }))
220
+ }
221
+ }
222
+
223
+ module.exports = { neighbours, waterNode, climbNode }