@vibemancer/core 0.1.4 → 0.1.6

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 (76) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-G5T65F3W.js +10617 -0
  3. package/dist/chunk-G5T65F3W.js.map +1 -0
  4. package/dist/index-browser.d.ts +1328 -1051
  5. package/dist/index-browser.js +59 -17
  6. package/dist/index.d.ts +53 -3
  7. package/dist/index.js +204 -54
  8. package/dist/index.js.map +1 -1
  9. package/package.json +21 -15
  10. package/src/banned-bot-names.ts +53 -0
  11. package/src/bots/Hero.ts +867 -0
  12. package/src/bots/berserker/01_Stormchaser.ts +162 -120
  13. package/src/bots/berserker/02_Stormcaller.ts +160 -116
  14. package/src/bots/berserker/03_Stormforger.ts +162 -129
  15. package/src/bots/caster/01_Flamecaller.ts +126 -84
  16. package/src/bots/caster/02_Pyromancer.ts +148 -101
  17. package/src/bots/caster/03_Infernalist.ts +180 -160
  18. package/src/bots/defensive/01_Turtle.ts +48 -44
  19. package/src/bots/defensive/02_Sentinel.ts +57 -53
  20. package/src/bots/defensive/03_Golem.ts +85 -97
  21. package/src/bots/duelist/01_Battlemage.ts +157 -122
  22. package/src/bots/duelist/02_Warmage.ts +166 -121
  23. package/src/bots/duelist/03_Archmage.ts +198 -178
  24. package/src/bots/homing/01_Bonemancer.ts +20 -32
  25. package/src/bots/homing/02_Lich.ts +145 -93
  26. package/src/bots/homing/03_Archlich.ts +129 -60
  27. package/src/bots/kiter/01_Spellspinner.ts +145 -107
  28. package/src/bots/kiter/02_Spellweaver.ts +153 -105
  29. package/src/bots/kiter/03_Spellbinder.ts +168 -123
  30. package/src/bots/melee/01_Shadowblade.ts +131 -75
  31. package/src/bots/melee/02_Nightblade.ts +174 -130
  32. package/src/bots/melee/03_Voidblade.ts +266 -168
  33. package/src/bots/registry.ts +44 -37
  34. package/src/bots/shared.ts +185 -25
  35. package/src/bots/sniper/01_Spellshot.ts +151 -98
  36. package/src/bots/sniper/02_Spelltracer.ts +173 -128
  37. package/src/bots/sniper/03_Spellseeker.ts +177 -152
  38. package/src/bots/standalone/Critter.ts +46 -52
  39. package/src/bots/standalone/Doombringer.ts +36 -40
  40. package/src/bots/standalone/Hogger.ts +120 -89
  41. package/src/bots/standalone/Rookie.ts +21 -23
  42. package/src/bots/standalone/TargetDummy.ts +5 -6
  43. package/src/bots/test/cheater.ts +91 -85
  44. package/src/bots/test/crasher.ts +26 -28
  45. package/src/engine/bundle-fight.ts +242 -0
  46. package/src/engine/hooks-runtime.ts +58 -18
  47. package/src/engine/manual-match.ts +33 -25
  48. package/src/engine/missile-templates.ts +25 -43
  49. package/src/engine/optimizer.ts +7 -7
  50. package/src/engine/params-runtime.ts +3 -3
  51. package/src/engine/physics.ts +33 -23
  52. package/src/engine/sandbox-browser.ts +8 -7
  53. package/src/engine/sandbox-compile.ts +1 -1
  54. package/src/engine/sandbox-harness.ts +299 -367
  55. package/src/engine/sandbox.ts +3 -3
  56. package/src/engine/simulation.ts +291 -76
  57. package/src/engine/spells.ts +9 -12
  58. package/src/engine-version.ts +1 -1
  59. package/src/hooks/action-builders.ts +76 -21
  60. package/src/hooks/index.ts +17 -9
  61. package/src/hooks/state-hooks.ts +61 -25
  62. package/src/hooks/threat-analysis.ts +44 -20
  63. package/src/hooks/types.ts +62 -5
  64. package/src/index-browser.ts +1 -0
  65. package/src/index.ts +3 -0
  66. package/src/rules.ts +209 -63
  67. package/src/stats.ts +2 -2
  68. package/src/testing.ts +6 -30
  69. package/src/trace.ts +63 -3
  70. package/src/types.ts +127 -14
  71. package/src/utils/angles.ts +1 -0
  72. package/src/utils/combat.ts +98 -6
  73. package/src/utils/spatial.ts +3 -3
  74. package/dist/chunk-74FFJCFF.js +0 -9140
  75. package/dist/chunk-74FFJCFF.js.map +0 -1
  76. package/src/hooks/bot-wrapper.ts +0 -84
