@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,14 +1,10 @@
1
- import {
2
- WizardFunction,
3
- MissileAIFunction,
4
- MissileActions,
5
- } from '../../types.js';
1
+ import type {FinalAction, MissileFunction} from '../../hooks/index.js';
2
+ import {usePosition, useEnemy, useStatus, useBlinkCooldown, useHealth, useTick, useThreats, useLastMissileConfig, useCastingSpell, useCastProgress, idle, move as moveAction, missile, shield, blink, cancel, getMissileContext, turnToward, flyStraight} from '../../hooks/index.js';
6
3
  import {angleTo} from '../../utils/angles.js';
7
4
  import {distanceTo} from '../../utils/distance.js';
8
5
  import {interceptAngle, moveInDirection} from '../../utils/movement.js';
9
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
6
+ import {RULES, ARENA_MIN, ARENA_MAX} from '../../rules.js';
10
7
  import {
11
- getThreats,
12
8
  getMostUrgentThreat,
13
9
  getDodgeDirectionForMovement,
14
10
  getBlinkToCenter,
@@ -16,6 +12,8 @@ import {
16
12
  shouldForceShield,
17
13
  MAX_CAST_BUDGET,
18
14
  MIN_USEFUL_DAMAGE,
15
+ keepOffWalls,
16
+ getPreCastReposition,
19
17
  } from '../shared.js';
20
18
  import {useParam} from '../../engine/params-runtime.js';
21
19
  const WALL_BUFFER = 80;
@@ -25,37 +23,38 @@ const SHIELD_BUFFER = 3;
25
23
  /**
26
24
  * Predictive homing missile AI - leads the target.
27
25
  */
28
- const StormcallerMissileAI: MissileAIFunction = ({
29
- worldState,
30
- missileState,
31
- }) =>
26
+ const StormcallerMissileAI: MissileFunction = () =>
32
27
  {
33
- const enemy = worldState.enemies[0];
34
- if (!enemy) return {};
28
+ const ctx = getMissileContext();
29
+ const enemy = ctx.worldState.enemies[0];
30
+ if (enemy.health <= 0) return flyStraight();
35
31
 
36
32
  const angle = interceptAngle(
37
- missileState.position,
33
+ ctx.position,
38
34
  enemy.position,
39
35
  enemy.velocity,
40
- missileState.speed,
36
+ ctx.speed,
41
37
  );
42
- if (angle !== null)
38
+ if (angle !== null)
43
39
  {
44
- return {turnToward: moveInDirection(missileState.position, angle, 100)};
40
+ const target = moveInDirection(ctx.position, angle, 100);
41
+ return turnToward(target.x, target.y);
45
42
  }
46
- return {turnToward: enemy.position};
43
+ return turnToward(enemy.position.x, enemy.position.y);
47
44
  };
48
45
 
46
+ const StraightAI: MissileFunction = () => flyStraight();
47
+
49
48
  /**
50
49
  * Check if position is near a wall.
51
50
  */
52
51
  function isNearWall(position: {x: number; y: number}): boolean
53
52
  {
54
53
  return (
55
- position.x < WALL_BUFFER ||
56
- position.x > ARENA_SIZE - WALL_BUFFER ||
57
- position.y < WALL_BUFFER ||
58
- position.y > ARENA_SIZE - WALL_BUFFER
54
+ position.x < ARENA_MIN + WALL_BUFFER ||
55
+ position.x > ARENA_MAX - WALL_BUFFER ||
56
+ position.y < ARENA_MIN + WALL_BUFFER ||
57
+ position.y > ARENA_MAX - WALL_BUFFER
59
58
  );
60
59
  }
61
60
 
@@ -73,12 +72,22 @@ function combatMove(
73
72
  strafeIntensity: number,
74
73
  approachSpeed: number,
75
74
  distanceDeadZone: number,
76
- ): {x: number; y: number}
75
+ centerBias: number,
76
+ ): {x: number; y: number}
77
77
  {
78
78
  const deltaX = enemy.position.x - position.x;
79
79
  const deltaY = enemy.position.y - position.y;
80
80
  const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
81
81
 
82
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
83
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
84
+ const toCenterX = center - enemy.position.x;
85
+ const toCenterY = center - enemy.position.y;
86
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
87
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
88
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
89
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
90
+
82
91
  const strafeClockwise = {
83
92
  x: -deltaY / normalizedDistance,
84
93
  y: deltaX / normalizedDistance,
@@ -105,16 +114,16 @@ function combatMove(
105
114
  y: position.y + strafeCounterClockwise.y * 100,
106
115
  };
107
116
  const scoreClockwise = Math.min(
108
- futureClockwise.x,
109
- ARENA_SIZE - futureClockwise.x,
110
- futureClockwise.y,
111
- ARENA_SIZE - futureClockwise.y,
117
+ futureClockwise.x - ARENA_MIN,
118
+ ARENA_MAX - futureClockwise.x,
119
+ futureClockwise.y - ARENA_MIN,
120
+ ARENA_MAX - futureClockwise.y,
112
121
  );
113
122
  const scoreCounterClockwise = Math.min(
114
- futureCounterClockwise.x,
115
- ARENA_SIZE - futureCounterClockwise.x,
116
- futureCounterClockwise.y,
117
- ARENA_SIZE - futureCounterClockwise.y,
123
+ futureCounterClockwise.x - ARENA_MIN,
124
+ ARENA_MAX - futureCounterClockwise.x,
125
+ futureCounterClockwise.y - ARENA_MIN,
126
+ ARENA_MAX - futureCounterClockwise.y,
118
127
  );
119
128
  strafeDirection =
120
129
  scoreClockwise > scoreCounterClockwise
@@ -129,34 +138,34 @@ function combatMove(
129
138
 
130
139
  if (dodgeDirection)
131
140
  {
132
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
141
+ return {x: strafeDirection.x, y: strafeDirection.y};
133
142
  }
134
143
 
135
144
  let moveX = strafeDirection.x * strafeIntensity;
136
145
  let moveY = strafeDirection.y * strafeIntensity;
137
146
 
138
- if (currentDistance > targetDistance + distanceDeadZone)
147
+ if (currentDistance > targetDistance + distanceDeadZone)
139
148
  {
140
- moveX += (deltaX / normalizedDistance) * approachSpeed;
141
- moveY += (deltaY / normalizedDistance) * approachSpeed;
149
+ moveX += (approachDirX / approachMag) * approachSpeed;
150
+ moveY += (approachDirY / approachMag) * approachSpeed;
142
151
  }
143
152
  else if (currentDistance < targetDistance - distanceDeadZone)
144
153
  {
145
154
  let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
146
155
  let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
147
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
148
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
149
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
150
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
156
+ if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
157
+ if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0) retreatX = 0;
158
+ if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
159
+ if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0) retreatY = 0;
151
160
  moveX += retreatX;
152
161
  moveY += retreatY;
153
162
  }
154
163
 
155
164
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
156
- if (magnitude > 100)
165
+ if (magnitude > 1)
157
166
  {
158
- moveX = (moveX / magnitude) * 100;
159
- moveY = (moveY / magnitude) * 100;
167
+ moveX = moveX / magnitude;
168
+ moveY = moveY / magnitude;
160
169
  }
161
170
 
162
171
  return {x: moveX, y: moveY};
@@ -178,10 +187,22 @@ function combatMove(
178
187
  *
179
188
  * TIER: 2 (enhanced Stormchaser)
180
189
  */
181
- export const Stormcaller: WizardFunction = ({state}) =>
190
+ export function Stormcaller(): FinalAction
182
191
  {
192
+ const position = usePosition();
193
+ const enemy = useEnemy();
194
+ const status = useStatus();
195
+ const blinkCooldown = useBlinkCooldown();
196
+ const health = useHealth();
197
+ const tick = useTick();
198
+ const threats = useThreats();
199
+ const lastMissileConfig = useLastMissileConfig();
200
+ const castingSpell = useCastingSpell();
201
+ const castProgressData = useCastProgress();
202
+
203
+
183
204
  // Tunable parameters (run optimizer script to find optimal values)
184
- const targetDistance = useParam('targetDistance', 129, {
205
+ const targetDistance = useParam('targetDistance', 176, {
185
206
  range: 200,
186
207
  min: 0,
187
208
  });
@@ -191,24 +212,24 @@ export const Stormcaller: WizardFunction = ({state}) =>
191
212
  min: 1,
192
213
  steps: 7,
193
214
  });
194
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 281, {
215
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 208, {
195
216
  range: 75,
196
217
  min: 80,
197
218
  max: 300,
198
219
  });
199
- const strafeIntensity = useParam('strafeIntensity', 69, {
200
- range: 40,
201
- min: 20,
202
- max: 100,
220
+ const strafeIntensity = useParam('strafeIntensity', 0.9, {
221
+ range: 0.4,
222
+ min: 0.2,
223
+ max: 1,
203
224
  steps: 7,
204
225
  });
205
- const approachSpeed = useParam('approachSpeed', 69, {
206
- range: 30,
207
- min: 10,
208
- max: 70,
226
+ const approachSpeed = useParam('approachSpeed', 0.45, {
227
+ range: 0.3,
228
+ min: 0.1,
229
+ max: 0.7,
209
230
  steps: 7,
210
231
  });
211
- const distanceDeadZone = useParam('distanceDeadZone', 18, {
232
+ const distanceDeadZone = useParam('distanceDeadZone', 33, {
212
233
  range: 25,
213
234
  min: 10,
214
235
  max: 60,
@@ -219,78 +240,95 @@ export const Stormcaller: WizardFunction = ({state}) =>
219
240
  min: 50,
220
241
  max: 300,
221
242
  });
222
- const blinkWindowMax = useParam('blinkWindowMax', 70, {
243
+ const blinkWindowMax = useParam('blinkWindowMax', 31, {
223
244
  range: 40,
224
245
  min: 20,
225
246
  max: 120,
226
247
  steps: 7,
227
248
  });
228
- const attackRange = useParam('attackRange', 430, {
249
+ const attackRange = useParam('attackRange', 590, {
229
250
  range: 200,
230
251
  min: 300,
231
252
  max: 800,
232
253
  });
233
- const flightTimeDivisor = useParam('flightTimeDivisor', 10.8, {
254
+ const flightTimeDivisor = useParam('flightTimeDivisor', 10.5, {
234
255
  range: 4,
235
256
  min: 2,
236
257
  max: 12,
237
258
  steps: 5,
238
259
  });
260
+ const centerBias = useParam('centerBias', 0.26, {
261
+ range: 0.4,
262
+ min: 0,
263
+ max: 0.8,
264
+ steps: 5,
265
+ });
266
+ const maxBlinkDistance = useParam('maxBlinkDistance', 178, {
267
+ range: 100,
268
+ min: 50,
269
+ max: 300,
270
+ steps: 7,
271
+ });
272
+
273
+ const distance = distanceTo(position, enemy.position);
239
274
 
240
- const enemy = state.enemies[0];
241
- if (!enemy)
275
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
242
276
  {
243
- return {move: {x: 0, y: 0}};
277
+ const dx = target.x - position.x;
278
+ const dy = target.y - position.y;
279
+ const dist = Math.sqrt(dx * dx + dy * dy);
280
+ if (dist <= maxBlinkDistance) return target;
281
+ const scale = maxBlinkDistance / dist;
282
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
244
283
  }
245
284
 
246
- const distance = distanceTo(state.position, enemy.position);
247
-
248
285
  // Analyze threats using simulation
249
- const threats = getThreats(state);
250
286
  const urgentThreat = getMostUrgentThreat(threats);
251
287
 
252
288
  // === DEFENSE: Handle channeling state ===
253
- if (state.state === 'channeling')
289
+ if (status === 'channeling')
254
290
  {
255
291
  if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
256
292
  {
257
- return {move: {x: 0, y: 0}, cancel: true};
293
+ return cancel();
258
294
  }
259
- return {move: {x: 0, y: 0}};
295
+ return idle();
260
296
  }
261
297
 
262
298
  // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
263
299
  // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
264
300
  if (
265
- state.state === 'casting' &&
266
- state.castingSpell === 'missile' &&
301
+ status === 'casting' &&
302
+ castingSpell === 'missile' &&
267
303
  urgentThreat &&
268
- urgentThreat.projectile.damage >= state.health
304
+ urgentThreat.projectile.damage >= health
269
305
  )
270
306
  {
271
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
307
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
272
308
  if (
273
309
  urgentThreat.ticksToImpact <=
274
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
310
+ remainingCast + RULES.GCD_DURATION + RULES.SHIELD_CAST_TIME + 1
275
311
  )
276
312
  {
277
313
  const dodgeDirection = getDodgeDirectionForMovement(
278
314
  threats,
279
- state.position,
315
+ position,
280
316
  );
281
- const move = combatMove(
282
- state.position,
317
+ const cancelMove = combatMove(
318
+ position,
283
319
  enemy,
284
320
  distance,
285
321
  targetDistance,
286
322
  dodgeDirection,
287
- state.tick,
323
+ tick,
288
324
  strafeCyclePeriod,
289
325
  strafeIntensity,
290
326
  approachSpeed,
291
327
  distanceDeadZone,
328
+ centerBias,
292
329
  );
293
- return {move, cancel: true};
330
+ const safeCancelMove = keepOffWalls(position, cancelMove);
331
+ return cancel().move(safeCancelMove.x, safeCancelMove.y);
294
332
  }
295
333
  }
296
334
 
@@ -298,27 +336,25 @@ export const Stormcaller: WizardFunction = ({state}) =>
298
336
  // Blink dodges the incoming missile — no GCD after, so can immediately fire back.
299
337
  // Only shield when blink is on cooldown.
300
338
  if (
301
- state.state === 'idle' &&
339
+ status === 'idle' &&
302
340
  urgentThreat &&
303
- state.blinkCooldown === 0 &&
341
+ blinkCooldown === 0 &&
304
342
  urgentThreat.ticksToImpact >= 10 &&
305
343
  urgentThreat.ticksToImpact <= blinkWindowMax
306
344
  )
307
345
  {
308
- return {
309
- move: {x: 0, y: 0},
310
- startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
311
- };
346
+ const blinkTarget = capBlinkDistance(getBlinkToCenter(position));
347
+ return blink(blinkTarget.x, blinkTarget.y);
312
348
  }
313
349
 
314
350
  // === DEFENSE: Shield threats (when blink is on cooldown) ===
315
- if (state.state === 'idle' && urgentThreat)
351
+ if (status === 'idle' && urgentThreat)
316
352
  {
317
353
  const forceShield = shouldForceShield(urgentThreat);
318
354
  const undodgeable = !urgentThreat.bestDodgeDirection;
319
- const healthLow = state.health < shieldHealthThreshold;
355
+ const healthLow = health < shieldHealthThreshold;
320
356
  const healthCritical =
321
- state.health <= urgentThreat.projectile.damage * 2 + 1;
357
+ health <= urgentThreat.projectile.damage * 2 + 1;
322
358
  const doShield =
323
359
  forceShield || (undodgeable && healthLow) || healthCritical;
324
360
 
@@ -326,36 +362,58 @@ export const Stormcaller: WizardFunction = ({state}) =>
326
362
  {
327
363
  if (urgentThreat.ticksToStartShield <= 3)
328
364
  {
329
- return {
330
- move: {x: 0, y: 0},
331
- startCast: {spell: 'shield'},
332
- };
365
+ return shield();
333
366
  }
334
367
  }
335
368
  }
336
369
 
370
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
371
+ if (
372
+ status === 'idle' &&
373
+ distance < 300 &&
374
+ enemy.status === 'casting' &&
375
+ enemy.castingSpell === 'missile'
376
+ )
377
+ {
378
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
379
+ const estimatedFlightTicks = distance / 8;
380
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
381
+
382
+ if (
383
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
384
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
385
+ )
386
+ {
387
+ return shield();
388
+ }
389
+ }
390
+
337
391
  // === MOVEMENT: Smart combat movement ===
338
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
392
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
339
393
  const move = combatMove(
340
- state.position,
394
+ position,
341
395
  enemy,
342
396
  distance,
343
397
  targetDistance,
344
398
  dodgeDirection,
345
- state.tick,
399
+ tick,
346
400
  strafeCyclePeriod,
347
401
  strafeIntensity,
348
402
  approachSpeed,
349
403
  distanceDeadZone,
404
+ centerBias,
350
405
  );
351
-
406
+ const safeMove = keepOffWalls(position, move);
352
407
  // === OFFENSE: Adaptive missile fitting with predictive homing ===
353
- if (state.state === 'idle' && distance < attackRange)
408
+ if (status === 'idle' && distance < attackRange)
354
409
  {
410
+ const reposition = getPreCastReposition(position, enemy.position);
411
+ if (reposition) return moveAction(reposition.x, reposition.y);
412
+
355
413
  let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
356
414
 
357
415
  // Account for enemy's active cast — their missile will arrive after cast completes + flight time
358
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
416
+ if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
359
417
  {
360
418
  const remainingEnemyCast =
361
419
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
@@ -367,7 +425,7 @@ export const Stormcaller: WizardFunction = ({state}) =>
367
425
  }
368
426
 
369
427
  const safeBudget =
370
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
428
+ nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
371
429
  const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
372
430
 
373
431
  // Predictive homing compensates for movement during cast
@@ -376,23 +434,12 @@ export const Stormcaller: WizardFunction = ({state}) =>
376
434
  const config = fitMissileToBudget(budgetTicks, distance, {
377
435
  minTurnRate,
378
436
  maxDamage: enemyHP,
379
- lastMissileConfig: state.lastMissileConfig,
437
+ lastMissileConfig: lastMissileConfig,
380
438
  });
381
439
 
382
440
  if (config && config.damage >= minDmg)
383
441
  {
384
- return {
385
- move,
386
- startCast: {
387
- spell: 'missile',
388
- config,
389
- missileAI:
390
- config.turnRate > 0
391
- ? StormcallerMissileAI
392
- : (): MissileActions => ({}),
393
- direction: angleTo(state.position, enemy.position),
394
- },
395
- };
442
+ return missile(config, config.turnRate > 0 ? StormcallerMissileAI : StraightAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
396
443
  }
397
444
 
398
445
  // Budget too small for useful missile — wait for threat to pass, then fire
@@ -400,18 +447,15 @@ export const Stormcaller: WizardFunction = ({state}) =>
400
447
 
401
448
  // === FALLBACK DEFENSE: Shield when no attack window exists ===
402
449
  if (
403
- state.state === 'idle' &&
450
+ status === 'idle' &&
404
451
  urgentThreat &&
405
452
  !urgentThreat.bestDodgeDirection &&
406
453
  urgentThreat.canBlockInTime &&
407
454
  urgentThreat.ticksToStartShield <= 3
408
455
  )
409
456
  {
410
- return {
411
- move: {x: 0, y: 0},
412
- startCast: {spell: 'shield'},
413
- };
457
+ return shield();
414
458
  }
415
459
 
416
- return {move};
417
- };
460
+ return moveAction(safeMove.x, safeMove.y);
461
+ }