@vibemancer/core 0.1.3 → 0.1.5

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.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-W7HWJFQ4.js +10599 -0
  3. package/dist/chunk-W7HWJFQ4.js.map +1 -0
  4. package/dist/index-browser.d.ts +1300 -1051
  5. package/dist/index-browser.js +55 -17
  6. package/dist/index.d.ts +53 -3
  7. package/dist/index.js +200 -54
  8. package/dist/index.js.map +1 -1
  9. package/package.json +21 -15
  10. package/src/bots/Hero.ts +867 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +162 -120
  12. package/src/bots/berserker/02_Stormcaller.ts +160 -116
  13. package/src/bots/berserker/03_Stormforger.ts +162 -129
  14. package/src/bots/caster/01_Flamecaller.ts +126 -84
  15. package/src/bots/caster/02_Pyromancer.ts +148 -101
  16. package/src/bots/caster/03_Infernalist.ts +180 -160
  17. package/src/bots/defensive/01_Turtle.ts +48 -44
  18. package/src/bots/defensive/02_Sentinel.ts +57 -53
  19. package/src/bots/defensive/03_Golem.ts +85 -97
  20. package/src/bots/duelist/01_Battlemage.ts +157 -122
  21. package/src/bots/duelist/02_Warmage.ts +166 -121
  22. package/src/bots/duelist/03_Archmage.ts +198 -178
  23. package/src/bots/homing/01_Bonemancer.ts +20 -32
  24. package/src/bots/homing/02_Lich.ts +145 -93
  25. package/src/bots/homing/03_Archlich.ts +129 -60
  26. package/src/bots/kiter/01_Spellspinner.ts +145 -107
  27. package/src/bots/kiter/02_Spellweaver.ts +153 -105
  28. package/src/bots/kiter/03_Spellbinder.ts +168 -123
  29. package/src/bots/melee/01_Shadowblade.ts +131 -75
  30. package/src/bots/melee/02_Nightblade.ts +174 -130
  31. package/src/bots/melee/03_Voidblade.ts +266 -168
  32. package/src/bots/registry.ts +44 -37
  33. package/src/bots/shared.ts +185 -25
  34. package/src/bots/sniper/01_Spellshot.ts +151 -98
  35. package/src/bots/sniper/02_Spelltracer.ts +173 -128
  36. package/src/bots/sniper/03_Spellseeker.ts +177 -152
  37. package/src/bots/standalone/Critter.ts +46 -52
  38. package/src/bots/standalone/Doombringer.ts +36 -40
  39. package/src/bots/standalone/Hogger.ts +120 -89
  40. package/src/bots/standalone/Rookie.ts +21 -23
  41. package/src/bots/standalone/TargetDummy.ts +5 -6
  42. package/src/bots/test/cheater.ts +91 -85
  43. package/src/bots/test/crasher.ts +26 -28
  44. package/src/engine/bundle-fight.ts +242 -0
  45. package/src/engine/hooks-runtime.ts +58 -18
  46. package/src/engine/manual-match.ts +33 -25
  47. package/src/engine/missile-templates.ts +25 -43
  48. package/src/engine/optimizer.ts +7 -7
  49. package/src/engine/params-runtime.ts +3 -3
  50. package/src/engine/physics.ts +33 -23
  51. package/src/engine/sandbox-browser.ts +8 -7
  52. package/src/engine/sandbox-compile.ts +1 -1
  53. package/src/engine/sandbox-harness.ts +299 -367
  54. package/src/engine/sandbox.ts +3 -3
  55. package/src/engine/simulation.ts +291 -76
  56. package/src/engine/spells.ts +9 -12
  57. package/src/engine-version.ts +1 -1
  58. package/src/hooks/action-builders.ts +76 -21
  59. package/src/hooks/index.ts +17 -9
  60. package/src/hooks/state-hooks.ts +61 -25
  61. package/src/hooks/threat-analysis.ts +44 -20
  62. package/src/hooks/types.ts +62 -5
  63. package/src/index.ts +2 -0
  64. package/src/rules.ts +209 -63
  65. package/src/stats.ts +2 -2
  66. package/src/testing.ts +6 -30
  67. package/src/trace.ts +63 -3
  68. package/src/types.ts +127 -14
  69. package/src/utils/angles.ts +1 -0
  70. package/src/utils/combat.ts +98 -6
  71. package/src/utils/spatial.ts +3 -3
  72. package/dist/chunk-B7YYYBEC.js +0 -9140
  73. package/dist/chunk-B7YYYBEC.js.map +0 -1
  74. package/src/hooks/bot-wrapper.ts +0 -84