@@ -1,16 +1,18 @@
1
- import {WizardFunction, MissileAIFunction} from '../../types.js';
1
+ import type {FinalAction, MissileFunction} from '../../hooks/index.js';
2
+ import {usePosition, useEnemy, useStatus, useBlinkCooldown, useHealth, useTick, useThreats, useCastingSpell, useCastProgress, idle, move as moveAction, missile, shield, blink, cancel, getMissileContext, turnToward, flyStraight} from '../../hooks/index.js';
2
3
  import {angleTo} from '../../utils/angles.js';
3
4
  import {distanceTo} from '../../utils/distance.js';
4
5
  import {interceptAngle, moveInDirection} from '../../utils/movement.js';
5
6
  import {getMissileCastTime} from '../../utils/combat.js';
6
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
7
+ import {RULES, ARENA_MIN, ARENA_MAX} from '../../rules.js';
7
8
  import {
8
- getThreats,
9
9
  getMostUrgentThreat,
10
10
  getDodgeDirectionForMovement,
11
11
  isSafeToAttack,
12
12
  getBlinkToCenter,
13
13
  shouldForceShield,
14
+ keepOffWalls,
15
+ getPreCastReposition,
14
16
  } from '../shared.js';
15
17
  import {useParam} from '../../engine/params-runtime.js';
16
18
  const WALL_BUFFER = 80;
@@ -29,25 +31,24 @@ const QUICK_CONFIG = {damage: 10, speed: 5, turnRate: 1, duration: 80};
29
31
  /**
30
32
  * Predictive homing missile AI - leads the target.
31
33
  */
32
- const StormchaserMissileAI: MissileAIFunction = ({
33
- worldState,
34
- missileState,
35
- }) =>
34
+ const StormchaserMissileAI: MissileFunction = () =>
36
35
  {
37
- const enemy = worldState.enemies[0];
38
- if (!enemy) return {};
36
+ const ctx = getMissileContext();
37
+ const enemy = ctx.worldState.enemies[0];
38
+ if (enemy.health <= 0) return flyStraight();
39
39
 
40
40
  const angle = interceptAngle(
41
- missileState.position,
41
+ ctx.position,
42
42
  enemy.position,
43
43
  enemy.velocity,
44
- missileState.speed,
44
+ ctx.speed,
45
45
  );
46
- if (angle !== null)
46
+ if (angle !== null)
47
47
  {
48
- return {turnToward: moveInDirection(missileState.position, angle, 100)};
48
+ const target = moveInDirection(ctx.position, angle, 100);
49
+ return turnToward(target.x, target.y);
49
50
  }
50
- return {turnToward: enemy.position};
51
+ return turnToward(enemy.position.x, enemy.position.y);
51
52
  };
52
53
 
