@vibemancer/core 0.1.4 → 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-74FFJCFF.js +0 -9140
  73. package/dist/chunk-74FFJCFF.js.map +0 -1
  74. package/src/hooks/bot-wrapper.ts +0 -84
@@ -1,20 +1,23 @@
1
- import {WizardFunction} from '../../types.js';
1
+ import type {FinalAction} from '../../hooks/index.js';
2
+ import {usePosition, useEnemy, useStatus, useBlinkCooldown, useHealth, useTick, useThreats, useProjectiles, useMyProjectiles, useLastMissileConfig, useCastingSpell, useCastProgress, idle, move as moveAction, missile, shield, blink, cancel, flyStraight} from '../../hooks/index.js';
2
3
  import {distanceTo} from '../../utils/distance.js';
3
4
  import {angleTo} from '../../utils/angles.js';
4
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
+ import {RULES, ARENA_MIN, ARENA_MAX} from '../../rules.js';
5
6
  import {
6
- getThreats,
7
7
  getMostUrgentThreat,
8
8
  getDodgeDirectionForMovement,
9
9
  getBlinkToDecreaseDistance,
10
10
  shouldForceShield,
11
- fitMissileToBudget,
11
+ fitMissileForEscapingTarget,
12
12
  MAX_CAST_BUDGET,
13
13
  MIN_USEFUL_DAMAGE,
14
+ keepOffWalls,
15
+ getPreCastReposition,
14
16
  } from '../shared.js';
15
17
  import {useParam} from '../../engine/params-runtime.js';
16
18
  const WALL_BUFFER = 50;
17
19
  const SHIELD_BUFFER = 3;
20
+ const FAST_STAB = {speed: 8, turnRate: 0, duration: 25};
18
21
 
19
22
  /**
20
23
  * Aggressive movement: dodge missiles, close distance, light strafe.
@@ -28,12 +31,22 @@ function aggressiveMove(
28
31
  strafeCyclePeriod: number,
29
32
  strafeIntensity: number,
30
33
  approachSpeed: number,
31
- ): {x: number; y: number}
34
+ centerBias: number,
35
+ ): {x: number; y: number}
32
36
  {
33
37
  const deltaX = enemy.position.x - position.x;
34
38
  const deltaY = enemy.position.y - position.y;
35
39
  const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
36
40
 
41
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
42
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
43
+ const toCenterX = center - enemy.position.x;
44
+ const toCenterY = center - enemy.position.y;
45
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
46
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
47
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
48
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
49
+
37
50
  const strafeClockwise = {
38
51
  x: -deltaY / normalizedDistance,
39
52
  y: deltaX / normalizedDistance,
@@ -50,10 +63,10 @@ function aggressiveMove(
50
63
  strafeDirection = dodgeDirection;
51
64
  }
52
65
  else if (
53
- position.x < WALL_BUFFER ||
54
- position.x > ARENA_SIZE - WALL_BUFFER ||
55
- position.y < WALL_BUFFER ||
56
- position.y > ARENA_SIZE - WALL_BUFFER
66
+ position.x < ARENA_MIN + WALL_BUFFER ||
67
+ position.x > ARENA_MAX - WALL_BUFFER ||
68
+ position.y < ARENA_MIN + WALL_BUFFER ||
69
+ position.y > ARENA_MAX - WALL_BUFFER
57
70
  )
58
71
  {
59
72
  const futureClockwise = {
@@ -65,16 +78,16 @@ function aggressiveMove(
65
78
  y: position.y + strafeCounterClockwise.y * 100,
66
79
  };
67
80
  const scoreClockwise = Math.min(
68
- futureClockwise.x,
69
- ARENA_SIZE - futureClockwise.x,
70
- futureClockwise.y,
71
- ARENA_SIZE - futureClockwise.y,
81
+ futureClockwise.x - ARENA_MIN,
82
+ ARENA_MAX - futureClockwise.x,
83
+ futureClockwise.y - ARENA_MIN,
84
+ ARENA_MAX - futureClockwise.y,
72
85
  );
73
86
  const scoreCounterClockwise = Math.min(
74
- futureCounterClockwise.x,
75
- ARENA_SIZE - futureCounterClockwise.x,
76
- futureCounterClockwise.y,
77
- ARENA_SIZE - futureCounterClockwise.y,
87
+ futureCounterClockwise.x - ARENA_MIN,
88
+ ARENA_MAX - futureCounterClockwise.x,
89
+ futureCounterClockwise.y - ARENA_MIN,
90
+ ARENA_MAX - futureCounterClockwise.y,
78
91
  );
79
92
  strafeDirection =
80
93
  scoreClockwise > scoreCounterClockwise
@@ -90,7 +103,7 @@ function aggressiveMove(
90
103
  // When actively dodging, commit fully
91
104
  if (dodgeDirection)
92
105
  {
93
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
106
+ return {x: strafeDirection.x, y: strafeDirection.y};
94
107
  }
95
108
 
96
109
  // Light strafe + aggressive closing
@@ -98,14 +111,14 @@ function aggressiveMove(
98
111
  let moveY = strafeDirection.y * strafeIntensity;
99
112
 
100
113
  // Always close distance aggressively
101
- moveX += (deltaX / normalizedDistance) * approachSpeed;
102
- moveY += (deltaY / normalizedDistance) * approachSpeed;
114
+ moveX += (approachDirX / approachMag) * approachSpeed;
115
+ moveY += (approachDirY / approachMag) * approachSpeed;
103
116
 
104
117
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
105
- if (magnitude > 100)
118
+ if (magnitude > 1)
106
119
  {
107
- moveX = (moveX / magnitude) * 100;
108
- moveY = (moveY / magnitude) * 100;
120
+ moveX = moveX / magnitude;
121
+ moveY = moveY / magnitude;
109
122
  }
110
123
 
111
124
  return {x: moveX, y: moveY};
@@ -128,11 +141,25 @@ function aggressiveMove(
128
141
  *
129
142
  * TIER: 2 (enhanced Shadowblade)
130
143
  */