@@ -1,28 +1,32 @@
1
- import {WizardFunction} from '../../types.js';
1
+ import type {FinalAction, MissileAction} from '../../hooks/index.js';
2
+ import {usePosition, useEnemy, useStatus, useBlinkCooldown, useTick, useThreats, useProjectiles, useLastMissileConfig, useCastingSpell, useCastProgress, idle, move as moveAction, missile, shield, blink, cancel, flyStraight, getMissileContext, turnToward} from '../../hooks/index.js';
2
3
  import {distanceTo} from '../../utils/distance.js';
3
4
  import {angleTo} from '../../utils/angles.js';
5
+ import {RULES, ARENA_MIN, ARENA_MAX} from '../../rules.js';
4
6
  import {
5
- ARENA_SIZE,
6
- GCD_DURATION,
7
- SHIELD_CAST_TIME,
8
- BLINK_CAST_TIME,
9
- } from '../../rules.js';
10
- import {
11
- getThreats,
12
7
  getMostUrgentThreat,
13
8
  getDodgeDirectionForMovement,
14
9
  getBlinkToDecreaseDistance,
15
10
  shouldForceShield,
16
- fitMissileToBudget,
11
+ fitMissileForEscapingTarget,
17
12
  MIN_USEFUL_DAMAGE,
18
13
  getEnemyVulnerabilityTicks,
14
+ keepOffWalls,
15
+ getPreCastReposition,
19
16
  } from '../shared.js';
20
- import {getMissileCastTime} from '../../utils/combat.js';
17
+ import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
21
18
  import {useParam} from '../../engine/params-runtime.js';
22
19
 
23
20
  const WALL_BUFFER = 50;
24
21
  const SHIELD_BUFFER = 3;
25
22
 
23
+ const HomingStabAI = (): MissileAction =>
24
+ {
25
+ const ctx = getMissileContext();
26
+ const enemy = ctx.worldState.enemies[0];
27
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
28
+ };
29
+
26
30
  /**
27
31
  * Aggressive movement: dodge missiles, close distance, light strafe.
28
32
  */