53
54
  /**
@@ -56,10 +57,10 @@ const StormchaserMissileAI: MissileAIFunction = ({
56
57
  function isNearWall(position: {x: number; y: number}): boolean
57
58
  {
58
59
  return (
59
- position.x < WALL_BUFFER ||
60
- position.x > ARENA_SIZE - WALL_BUFFER ||
61
- position.y < WALL_BUFFER ||
62
- position.y > ARENA_SIZE - WALL_BUFFER
60
+ position.x < ARENA_MIN + WALL_BUFFER ||
61
+ position.x > ARENA_MAX - WALL_BUFFER ||
62
+ position.y < ARENA_MIN + WALL_BUFFER ||
63
+ position.y > ARENA_MAX - WALL_BUFFER
63
64
  );
64
65
  }
65
66
 
@@ -77,12 +78,22 @@ function combatMove(
77
78
  strafeIntensity: number,
78
79
  approachSpeed: number,
79
80
  distanceDeadZone: number,
80
- ): {x: number; y: number}
81
+ centerBias: number,
82
+ ): {x: number; y: number}
81
83
  {
82
84
  const deltaX = enemy.position.x - position.x;
83
85
  const deltaY = enemy.position.y - position.y;
84
86
  const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
85
87
 
88
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
89
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
90
+ const toCenterX = center - enemy.position.x;
91
+ const toCenterY = center - enemy.position.y;
92
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
93
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
94
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
95
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
96
+
86
97
  // Perpendicular directions for strafing
87
98
  const strafeClockwise = {
88
99
  x: -deltaY / normalizedDistance,
@@ -112,16 +123,16 @@ function combatMove(
112
123
  y: position.y + strafeCounterClockwise.y * 100,
113
124
  };
114
125
  const scoreClockwise = Math.min(
115
- futureClockwise.x,
116
- ARENA_SIZE - futureClockwise.x,
117
- futureClockwise.y,
118
- ARENA_SIZE - futureClockwise.y,
126
+ futureClockwise.x - ARENA_MIN,
127
+ ARENA_MAX - futureClockwise.x,
128
+ futureClockwise.y - ARENA_MIN,
129
+ ARENA_MAX - futureClockwise.y,
119
130
  );
120
131
  const scoreCounterClockwise = Math.min(
121
- futureCounterClockwise.x,
122
- ARENA_SIZE - futureCounterClockwise.x,
123
- futureCounterClockwise.y,
124
- ARENA_SIZE - futureCounterClockwise.y,
132
+ futureCounterClockwise.x - ARENA_MIN,
133
+ ARENA_MAX - futureCounterClockwise.x,
134
+ futureCounterClockwise.y - ARENA_MIN,
135
+ ARENA_MAX - futureCounterClockwise.y,
125
136
  );
126
137
  strafeDirection =
127
138
  scoreClockwise > scoreCounterClockwise
@@ -138,36 +149,36 @@ function combatMove(
138
149
  // When actively dodging, commit fully — no approach/retreat dilution
139
150
  if (dodgeDirection)
140
151
  {
141
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
152
+ return {x: strafeDirection.x, y: strafeDirection.y};
142
153
  }
143
154
 
144
155
  let moveX = strafeDirection.x * strafeIntensity;
145
156
  let moveY = strafeDirection.y * strafeIntensity;
146
157
 
147
158
  // Distance management (tighter band than other bots, only when not dodging)
148
- if (currentDistance > targetDistance + distanceDeadZone)
159
+ if (currentDistance > targetDistance + distanceDeadZone)
149
160
  {
150
- moveX += (deltaX / normalizedDistance) * approachSpeed;
151
- moveY += (deltaY / normalizedDistance) * approachSpeed;
161
+ moveX += (approachDirX / approachMag) * approachSpeed;
162
+ moveY += (approachDirY / approachMag) * approachSpeed;
152
163
  }
153
164
  else if (currentDistance < targetDistance - distanceDeadZone)
154
165
  {
155
166
  let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
156
167
  let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
157
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
158
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
159
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
160
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
168
+ if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
169
+ if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0) retreatX = 0;
170
+ if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
171
+ if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0) retreatY = 0;
161
172
  moveX += retreatX;
162
173
  moveY += retreatY;
163
174
  }
164
175
 
165
176
  // Normalize to max speed
166
177
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
167
- if (magnitude > 100)
178
+ if (magnitude > 1)
168
179
  {
169
- moveX = (moveX / magnitude) * 100;
170
- moveY = (moveY / magnitude) * 100;
180
+ moveX = moveX / magnitude;
181
+ moveY = moveY / magnitude;
171
182
  }
172
183
 
173
184
  return {x: moveX, y: moveY};
@@ -189,9 +200,20 @@ function combatMove(
189
200
  *
190
201
  * TIER: 1 (base)
191
202
  */