131
- export const Nightblade: WizardFunction = ({state}) =>
144
+ export function Nightblade(): FinalAction
132
145
  {
133
- const stabRange = useParam('stabRange', 263, {range: 38, min: 0, steps: 5});
146
+ const position = usePosition();
147
+ const enemy = useEnemy();
148
+ const status = useStatus();
149
+ const blinkCooldown = useBlinkCooldown();
150
+ const health = useHealth();
151
+ const tick = useTick();
152
+ const threats = useThreats();
153
+ const projectiles = useProjectiles();
154
+ const myProjectiles = useMyProjectiles();
155
+ const lastMissileConfig = useLastMissileConfig();
156
+ const castingSpell = useCastingSpell();
157
+ const castProgressData = useCastProgress();
158
+
159
+
160
+ const stabRange = useParam('stabRange', 142, {range: 38, min: 0, steps: 5});
134
161
  const stabRangeVsCasting = stabRange + 20;
135
- const preemptiveShieldRange = useParam('preemptiveShieldRange', 261, {
162
+ const preemptiveShieldRange = useParam('preemptiveShieldRange', 94, {
136
163
  range: 85,
137
164
  min: 0,
138
165
  steps: 7,
@@ -143,22 +170,22 @@ export const Nightblade: WizardFunction = ({state}) =>
143
170
  max: 1,
144
171
  steps: 5,
145
172
  });
146
- const minTurnRate = useParam('minTurnRate', -11.4, {range: 5.25, steps: 5});
147
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {
173
+ const minTurnRate = useParam('minTurnRate', 0, {range: 2, min: 0, steps: 5});
174
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 174, {
148
175
  range: 75,
149
176
  min: 80,
150
177
  max: 300,
151
178
  });
152
- const strafeIntensity = useParam('strafeIntensity', 22, {
153
- range: 30,
154
- min: 10,
155
- max: 80,
179
+ const strafeIntensity = useParam('strafeIntensity', 0.2, {
180
+ range: 0.3,
181
+ min: 0.1,
182
+ max: 0.8,
156
183
  steps: 7,
157
184
  });
158
- const approachSpeed = useParam('approachSpeed', 92, {
159
- range: 20,
160
- min: 50,
161
- max: 100,
185
+ const approachSpeed = useParam('approachSpeed', 0.98, {
186
+ range: 0.2,
187
+ min: 0.5,
188
+ max: 1,
162
189
  steps: 7,
163
190
  });
164
191
  const shieldCancelThreshold = useParam('shieldCancelThreshold', 50, {
@@ -166,13 +193,13 @@ export const Nightblade: WizardFunction = ({state}) =>
166
193
  min: 50,
167
194
  max: 300,
168
195
  });
169
- const blinkWindowMax = useParam('blinkWindowMax', 19, {
196
+ const blinkWindowMax = useParam('blinkWindowMax', 15, {
170
197
  range: 30,
171
198
  min: 15,
172
199
  max: 80,
173
200
  steps: 7,
174
201
  });
175
- const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
202
+ const flightTimeDivisor = useParam('flightTimeDivisor', 11, {
176
203
  range: 4,
177
204
  min: 2,
178
205
  max: 12,
@@ -184,43 +211,67 @@ export const Nightblade: WizardFunction = ({state}) =>
184
211
  max: 100,
185
212
  steps: 7,
186
213
  });
187
- const meleeAbortRange = useParam('meleeAbortRange', 200, {
214
+ const meleeAbortRange = useParam('meleeAbortRange', 178, {
188
215
  range: 50,
189
216
  min: 50,
190
217
  max: 200,
191
218
  steps: 7,
192
219
  });
220
+ const useDynamicStab =
221
+ useParam('useDynamicStab', 0, {min: 0, max: 1, steps: 2}) >= 0.5;
222
+ const stabDamage = Math.round(useParam('stabDamage', 23, {
223
+ range: 10,
224
+ min: 5,
225
+ max: 25,
226
+ steps: 7,
227
+ }));
228
+ const centerBias = useParam('centerBias', 0.7, {
229
+ range: 0.4,
230
+ min: 0,
231
+ max: 0.8,
232
+ steps: 5,
233
+ });
234
+ const maxBlinkDistance = useParam('maxBlinkDistance', 200, {
235
+ range: 100,
236
+ min: 50,
237
+ max: 300,
238
+ steps: 7,
239
+ });
240
+
241
+ const distance = distanceTo(position, enemy.position);
193
242
 
194
- const enemy = state.enemies[0];
195
- if (!enemy)
243
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
196
244
  {
197
- return {move: {x: 0, y: 0}};
245
+ const dx = target.x - position.x;
246
+ const dy = target.y - position.y;
247
+ const dist = Math.sqrt(dx * dx + dy * dy);
248
+ if (dist <= maxBlinkDistance) return target;
249
+ const scale = maxBlinkDistance / dist;
250
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
198
251
  }
199
252
 
200
- const distance = distanceTo(state.position, enemy.position);
201
-
202
- const threats = getThreats(state);
203
253
  const urgentThreat = getMostUrgentThreat(threats);
204
254
 
205
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
255
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
206
256
  const move = aggressiveMove(
207
- state.position,
257
+ position,
208
258
  enemy,
209
259
  distance,
210
260
  dodgeDirection,
211
- state.tick,
261
+ tick,
212
262
  strafeCyclePeriod,
213
263
  strafeIntensity,
214
264
  approachSpeed,
265
+ centerBias,
215
266
  );
216
-
267
+ const safeMove = keepOffWalls(position, move);
217
268
  // === DEFENSE: Handle channeling ===
218
- if (state.state === 'channeling')
269
+ if (status === 'channeling')
219
270
  {
220
271
  // Keep shield up if enemy is about to fire at close range (preemptive shield scenario)
221
272
  const enemyFiringSoon =
222
273
  distance < preemptiveShieldRange &&
223
- enemy.state === 'casting' &&
274
+ enemy.status === 'casting' &&
224
275
  enemy.castingSpell === 'missile' &&
225
276
  (enemy.castDuration ?? 0) -
226
277
  (enemy.castProgress ?? 0) +
@@ -231,27 +282,27 @@ export const Nightblade: WizardFunction = ({state}) =>
231
282
  (urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
232
283
  )
233
284
  {
234
- return {move, cancel: true};
285
+ return cancel().move(safeMove.x, safeMove.y);
235
286
  }
236
- return {move: {x: 0, y: 0}};
287
+ return idle();
237
288
  }
238
289
 
239
290
  // === DEFENSE: Cancel missile cast for LETHAL incoming damage ===
240
291
  // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
241
292
  if (
242
- state.state === 'casting' &&
243
- state.castingSpell === 'missile' &&
293
+ status === 'casting' &&
294
+ castingSpell === 'missile' &&
244
295
  urgentThreat &&
245
- urgentThreat.projectile.damage >= state.health
296
+ urgentThreat.projectile.damage >= health
246
297
  )
247
298
  {
248
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
299
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
249
300
  if (
250
301
  urgentThreat.ticksToImpact <=
251
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
302
+ remainingCast + RULES.GCD_DURATION + RULES.SHIELD_CAST_TIME + SHIELD_BUFFER
252
303
  )
253
304
  {
254
- return {move, cancel: true};
305
+ return cancel().move(safeMove.x, safeMove.y);
255
306
  }
256
307
  }
257
308
 
@@ -259,51 +310,46 @@ export const Nightblade: WizardFunction = ({state}) =>
259
310
  // Melee missiles are non-homing with short range (~63u). If the enemy blinked away,
260
311
  // continuing the cast is wasted time while we're vulnerable.
261
312
  if (
262
- state.state === 'casting' &&
263
- state.castingSpell === 'missile' &&
313
+ status === 'casting' &&
314
+ castingSpell === 'missile' &&
264
315
  distance > meleeAbortRange
265
316
  )
266
317
  {
267
- return {move, cancel: true};
318
+ return cancel().move(safeMove.x, safeMove.y);
268
319
  }
269
320
 
270
321
  // === BLINK: Dodge missile + close gap simultaneously (first priority) ===
271
322
  // Blink both dodges the incoming missile AND closes distance — better than shielding.
272
323
  // Only shield when blink is on cooldown.
273
324
  if (
274
- state.state === 'idle' &&
325
+ status === 'idle' &&
275
326
  urgentThreat &&
276
- state.blinkCooldown === 0 &&
327
+ blinkCooldown === 0 &&
277
328
  distance > stabRange &&
278
329
  urgentThreat.ticksToImpact >= 10 &&
279
330
  urgentThreat.ticksToImpact <= blinkWindowMax
280
331
  )
281
332
  {
282
- const blinkTarget =
333
+ const rawBlinkTarget =
283
334
  getBlinkToDecreaseDistance(
284
- state.position,
335
+ position,
285
336
  enemy.position,
286
- state.projectiles,
337
+ projectiles,
287
338
  ) ?? enemy.position;
288
- return {
289
- move: {x: 0, y: 0},
290
- startCast: {spell: 'blink', target: blinkTarget},
291
- };
339
+ const blinkTarget = capBlinkDistance(rawBlinkTarget);
340
+ return blink(blinkTarget.x, blinkTarget.y);
292
341
  }
293
342
 
294
343
  // === DEFENSE: Shield lethal/high-damage undodgeable threats — survival over offense ===
295
344
  if (
296
- state.state === 'idle' &&
345
+ status === 'idle' &&
297
346
  urgentThreat &&
298
347
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
299
- urgentThreat.projectile.damage >= state.health &&
348
+ urgentThreat.projectile.damage >= health &&
300
349
  urgentThreat.canBlockInTime
301
350
  )
302
351
  {
303
- return {
304
- move: {x: 0, y: 0},
305
- startCast: {spell: 'shield'},
306
- };
352
+ return shield();
307
353
  }
308
354
 
309
355
  // === OFFENSE: Budget-based melee stab when close enough ===
@@ -311,28 +357,31 @@ export const Nightblade: WizardFunction = ({state}) =>
311
357
  // before needing to shield. Also stabs from farther when enemy is casting/channeling
312
358
  // (they move at 0.5 or 0 speed, so the cast delay doesn't let them dodge).
313
359
  const effectiveStabRange =
314
- enemy.state === 'casting' || enemy.state === 'channeling'
360
+ enemy.status === 'casting' || enemy.status === 'channeling'
315
361
  ? stabRangeVsCasting
316
362
  : stabRange;
317
363
  // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
318
- const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
319
- const hasNearbyLethalMissile = state.projectiles.some(
364
+ const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
365
+ const hasNearbyLethalMissile = projectiles.some(
320
366
  (p) =>
321
367
  !myProjectileIds.has(p.id) &&
322
- p.damage >= state.health &&
323
- distanceTo(state.position, p.position) / p.speed <
324
- MAX_CAST_BUDGET + GCD_DURATION,
368
+ p.damage >= health &&
369
+ distanceTo(position, p.position) / p.speed <
370
+ MAX_CAST_BUDGET + RULES.GCD_DURATION,
325
371
  );
326
372
  if (
327
- state.state === 'idle' &&
373
+ status === 'idle' &&
328
374
  distance < effectiveStabRange &&
329
375
  !hasNearbyLethalMissile
330
- )
376
+ )
331
377
  {
378
+ const reposition = getPreCastReposition(position, enemy.position);
379
+ if (reposition) return moveAction(reposition.x, reposition.y);
380
+
332
381
  let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
333
382
 
334
383
  // Account for enemy's active cast — their missile will arrive after cast completes + flight time
335
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
384
+ if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
336
385
  {
337
386
  const remainingEnemyCast =
338
387
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
@@ -344,65 +393,63 @@ export const Nightblade: WizardFunction = ({state}) =>
344
393
  }
345
394
 
346
395
  const safeBudget =
347
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
396
+ nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
348
397
  const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
349
398
  const enemyHP = Math.ceil(enemy.health);
350
399
  const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
351
- let config = fitMissileToBudget(budgetTicks, distance, {
352
- minTurnRate,
353
- maxDamage: enemyHP,
354
- lastMissileConfig: state.lastMissileConfig,
355
- });
356
-
357
- // If budget too small for useful stab, fire bigger and accept the hit
358
- // Only accept small hits (< 25% HP) — don't trade away large chunks of health
359
- // Never accept when enemy is actively casting (their missile WILL arrive during our long cast)
400
+ const stabConfig = {damage: stabDamage, speed: FAST_STAB.speed, turnRate: FAST_STAB.turnRate, duration: FAST_STAB.duration};
401
+
402
+ let config = useDynamicStab
403
+ ? fitMissileForEscapingTarget(budgetTicks, distance, {
404
+ minTurnRate,
405
+ maxDamage: enemyHP,
406
+ lastMissileConfig: lastMissileConfig,
407
+ })
408
+ : null;
409
+
410
+ // If dynamic fitting failed, try bigger budget accepting a hit
360
411
  const enemyCasting =
361
- enemy.state === 'casting' && enemy.castingSpell === 'missile';
412
+ enemy.status === 'casting' && enemy.castingSpell === 'missile';
362
413
  if (
414
+ useDynamicStab &&
363
415
  (!config || config.damage < minDmg) &&
364
- !enemyCasting &&
365
- (!urgentThreat ||
366
- urgentThreat.projectile.damage < state.health * acceptHitFraction)
367
- )
416
+ !enemyCasting &&
417
+ (!urgentThreat ||
418
+ urgentThreat.projectile.damage < health * acceptHitFraction)
419
+ )
368
420
  {
369
- config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
421
+ config = fitMissileForEscapingTarget(MAX_CAST_BUDGET, distance, {
370
422
  minTurnRate,
371
423
  maxDamage: enemyHP,
372
- lastMissileConfig: state.lastMissileConfig,
424
+ lastMissileConfig: lastMissileConfig,
373
425
  });
374
426
  }
375
427
 
376
- if (config)
428
+ // Fallback to fixed fast stab
429
+ if (!config || config.damage < minDmg)
377
430
  {
378
- return {
379
- move,
380
- startCast: {
381
- spell: 'missile',
382
- config,
383
- missileAI: () => ({}),
384
- direction: angleTo(state.position, enemy.position),
385
- },
386
- };
431
+ config = stabConfig;
432
+ }
433
+
434
+ if (config)
435
+ {
436
+ return missile(config, () => flyStraight(), angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
387
437
  }
388
438
  }
389
439
 
390
440
  // === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
391
441
  if (
392
- state.state === 'idle' &&
442
+ status === 'idle' &&
393
443
  urgentThreat &&
394
444
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
395
445
  )
396
446
  {
397
447
  if (
398
448
  urgentThreat.canBlockInTime &&
399
- (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
449
+ (blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
400
450
  )
401
451
  {
402
- return {
403
- move: {x: 0, y: 0},
404
- startCast: {spell: 'shield'},
405
- };
452
+ return shield();
406
453
  }
407
454
  }
408
455
 
@@ -410,9 +457,9 @@ export const Nightblade: WizardFunction = ({state}) =>
410
457
  // At melee range, missiles arrive almost instantly after launch — too fast to react.
411
458
  // Shield BEFORE the missile launches. Trigger when shield will finish BEFORE missile hits.
412
459
  if (
413
- state.state === 'idle' &&
460
+ status === 'idle' &&
414
461
  distance < preemptiveShieldRange &&
415
- enemy.state === 'casting' &&
462
+ enemy.status === 'casting' &&
416
463
  enemy.castingSpell === 'missile'
417
464
  )
418
465
  {
@@ -420,18 +467,15 @@ export const Nightblade: WizardFunction = ({state}) =>
420
467
  const estimatedFlightTicks = distance / flightTimeDivisor;
421
468
  const ticksUntilHit = remainingCast + estimatedFlightTicks;
422
469
  // Shield when hit is close enough that we MUST start now, but far enough that
423
- // the shield finishes in time. The window: SHIELD_CAST_TIME <= ticksUntilHit < SHIELD_CAST_TIME+15
470
+ // the shield finishes in time. The window: RULES.SHIELD_CAST_TIME <= ticksUntilHit < RULES.SHIELD_CAST_TIME+15
424
471
  if (
425
- ticksUntilHit >= SHIELD_CAST_TIME &&
426
- ticksUntilHit < SHIELD_CAST_TIME + 15
472
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
473
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
427
474
  )
428
475
  {
429
- return {
430
- move: {x: 0, y: 0},
431
- startCast: {spell: 'shield'},
432
- };
476
+ return shield();
433
477
  }
434
478
  }
435
479
 
436
- return {move};
437
- };
480
+ return moveAction(safeMove.x, safeMove.y);
481
+ }