@@ -35,12 +39,22 @@ function aggressiveMove(
35
39
  strafeCyclePeriod: number,
36
40
  strafeIntensity: number,
37
41
  approachSpeed: number,
38
- ): {x: number; y: number}
42
+ centerBias: number,
43
+ ): {x: number; y: number}
39
44
  {
40
45
  const deltaX = enemy.position.x - position.x;
41
46
  const deltaY = enemy.position.y - position.y;
42
47
  const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
43
48
 
49
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
50
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
51
+ const toCenterX = center - enemy.position.x;
52
+ const toCenterY = center - enemy.position.y;
53
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
54
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
55
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
56
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
57
+
44
58
  const strafeClockwise = {
45
59
  x: -deltaY / normalizedDistance,
46
60
  y: deltaX / normalizedDistance,
@@ -57,10 +71,10 @@ function aggressiveMove(
57
71
  strafeDirection = dodgeDirection;
58
72
  }
59
73
  else if (
60
- position.x < WALL_BUFFER ||
61
- position.x > ARENA_SIZE - WALL_BUFFER ||
62
- position.y < WALL_BUFFER ||
63
- position.y > ARENA_SIZE - WALL_BUFFER
74
+ position.x < ARENA_MIN + WALL_BUFFER ||
75
+ position.x > ARENA_MAX - WALL_BUFFER ||
76
+ position.y < ARENA_MIN + WALL_BUFFER ||
77
+ position.y > ARENA_MAX - WALL_BUFFER
64
78
  )
65
79
  {
66
80
  const futureClockwise = {
@@ -72,16 +86,16 @@ function aggressiveMove(
72
86
  y: position.y + strafeCounterClockwise.y * 100,
73
87
  };
74
88
  const scoreClockwise = Math.min(
75
- futureClockwise.x,
76
- ARENA_SIZE - futureClockwise.x,
77
- futureClockwise.y,
78
- ARENA_SIZE - futureClockwise.y,
89
+ futureClockwise.x - ARENA_MIN,
90
+ ARENA_MAX - futureClockwise.x,
91
+ futureClockwise.y - ARENA_MIN,
92
+ ARENA_MAX - futureClockwise.y,
79
93
  );
80
94
  const scoreCounterClockwise = Math.min(
81
- futureCounterClockwise.x,
82
- ARENA_SIZE - futureCounterClockwise.x,
83
- futureCounterClockwise.y,
84
- ARENA_SIZE - futureCounterClockwise.y,
95
+ futureCounterClockwise.x - ARENA_MIN,
96
+ ARENA_MAX - futureCounterClockwise.x,
97
+ futureCounterClockwise.y - ARENA_MIN,
98
+ ARENA_MAX - futureCounterClockwise.y,
85
99
  );
86
100
  strafeDirection =
87
101
  scoreClockwise > scoreCounterClockwise
@@ -96,20 +110,20 @@ function aggressiveMove(
96
110
 
97
111
  if (dodgeDirection)
98
112
  {
99
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
113
+ return {x: strafeDirection.x, y: strafeDirection.y};
100
114
  }
101
115
 
102
116
  let moveX = strafeDirection.x * strafeIntensity;
103
117
  let moveY = strafeDirection.y * strafeIntensity;
104
118
 
105
- moveX += (deltaX / normalizedDistance) * approachSpeed;
106
- moveY += (deltaY / normalizedDistance) * approachSpeed;
119
+ moveX += (approachDirX / approachMag) * approachSpeed;
120
+ moveY += (approachDirY / approachMag) * approachSpeed;
107
121
 
108
122
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
109
- if (magnitude > 100)
123
+ if (magnitude > 1)
110
124
  {
111
- moveX = (moveX / magnitude) * 100;
112
- moveY = (moveY / magnitude) * 100;
125
+ moveX = moveX / magnitude;
126
+ moveY = moveY / magnitude;
113
127
  }
114
128
 
115
129
  return {x: moveX, y: moveY};
@@ -137,41 +151,53 @@ function aggressiveMove(
137
151
  * PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
138
152
  * TIER: 3 (elite Melee line)
139
153
  */
140
- export const Voidblade: WizardFunction = ({state}) =>
154
+ export function Voidblade(): FinalAction
141
155
  {
156
+ const position = usePosition();
157
+ const enemy = useEnemy();
158
+ const status = useStatus();
159
+ const blinkCooldown = useBlinkCooldown();
160
+ const tick = useTick();
161
+ const threats = useThreats();
162
+ const projectiles = useProjectiles();
163
+ const lastMissileConfig = useLastMissileConfig();
164
+ const castingSpell = useCastingSpell();
165
+ const castProgressData = useCastProgress();
166
+
167
+
142
168
  const preemptiveShieldRange = useParam('preemptiveShieldRange', 348, {
143
169
  range: 85,
144
170
  min: 50,
145
171
  steps: 7,
146
172
  });
147
- const shieldWindow = useParam('shieldWindow', 18, {
173
+ const shieldWindow = useParam('shieldWindow', 5, {
148
174
  range: 10,
149
175
  min: 5,
150
176
  max: 30,
151
177
  steps: 5,
152
178
  });
153
- const punishMinVulnerability = useParam('punishMinVulnerability', 10, {
179
+ const punishMinVulnerability = useParam('punishMinVulnerability', 15, {
154
180
  range: 15,
155
181
  min: 10,
156
182
  steps: 5,
157
183
  });
158
184
  const preferBlinkDodge =
159
185
  useParam('preferBlinkDodge', 0, {min: 0, max: 1, steps: 2}) >= 0.5;
160
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
186
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 230, {
161
187
  range: 75,
162
188
  min: 80,
163
189
  max: 250,
164
190
  });
165
- const strafeIntensity = useParam('strafeIntensity', 45, {
166
- range: 30,
167
- min: 10,
168
- max: 80,
191
+ const strafeIntensity = useParam('strafeIntensity', 0.28, {
192
+ range: 0.3,
193
+ min: 0.1,
194
+ max: 0.8,
169
195
  steps: 7,
170
196
  });
171
- const approachSpeed = useParam('approachSpeed', 92, {
172
- range: 20,
173
- min: 50,
174
- max: 100,
197
+ const approachSpeed = useParam('approachSpeed', 1, {
198
+ range: 0.2,
199
+ min: 0.5,
200
+ max: 1,
175
201
  steps: 7,
176
202
  });
177
203
  const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
@@ -185,7 +211,7 @@ export const Voidblade: WizardFunction = ({state}) =>
185
211
  max: 80,
186
212
  steps: 7,
187
213
  });
188
- const blinkPastMargin = useParam('blinkPastMargin', 50, {
214
+ const blinkPastMargin = useParam('blinkPastMargin', 61, {
189
215
  range: 30,
190
216
  min: 20,
191
217
  max: 100,
@@ -203,13 +229,13 @@ export const Voidblade: WizardFunction = ({state}) =>
203
229
  max: 200,
204
230
  steps: 7,
205
231
  });
206
- const counterStabRange = useParam('counterStabRange', 27, {
207
- range: 20,
232
+ const counterStabRange = useParam('counterStabRange', 14, {
233
+ range: 30,
208
234
  min: 10,
209
- max: 60,
235
+ max: 80,
210
236
  steps: 7,
211
237
  });
212
- const flightTimeDivisor = useParam('flightTimeDivisor', 7.5, {
238
+ const flightTimeDivisor = useParam('flightTimeDivisor', 3, {
213
239
  range: 4,
214
240
  min: 3,
215
241
  max: 12,
@@ -221,16 +247,16 @@ export const Voidblade: WizardFunction = ({state}) =>
221
247
  max: 10,
222
248
  steps: 5,
223
249
  });
224
- const idleProvokeBudget = useParam('idleProvokeBudget', 37, {
250
+ const idleProvokeBudget = useParam('idleProvokeBudget', 110, {
225
251
  range: 40,
226
252
  min: 20,
227
253
  max: 120,
228
254
  steps: 7,
229
255
  });
230
- const meleeAbortRange = useParam('meleeAbortRange', 50, {
231
- range: 50,
256
+ const meleeAbortRange = useParam('meleeAbortRange', 100, {
257
+ range: 100,
232
258
  min: 50,
233
- max: 200,
259
+ max: 400,
234
260
  steps: 7,
235
261
  });
236
262
  const enemyFiringSoonThreshold = useParam('enemyFiringSoonThreshold', 50, {
@@ -239,60 +265,136 @@ export const Voidblade: WizardFunction = ({state}) =>
239
265
  max: 100,
240
266
  steps: 7,
241
267
  });
268
+ const useDynamicStab =
269
+ useParam('useDynamicStab', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
270
+ const stabDamage = Math.round(useParam('stabDamage', 5, {
271
+ range: 10,
272
+ min: 5,
273
+ max: 25,
274
+ steps: 7,
275
+ }));
276
+ const centerBias = useParam('centerBias', 0.8, {
277
+ range: 0.3,
278
+ min: 0,
279
+ max: 0.8,
280
+ steps: 5,
281
+ });
282
+ const saveBlinkForChase =
283
+ useParam('saveBlinkForChase', 0, {min: 0, max: 1, steps: 2}) >= 0.5;
284
+ const chaseBlinkRange = useParam('chaseBlinkRange', 200, {
285
+ range: 100,
286
+ min: 100,
287
+ max: 400,
288
+ steps: 5,
289
+ });
290
+ const maxBlinkDistance = useParam('maxBlinkDistance', 278, {
291
+ range: 100,
292
+ min: 50,
293
+ max: 300,
294
+ steps: 7,
295
+ });
296
+ const stabTurnRate = useParam('stabTurnRate', 0, {
297
+ min: 0,
298
+ max: 1,
299
+ steps: 5,
300
+ });
301
+ const stabSpeed = useParam('stabSpeed', 11.3, {
302
+ range: 3,
303
+ min: 5,
304
+ max: 12,
305
+ steps: 5,
306
+ });
307
+ const stabDuration = Math.round(useParam('stabDuration', 33, {
308
+ range: 15,
309
+ min: 15,
310
+ max: 50,
311
+ steps: 5,
312
+ }));
313
+ const useLeadAiming =
314
+ useParam('useLeadAiming', 0, {min: 0, max: 1, steps: 2}) >= 0.5;
315
+ const useHomingAI =
316
+ useParam('useHomingAI', 0, {min: 0, max: 1, steps: 2}) >= 0.5;
317
+ const FAST_STAB = {speed: stabSpeed, turnRate: stabTurnRate, duration: stabDuration};
242
318
 
243
- const enemy = state.enemies[0];
244
- if (!enemy)
245
- {
246
- return {move: {x: 0, y: 0}};
247
- }
319
+ const distance = distanceTo(position, enemy.position);
320
+
321
+ const stabAimAngle = useLeadAiming
322
+ ? angleTo(position, getLeadPosition(enemy.position, enemy.velocity, FAST_STAB.speed, position))
323
+ : angleTo(position, enemy.position);
324
+ const stabAI = useHomingAI ? HomingStabAI : (): MissileAction => flyStraight();
248
325
 
249
- const distance = distanceTo(state.position, enemy.position);
250
- const threats = getThreats(state);
251
326
  const urgentThreat = getMostUrgentThreat(threats);
252
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
327
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
253
328
  const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
254
329
 
255
330
  const move = aggressiveMove(
256
- state.position,
331
+ position,
257
332
  enemy,
258
333
  distance,
259
334
  dodgeDirection,
260
- state.tick,
335
+ tick,
261
336
  strafeCyclePeriod,
262
337
  strafeIntensity,
263
338
  approachSpeed,
339
+ centerBias,
264
340
  );
341
+ const safeMove = keepOffWalls(position, move);
342
+
343
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
344
+ {
345
+ const dx = target.x - position.x;
346
+ const dy = target.y - position.y;
347
+ const dist = Math.sqrt(dx * dx + dy * dy);
348
+ if (dist <= maxBlinkDistance) return target;
349
+ const scale = maxBlinkDistance / dist;
350
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
351
+ }
265
352
 
266
353
  // Precompute blink targets — used by blink-dodge and engage-blink.
267
354
  // blinkCloseTarget: from getBlinkToDecreaseDistance (dodge + close gap). Null at melee range.
268
355
  // blinkPastTarget: blink ~200u past enemy (dodge only, loses position). For in-flight missiles only.
269
356
  let blinkCloseTarget: {x: number; y: number} | null = null;
270
357
  let blinkPastTarget: {x: number; y: number} | null = null;
271
- if (state.blinkCooldown === 0)
358
+ if (blinkCooldown === 0)
272
359
  {
273
- blinkCloseTarget = getBlinkToDecreaseDistance(
274
- state.position,
360
+ const rawBlinkTarget = getBlinkToDecreaseDistance(
361
+ position,
275
362
  enemy.position,
276
- state.projectiles,
363
+ projectiles,
277
364
  );
278
- if (!blinkCloseTarget)
365
+ if (rawBlinkTarget)
279
366
  {
280
- const dx = enemy.position.x - state.position.x;
281
- const dy = enemy.position.y - state.position.y;
367
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
368
+ const enemyDistToCenter = Math.max(
369
+ Math.abs(enemy.position.x - center),
370
+ Math.abs(enemy.position.y - center),
371
+ );
372
+ const capped = capBlinkDistance(rawBlinkTarget);
373
+ const blinkDistToCenter = Math.max(
374
+ Math.abs(capped.x - center),
375
+ Math.abs(capped.y - center),
376
+ );
377
+ if (blinkDistToCenter <= enemyDistToCenter + 30) blinkCloseTarget = capped;
378
+ }
379
+ if (!blinkCloseTarget)
380
+ {
381
+ const dx = enemy.position.x - position.x;
382
+ const dy = enemy.position.y - position.y;
282
383
  const norm = Math.sqrt(dx * dx + dy * dy) || 1;
384
+ const cappedDist = Math.min(blinkPastDistance, maxBlinkDistance);
283
385
  blinkPastTarget = {
284
386
  x: Math.max(
285
- blinkPastMargin,
387
+ ARENA_MIN + blinkPastMargin,
286
388
  Math.min(
287
- ARENA_SIZE - blinkPastMargin,
288
- state.position.x + (dx / norm) * blinkPastDistance,
389
+ ARENA_MAX - blinkPastMargin,
390
+ position.x + (dx / norm) * cappedDist,
289
391
  ),
290
392
  ),
291
393
  y: Math.max(
292
- blinkPastMargin,
394
+ ARENA_MIN + blinkPastMargin,
293
395
  Math.min(
294
- ARENA_SIZE - blinkPastMargin,
295
- state.position.y + (dy / norm) * blinkPastDistance,
396
+ ARENA_MAX - blinkPastMargin,
397
+ position.y + (dy / norm) * cappedDist,
296
398
  ),
297
399
  ),
298
400
  };
@@ -300,12 +402,12 @@ export const Voidblade: WizardFunction = ({state}) =>
300
402
  }
301
403
 
302
404
  // === STEP 1: CHANNELING — hold shield until missile passes ===
303
- if (state.state === 'channeling')
405
+ if (status === 'channeling')
304
406
  {
305
407
  // Hold shield while there's an active threat OR enemy is about to fire
306
408
  const enemyFiringSoon =
307
409
  distance < preemptiveShieldRange &&
308
- enemy.state === 'casting' &&
410
+ enemy.status === 'casting' &&
309
411
  enemy.castingSpell === 'missile' &&
310
412
  (enemy.castDuration ?? 0) -
311
413
  (enemy.castProgress ?? 0) +
@@ -317,39 +419,39 @@ export const Voidblade: WizardFunction = ({state}) =>
317
419
  (urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
318
420
  )
319
421
  {
320
- return {move, cancel: true};
422
+ return cancel().move(safeMove.x, safeMove.y);
321
423
  }
322
- return {move: {x: 0, y: 0}};
424
+ return idle();
323
425
  }
324
426
 
325
427
  // === STEP 2: CAST-CANCEL INTO DEFENSE ===
326
428
  // If we're casting a stab and a missile is incoming, cancel so we can
327
429
  // blink-dodge (if available) or shield. Defense time depends on blink availability.
328
- if (state.state === 'casting' && state.castingSpell === 'missile')
430
+ if (status === 'casting' && castingSpell === 'missile')
329
431
  {
330
432
  // Cancel if target escaped melee range
331
- if (distance > meleeAbortRange)
433
+ if (distance > meleeAbortRange)
332
434
  {
333
- return {move, cancel: true};
435
+ return cancel().move(safeMove.x, safeMove.y);
334
436
  }
335
437
 
336
438
  // Defense time: blink (10t) is faster than shield (20t + 3t buffer)
337
439
  const defenseTime =
338
- state.blinkCooldown === 0
339
- ? BLINK_CAST_TIME
340
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
440
+ blinkCooldown === 0
441
+ ? RULES.BLINK_CAST_TIME
442
+ : RULES.SHIELD_CAST_TIME + SHIELD_BUFFER;
341
443
 
342
444
  // Cancel for any undodgeable incoming threat we can't finish casting before
343
445
  if (urgentThreat && !urgentThreat.bestDodgeDirection)
344
446
  {
345
447
  const remainingCast =
346
- (state.castDuration ?? 0) - (state.castProgress ?? 0);
448
+ ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
347
449
  const willArriveInWindow =
348
450
  urgentThreat.ticksToImpact <=
349
- remainingCast + GCD_DURATION + defenseTime;
350
- if (willArriveInWindow)
451
+ remainingCast + RULES.GCD_DURATION + defenseTime;
452
+ if (willArriveInWindow)
351
453
  {
352
- return {move, cancel: true};
454
+ return cancel().move(safeMove.x, safeMove.y);
353
455
  }
354
456
  }
355
457
 
@@ -357,28 +459,28 @@ export const Voidblade: WizardFunction = ({state}) =>
357
459
  // at melee range their missile will arrive too fast to react after our cast finishes
358
460
  if (
359
461
  distance < preemptiveShieldRange &&
360
- enemy.state === 'casting' &&
462
+ enemy.status === 'casting' &&
361
463
  enemy.castingSpell === 'missile'
362
464
  )
363
465
  {
364
466
  const remainingEnemyCast =
365
467
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
366
468
  const remainingOurCast =
367
- (state.castDuration ?? 0) - (state.castProgress ?? 0);
469
+ ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
368
470
  const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
369
471
  const ticksUntilEnemyHit = remainingEnemyCast + estimatedFlightTicks;
370
- const ticksAfterOurCast = remainingOurCast + GCD_DURATION + defenseTime;
472
+ const ticksAfterOurCast = remainingOurCast + RULES.GCD_DURATION + defenseTime;
371
473
 
372
474
  // If enemy missile will arrive before we can defend after our cast, cancel now
373
- if (ticksUntilEnemyHit < ticksAfterOurCast)
475
+ if (ticksUntilEnemyHit < ticksAfterOurCast)
374
476
  {
375
- return {move, cancel: true};
477
+ return cancel().move(safeMove.x, safeMove.y);
376
478
  }
377
479
  }
378
480
  }
379
481
 
380
482
  // === STEP 3: BLINK-DODGE — 100% damage avoidance (preferred over shield) ===
381
- if (state.state === 'idle' && state.blinkCooldown === 0)
483
+ if (status === 'idle' && blinkCooldown === 0)
382
484
  {
383
485
  // Blink target for dodging: close-gap target if available, else blink-past for in-flight only
384
486
  const dodgeBlinkTarget = blinkCloseTarget ?? blinkPastTarget;
@@ -392,10 +494,7 @@ export const Voidblade: WizardFunction = ({state}) =>
392
494
  urgentThreat.ticksToImpact <= blinkWindowMax
393
495
  )
394
496
  {
395
- return {
396
- move: {x: 0, y: 0},
397
- startCast: {spell: 'blink', target: dodgeBlinkTarget},
398
- };
497
+ return blink(dodgeBlinkTarget.x, dodgeBlinkTarget.y);
399
498
  }
400
499
 
401
500
  // 3b: Preemptive blink-dodge — only when we have a close-gap target.
@@ -405,7 +504,7 @@ export const Voidblade: WizardFunction = ({state}) =>
405
504
  preferBlinkDodge &&
406
505
  blinkCloseTarget &&
407
506
  distance < preemptiveShieldRange &&
408
- enemy.state === 'casting' &&
507
+ enemy.status === 'casting' &&
409
508
  enemy.castingSpell === 'missile'
410
509
  )
411
510
  {
@@ -415,29 +514,40 @@ export const Voidblade: WizardFunction = ({state}) =>
415
514
  const ticksUntilHit = remainingCast + estimatedFlightTicks;
416
515
 
417
516
  if (
418
- ticksUntilHit >= BLINK_CAST_TIME &&
419
- ticksUntilHit < BLINK_CAST_TIME + shieldWindow
517
+ ticksUntilHit >= RULES.BLINK_CAST_TIME &&
518
+ ticksUntilHit < RULES.BLINK_CAST_TIME + shieldWindow
420
519
  )
421
520
  {
422
- return {
423
- move: {x: 0, y: 0},
424
- startCast: {spell: 'blink', target: blinkCloseTarget},
425
- };
521
+ return blink(blinkCloseTarget.x, blinkCloseTarget.y);
426
522
  }
427
523
  }
428
524
 
429
525
  // 3c: Engage blink — close distance during enemy vulnerability (they can't react)
526
+ // Skip if saving blink for chase (use blink reactively, not proactively)
430
527
  if (
528
+ !saveBlinkForChase &&
431
529
  blinkCloseTarget &&
432
530
  distance > engageBlinkRange &&
433
531
  vulnerabilityTicks >= punishMinVulnerability &&
434
532
  (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
435
- )
533
+ )
436
534
  {
437
- return {
438
- move: {x: 0, y: 0},
439
- startCast: {spell: 'blink', target: blinkCloseTarget},
535
+ return blink(blinkCloseTarget.x, blinkCloseTarget.y);
536
+ }
537
+
538
+ // 3d: Chase blink — enemy blinked away after we've fought at melee range
539
+ if (distance > chaseBlinkRange && lastMissileConfig)
540
+ {
541
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
542
+ const toCX = center - enemy.position.x;
543
+ const toCY = center - enemy.position.y;
544
+ const toCDist = Math.sqrt(toCX * toCX + toCY * toCY) || 1;
545
+ const rawChase = {
546
+ x: enemy.position.x + (toCX / toCDist) * 15,
547
+ y: enemy.position.y + (toCY / toCDist) * 15,
440
548
  };
549
+ const chaseTarget = capBlinkDistance(rawChase);
550
+ return blink(chaseTarget.x, chaseTarget.y);
441
551
  }
442
552
  }
443
553
 
@@ -446,25 +556,22 @@ export const Voidblade: WizardFunction = ({state}) =>
446
556
  // 4a: Shield in-flight missiles
447
557
  // Shield when: no blink target at all, or missile too close for blink (<10 ticks)
448
558
  if (
449
- state.state === 'idle' &&
559
+ status === 'idle' &&
450
560
  urgentThreat &&
451
561
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
452
562
  urgentThreat.canBlockInTime &&
453
563
  ((!blinkCloseTarget && !blinkPastTarget) || urgentThreat.ticksToImpact < 10)
454
- )
564
+ )
455
565
  {
456
- return {
457
- move: {x: 0, y: 0},
458
- startCast: {spell: 'shield'},
459
- };
566
+ return shield();
460
567
  }
461
568
 
462
569
  // 4b: Preemptive shield — when blink can't close gap (melee range or cooldown)
463
570
  if (
464
- state.state === 'idle' &&
571
+ status === 'idle' &&
465
572
  !blinkCloseTarget &&
466
573
  distance < preemptiveShieldRange &&
467
- enemy.state === 'casting' &&
574
+ enemy.status === 'casting' &&
468
575
  enemy.castingSpell === 'missile'
469
576
  )
470
577
  {
@@ -473,26 +580,26 @@ export const Voidblade: WizardFunction = ({state}) =>
473
580
  const ticksUntilHit = remainingCast + estimatedFlightTicks;
474
581
 
475
582
  if (
476
- ticksUntilHit >= SHIELD_CAST_TIME &&
477
- ticksUntilHit < SHIELD_CAST_TIME + shieldWindow
583
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
584
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + shieldWindow
478
585
  )
479
586
  {
480
- return {
481
- move: {x: 0, y: 0},
482
- startCast: {spell: 'shield'},
483
- };
587
+ return shield();
484
588
  }
485
589
  }
486
590
 
487
591
  // === STEP 5: COUNTER-STAB — fire during enemy vulnerability ===
488
592
  // Only fire when enemy CAN'T shield (in GCD or mid-cast).
489
593
  // Use small, fast stabs that fit within the vulnerability window.
490
- if (state.state === 'idle' && distance < counterStabRange)
594
+ if (status === 'idle' && distance < counterStabRange)
491
595
  {
596
+ const reposition = getPreCastReposition(position, enemy.position);
597
+ if (reposition) return moveAction(reposition.x, reposition.y);
598
+
492
599
  const enemyHP = Math.ceil(enemy.health);
493
600
  const flightTime = Math.ceil(distance / flightTimeDivisor) + 1;
494
601
 
495
- if (vulnerabilityTicks >= punishMinVulnerability)
602
+ if (vulnerabilityTicks >= punishMinVulnerability)
496
603
  {
497
604
  // Budget: stab must land while enemy is still vulnerable
498
605
  // castTime + flightTime < vulnerabilityTicks
@@ -502,7 +609,7 @@ export const Voidblade: WizardFunction = ({state}) =>
502
609
  let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
503
610
 
504
611
  // Account for enemy's active cast — their missile will arrive after cast completes + flight time
505
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
612
+ if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
506
613
  {
507
614
  const remainingEnemyCast =
508
615
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
@@ -515,35 +622,31 @@ export const Voidblade: WizardFunction = ({state}) =>
515
622
 
516
623
  // Defense time: blink (10t) is faster than shield (20t + 3t buffer)
517
624
  const defenseTime =
518
- state.blinkCooldown === 0
519
- ? BLINK_CAST_TIME
520
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
521
- const safeBudget = nextThreatTime - GCD_DURATION - defenseTime;
625
+ blinkCooldown === 0
626
+ ? RULES.BLINK_CAST_TIME
627
+ : RULES.SHIELD_CAST_TIME + SHIELD_BUFFER;
628
+ const safeBudget = nextThreatTime - RULES.GCD_DURATION - defenseTime;
522
629
  const effectiveBudget = Math.min(punishBudget, safeBudget);
523
630
 
524
- if (effectiveBudget > 0)
631
+ if (effectiveBudget > 0)
525
632
  {
526
- const config = fitMissileToBudget(effectiveBudget, distance, {
527
- minTurnRate: 0,
528
- maxDamage: enemyHP,
529
- lastMissileConfig: state.lastMissileConfig,
530
- });
633
+ let config = useDynamicStab
634
+ ? fitMissileForEscapingTarget(effectiveBudget, distance, {
635
+ minTurnRate: 0,
636
+ maxDamage: enemyHP,
637
+ lastMissileConfig: lastMissileConfig,
638
+ })
639
+ : null;
640
+
641
+ if (!config || config.damage < MIN_USEFUL_DAMAGE)
642
+ {
643
+ config = {damage: stabDamage, speed: FAST_STAB.speed, turnRate: FAST_STAB.turnRate, duration: FAST_STAB.duration};
644
+ }
531
645
 
532
- if (config && config.damage >= MIN_USEFUL_DAMAGE)
646
+ const castTime = getMissileCastTime(config, lastMissileConfig);
647
+ if (castTime <= effectiveBudget && castTime + flightTime <= vulnerabilityTicks)
533
648
  {
534
- const castTime = getMissileCastTime(config, state.lastMissileConfig);
535
- if (castTime + flightTime <= vulnerabilityTicks)
536
- {
537
- return {
538
- move,
539
- startCast: {
540
- spell: 'missile',
541
- config,
542
- missileAI: () => ({}),
543
- direction: angleTo(state.position, enemy.position),
544
- },
545
- };
546
- }
649
+ return missile(config, stabAI, stabAimAngle).move(safeMove.x, safeMove.y);
547
650
  }
548
651
  }
549
652
  }
@@ -553,30 +656,25 @@ export const Voidblade: WizardFunction = ({state}) =>
553
656
  // Channeling: shield blocks ~90% but 10% still lands. Stabs whittle HP so they
554
657
  // can't just hold shield forever. Why: otherwise the bot waits for a
555
658
  // vulnerability window that a channeling player never opens.
556
- if ((enemy.state === 'idle' || enemy.state === 'channeling') && !urgentThreat)
659
+ if ((enemy.status === 'idle' || enemy.status === 'channeling') && !urgentThreat)
557
660
  {
558
- // Use a small budget to keep stabs fast and stay reactive
559
661
  const quickBudget = idleProvokeBudget;
560
- const config = fitMissileToBudget(quickBudget, distance, {
561
- minTurnRate: 0,
562
- maxDamage: enemyHP,
563
- lastMissileConfig: state.lastMissileConfig,
564
- });
662
+ let config = useDynamicStab
663
+ ? fitMissileForEscapingTarget(quickBudget, distance, {
664
+ minTurnRate: 0,
665
+ maxDamage: enemyHP,
666
+ lastMissileConfig: lastMissileConfig,
667
+ })
668
+ : null;
565
669
 
566
- if (config)
670
+ if (!config)
567
671
  {
568
- return {
569
- move,
570
- startCast: {
571
- spell: 'missile',
572
- config,
573
- missileAI: () => ({}),
574
- direction: angleTo(state.position, enemy.position),
575
- },
576
- };
672
+ config = {damage: stabDamage, speed: FAST_STAB.speed, turnRate: FAST_STAB.turnRate, duration: FAST_STAB.duration};
577
673
  }
674
+
675
+ return missile(config, stabAI, stabAimAngle).move(safeMove.x, safeMove.y);
578
676
  }
579
677
  }
580
678
 
581
- return {move};
582
- };
679
+ return moveAction(safeMove.x, safeMove.y);
680
+ }