192
- export const Stormchaser: WizardFunction = ({state}) =>
203
+ export function Stormchaser(): FinalAction
193
204
  {
194
- const targetDistance = useParam('targetDistance', 191, {
205
+ const position = usePosition();
206
+ const enemy = useEnemy();
207
+ const status = useStatus();
208
+ const blinkCooldown = useBlinkCooldown();
209
+ const health = useHealth();
210
+ const tick = useTick();
211
+ const threats = useThreats();
212
+ const castingSpell = useCastingSpell();
213
+ const castProgressData = useCastProgress();
214
+
215
+
216
+ const targetDistance = useParam('targetDistance', 263, {
195
217
  range: 150,
196
218
  min: 0,
197
219
  });
@@ -200,24 +222,24 @@ export const Stormchaser: WizardFunction = ({state}) =>
200
222
  min: 1,
201
223
  steps: 5,
202
224
  });
203
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 130, {
225
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 205, {
204
226
  range: 75,
205
227
  min: 80,
206
228
  max: 300,
207
229
  });
208
- const strafeIntensity = useParam('strafeIntensity', 42, {
209
- range: 40,
210
- min: 20,
211
- max: 100,
230
+ const strafeIntensity = useParam('strafeIntensity', 0.93, {
231
+ range: 0.4,
232
+ min: 0.2,
233
+ max: 1,
212
234
  steps: 7,
213
235
  });
214
- const approachSpeed = useParam('approachSpeed', 68, {
215
- range: 30,
216
- min: 10,
217
- max: 70,
236
+ const approachSpeed = useParam('approachSpeed', 0.38, {
237
+ range: 0.3,
238
+ min: 0.1,
239
+ max: 0.7,
218
240
  steps: 7,
219
241
  });
220
- const distanceDeadZone = useParam('distanceDeadZone', 26, {
242
+ const distanceDeadZone = useParam('distanceDeadZone', 15, {
221
243
  range: 25,
222
244
  min: 10,
223
245
  max: 60,
@@ -228,7 +250,7 @@ export const Stormchaser: WizardFunction = ({state}) =>
228
250
  min: 50,
229
251
  max: 300,
230
252
  });
231
- const blinkWindowMax = useParam('blinkWindowMax', 25, {
253
+ const blinkWindowMax = useParam('blinkWindowMax', 80, {
232
254
  range: 30,
233
255
  min: 15,
234
256
  max: 80,
@@ -240,82 +262,99 @@ export const Stormchaser: WizardFunction = ({state}) =>
240
262
  max: 150,
241
263
  steps: 7,
242
264
  });
243
- const attackRange = useParam('attackRange', 676, {
265
+ const attackRange = useParam('attackRange', 697, {
244
266
  range: 200,
245
267
  min: 300,
246
268
  max: 800,
247
269
  });
248
- const healthDamageRatio = useParam('healthDamageRatio', 5, {
270
+ const healthDamageRatio = useParam('healthDamageRatio', 4.5, {
249
271
  range: 2,
250
272
  min: 0.5,
251
273
  max: 5,
252
274
  steps: 7,
253
275
  });
276
+ const centerBias = useParam('centerBias', 0.13, {
277
+ range: 0.4,
278
+ min: 0,
279
+ max: 0.8,
280
+ steps: 5,
281
+ });
282
+ const maxBlinkDistance = useParam('maxBlinkDistance', 300, {
283
+ range: 100,
284
+ min: 50,
285
+ max: 300,
286
+ steps: 7,
287
+ });
288
+
289
+ const distance = distanceTo(position, enemy.position);
254
290
 
255
- const enemy = state.enemies[0];
256
- if (!enemy)
291
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
257
292
  {
258
- return {move: {x: 0, y: 0}};
293
+ const dx = target.x - position.x;
294
+ const dy = target.y - position.y;
295
+ const dist = Math.sqrt(dx * dx + dy * dy);
296
+ if (dist <= maxBlinkDistance) return target;
297
+ const scale = maxBlinkDistance / dist;
298
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
259
299
  }
260
300
 
261
- const distance = distanceTo(state.position, enemy.position);
262
-
263
301
  // Analyze threats using simulation
264
- const threats = getThreats(state);
265
302
  const urgentThreat = getMostUrgentThreat(threats);
266
303
 
267
304
  // === DEFENSE: Handle channeling state ===
268
- if (state.state === 'channeling')
305
+ if (status === 'channeling')
269
306
  {
270
307
  // Cancel shield if no imminent danger
271
308
  if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
272
309
  {
273
- return {move: {x: 0, y: 0}, cancel: true};
310
+ return cancel();
274
311
  }
275
- return {move: {x: 0, y: 0}};
312
+ return idle();
276
313
  }
277
314
 
278
315
  // === DEFENSE: Cancel missile cast for threats arriving during vulnerability window ===
279
316
  if (
280
- state.state === 'casting' &&
281
- state.castingSpell === 'missile' &&
317
+ status === 'casting' &&
318
+ castingSpell === 'missile' &&
282
319
  urgentThreat
283
320
  )
284
321
  {
285
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
322
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
286
323
  const incomingDamage = urgentThreat.projectile.damage;
287
324
  const arrivesInGCD =
288
325
  urgentThreat.ticksToImpact > remainingCast &&
289
- urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION;
326
+ urgentThreat.ticksToImpact <= remainingCast + RULES.GCD_DURATION;
290
327
 
291
328
  // Cancel for undodgeable threats when trade isn't worth it
292
329
  if (!urgentThreat.bestDodgeDirection)
293
330
  {
294
331
  const canSurviveHit =
295
- state.health > incomingDamage * healthDamageRatio + 1;
332
+ health > incomingDamage * healthDamageRatio + 1;
296
333
  const tradeWorthIt =
297
334
  canSurviveHit && incomingDamage <= QUICK_CONFIG.damage;
298
335
  if (
299
336
  !tradeWorthIt &&
300
337
  remainingCast > cancelMinRemainingCast &&
301
338
  urgentThreat.ticksToImpact <=
302
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
339
+ remainingCast + RULES.GCD_DURATION + RULES.SHIELD_CAST_TIME + 1
303
340
  )
304
341
  {
305
- const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
342
+ const dodgeDir = getDodgeDirectionForMovement(threats, position);
306
343
  const cancelMove = combatMove(
307
- state.position,
344
+ position,
308
345
  enemy,
309
346
  distance,
310
347
  targetDistance,
311
348
  dodgeDir,
312
- state.tick,
349
+ tick,
313
350
  strafeCyclePeriod,
314
351
  strafeIntensity,
315
352
  approachSpeed,
316
353
  distanceDeadZone,
354
+ centerBias,
317
355
  );
318
- return {move: cancelMove, cancel: true};
356
+ const safeCancelMove = keepOffWalls(position, cancelMove);
357
+ return cancel().move(safeCancelMove.x, safeCancelMove.y);
319
358
  }
320
359
  }
321
360
 
@@ -323,24 +362,26 @@ export const Stormchaser: WizardFunction = ({state}) =>
323
362
  // At low health, surviving is more important than finishing the attack.
324
363
  if (
325
364
  arrivesInGCD &&
326
- state.health <= incomingDamage + 1 &&
365
+ health <= incomingDamage + 1 &&
327
366
  remainingCast > 10
328
367
  )
329
368
  {
330
- const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
369
+ const dodgeDir = getDodgeDirectionForMovement(threats, position);
331
370
  const cancelMove = combatMove(
332
- state.position,
371
+ position,
333
372
  enemy,
334
373
  distance,
335
374
  targetDistance,
336
375
  dodgeDir,
337
- state.tick,
376
+ tick,
338
377
  strafeCyclePeriod,
339
378
  strafeIntensity,
340
379
  approachSpeed,
341
380
  distanceDeadZone,
381
+ centerBias,
342
382
  );
343
- return {move: cancelMove, cancel: true};
383
+ const safeCancelMove = keepOffWalls(position, cancelMove);
384
+ return cancel().move(safeCancelMove.x, safeCancelMove.y);
344
385
  }
345
386
  }
346
387
 
@@ -348,27 +389,25 @@ export const Stormchaser: WizardFunction = ({state}) =>
348
389
  // Blink dodges the incoming missile — no GCD after, so can immediately fire back.
349
390
  // Only shield when blink is on cooldown.
350
391
  if (
351
- state.state === 'idle' &&
392
+ status === 'idle' &&
352
393
  urgentThreat &&
353
- state.blinkCooldown === 0 &&
394
+ blinkCooldown === 0 &&
354
395
  urgentThreat.ticksToImpact >= 10 &&
355
396
  urgentThreat.ticksToImpact <= blinkWindowMax
356
397
  )
357
398
  {
358
- return {
359
- move: {x: 0, y: 0},
360
- startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
361
- };
399
+ const blinkTarget = capBlinkDistance(getBlinkToCenter(position));
400
+ return blink(blinkTarget.x, blinkTarget.y);
362
401
  }
363
402
 
364
403
  // === DEFENSE: Shield threats (when blink is on cooldown) ===
365
- if (state.state === 'idle' && urgentThreat)
404
+ if (status === 'idle' && urgentThreat)
366
405
  {
367
406
  const forceShield = shouldForceShield(urgentThreat);
368
407
  const undodgeable = !urgentThreat.bestDodgeDirection;
369
- const healthLow = state.health < healthLowThreshold;
408
+ const healthLow = health < healthLowThreshold;
370
409
  const healthCritical =
371
- state.health <= urgentThreat.projectile.damage * healthDamageRatio + 1;
410
+ health <= urgentThreat.projectile.damage * healthDamageRatio + 1;
372
411
  const shouldShield =
373
412
  forceShield || (undodgeable && healthLow) || healthCritical;
374
413
 
@@ -376,60 +415,66 @@ export const Stormchaser: WizardFunction = ({state}) =>
376
415
  {
377
416
  if (urgentThreat.ticksToStartShield <= 3)
378
417
  {
379
- return {
380
- move: {x: 0, y: 0},
381
- startCast: {spell: 'shield'},
382
- };
418
+ return shield();
383
419
  }
384
420
  }
385
421
  }
386
422
 
423
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
424
+ if (
425
+ status === 'idle' &&
426
+ distance < 300 &&
427
+ enemy.status === 'casting' &&
428
+ enemy.castingSpell === 'missile'
429
+ )
430
+ {
431
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
432
+ const estimatedFlightTicks = distance / 8;
433
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
434
+
435
+ if (
436
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
437
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
438
+ )
439
+ {
440
+ return shield();
441
+ }
442
+ }
443
+
387
444
  // === MOVEMENT: Smart combat movement ===
388
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
445
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
389
446
  const move = combatMove(
390
- state.position,
447
+ position,
391
448
  enemy,
392
449
  distance,
393
450
  targetDistance,
394
451
  dodgeDirection,
395
- state.tick,
452
+ tick,
396
453
  strafeCyclePeriod,
397
454
  strafeIntensity,
398
455
  approachSpeed,
399
456
  distanceDeadZone,
457
+ centerBias,
400
458
  );
401
-
459
+ const safeMove = keepOffWalls(position, move);
402
460
  // === OFFENSE: Attack aggressively while moving ===
403
- if (state.state === 'idle' && distance < attackRange)
461
+ if (status === 'idle' && distance < attackRange)
404
462
  {
463
+ const reposition = getPreCastReposition(position, enemy.position);
464
+ if (reposition) return moveAction(reposition.x, reposition.y);
465
+
405
466
  // Primary: standard homing missile
406
467
  const standardCastTime = getMissileCastTime(STANDARD_CONFIG);
407
468
  if (isSafeToAttack(threats, standardCastTime))
408
469
  {
409
- return {
410
- move,
411
- startCast: {
412
- spell: 'missile',
413
- config: STANDARD_CONFIG,
414
- missileAI: StormchaserMissileAI,
415
- direction: angleTo(state.position, enemy.position),
416
- },
417
- };
470
+ return missile(STANDARD_CONFIG, StormchaserMissileAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
418
471
  }
419
472
 
420
473
  // Fallback: quick missile under pressure (shorter cast = smaller vulnerability window)
421
474
  const quickCastTime = getMissileCastTime(QUICK_CONFIG);
422
475
  if (isSafeToAttack(threats, quickCastTime))
423
476
  {
424
- return {
425
- move,
426
- startCast: {
427
- spell: 'missile',
428
- config: QUICK_CONFIG,
429
- missileAI: StormchaserMissileAI,
430
- direction: angleTo(state.position, enemy.position),
431
- },
432
- };
477
+ return missile(QUICK_CONFIG, StormchaserMissileAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
433
478
  }
434
479
 
435
480
  // Not safe to attack — wait for threat to pass, then fire
@@ -439,19 +484,16 @@ export const Stormchaser: WizardFunction = ({state}) =>
439
484
  // If all offense checks failed and there's an undodgeable threat, shield it
440
485
  // rather than sitting idle and taking the hit.
441
486
  if (
442
- state.state === 'idle' &&
487
+ status === 'idle' &&
443
488
  urgentThreat &&
444
489
  !urgentThreat.bestDodgeDirection &&
445
490
  urgentThreat.canBlockInTime &&
446
491
  urgentThreat.ticksToStartShield <= 3
447
492
  )
448
493
  {
449
- return {
450
- move: {x: 0, y: 0},
451
- startCast: {spell: 'shield'},
452
- };
494
+ return shield();
453
495
  }
454
496
 
455
497
  // Default: just move (dodging/positioning)
456
- return {move};
457
- };
498
+ return moveAction(safeMove.x, safeMove.y);
499
+ }