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