@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,13 +1,9 @@
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, useProjectiles, 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
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
+ import {RULES, ARENA_MIN, ARENA_MAX} from '../../rules.js';
9
6
  import {
10
- getThreats,
11
7
  getMostUrgentThreat,
12
8
  getDodgeDirectionForMovement,
13
9
  fitMissileToBudget,
@@ -17,6 +13,8 @@ import {
17
13
  MIN_BLINK_ESCAPE_DISTANCE,
18
14
  MAX_CAST_BUDGET,
19
15
  MIN_USEFUL_DAMAGE,
16
+ keepOffWalls,
17
+ getPreCastReposition,
20
18
  } from '../shared.js';
21
19
  import {useParam} from '../../engine/params-runtime.js';
22
20
  const WALL_BUFFER = 60;
@@ -26,24 +24,25 @@ const SHIELD_BUFFER = 3;
26
24
  /**
27
25
  * Standard homing missile AI.
28
26
  */
29
- const StandardHoming: MissileAIFunction = ({worldState}) =>
27
+ const StandardHoming: MissileFunction = () =>
30
28
  {
31
- const enemy = worldState.enemies[0];
32
- return {
33
- turnToward: enemy?.position,
34
- };
29
+ const ctx = getMissileContext();
30
+ const enemy = ctx.worldState.enemies[0];
31
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
35
32
  };
36
33
 
34
+ const StraightAI: MissileFunction = () => flyStraight();
35
+
37
36
  /**
38
37
  * Check if near wall.
39
38
  */
40
39
  function isNearWall(position: {x: number; y: number}): boolean
41
40
  {
42
41
  return (
43
- position.x < WALL_BUFFER ||
44
- position.x > ARENA_SIZE - WALL_BUFFER ||
45
- position.y < WALL_BUFFER ||
46
- position.y > ARENA_SIZE - WALL_BUFFER
42
+ position.x < ARENA_MIN + WALL_BUFFER ||
43
+ position.x > ARENA_MAX - WALL_BUFFER ||
44
+ position.y < ARENA_MIN + WALL_BUFFER ||
45
+ position.y > ARENA_MAX - WALL_BUFFER
47
46
  );
48
47
  }
49
48
 
@@ -61,12 +60,22 @@ function combatMove(
61
60
  strafeIntensity: number,
62
61
  approachSpeed: number,
63
62
  distanceDeadZone: number,
64
- ): {x: number; y: number}
63
+ centerBias: number,
64
+ ): {x: number; y: number}
65
65
  {
66
66
  const deltaX = enemy.position.x - position.x;
67
67
  const deltaY = enemy.position.y - position.y;
68
68
  const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
69
69
 
70
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
71
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
72
+ const toCenterX = center - enemy.position.x;
73
+ const toCenterY = center - enemy.position.y;
74
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
75
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
76
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
77
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
78
+
70
79
  // Perpendicular directions
71
80
  const strafeClockwise = {
72
81
  x: -deltaY / normalizedDistance,
@@ -94,16 +103,16 @@ function combatMove(
94
103
  y: position.y + strafeCounterClockwise.y * 100,
95
104
  };
96
105
  const scoreClockwise = Math.min(
97
- futureClockwise.x,
98
- ARENA_SIZE - futureClockwise.x,
99
- futureClockwise.y,
100
- ARENA_SIZE - futureClockwise.y,
106
+ futureClockwise.x - ARENA_MIN,
107
+ ARENA_MAX - futureClockwise.x,
108
+ futureClockwise.y - ARENA_MIN,
109
+ ARENA_MAX - futureClockwise.y,
101
110
  );
102
111
  const scoreCounterClockwise = Math.min(
103
- futureCounterClockwise.x,
104
- ARENA_SIZE - futureCounterClockwise.x,
105
- futureCounterClockwise.y,
106
- ARENA_SIZE - futureCounterClockwise.y,
112
+ futureCounterClockwise.x - ARENA_MIN,
113
+ ARENA_MAX - futureCounterClockwise.x,
114
+ futureCounterClockwise.y - ARENA_MIN,
115
+ ARENA_MAX - futureCounterClockwise.y,
107
116
  );
108
117
  strafeDirection =
109
118
  scoreClockwise > scoreCounterClockwise
@@ -118,34 +127,34 @@ function combatMove(
118
127
 
119
128
  if (dodgeDirection)
120
129
  {
121
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
130
+ return {x: strafeDirection.x, y: strafeDirection.y};
122
131
  }
123
132
 
124
133
  let moveX = strafeDirection.x * strafeIntensity;
125
134
  let moveY = strafeDirection.y * strafeIntensity;
126
135
 
127
- if (currentDistance > targetDistance + distanceDeadZone)
136
+ if (currentDistance > targetDistance + distanceDeadZone)
128
137
  {
129
- moveX += (deltaX / normalizedDistance) * approachSpeed;
130
- moveY += (deltaY / normalizedDistance) * approachSpeed;
138
+ moveX += (approachDirX / approachMag) * approachSpeed;
139
+ moveY += (approachDirY / approachMag) * approachSpeed;
131
140
  }
132
141
  else if (currentDistance < targetDistance - distanceDeadZone)
133
142
  {
134
143
  let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
135
144
  let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
136
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
137
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
138
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
139
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
145
+ if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
146
+ if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0) retreatX = 0;
147
+ if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
148
+ if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0) retreatY = 0;
140
149
  moveX += retreatX;
141
150
  moveY += retreatY;
142
151
  }
143
152
 
144
153
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
145
- if (magnitude > 100)
154
+ if (magnitude > 1)
146
155
  {
147
- moveX = (moveX / magnitude) * 100;
148
- moveY = (moveY / magnitude) * 100;
156
+ moveX = moveX / magnitude;
157
+ moveY = moveY / magnitude;
149
158
  }
150
159
 
151
160
  return {x: moveX, y: moveY};
@@ -167,36 +176,49 @@ function combatMove(
167
176
  *
168
177
  * TIER: 2 (enhanced Battlemage)
169
178
  */
170
- export const Warmage: WizardFunction = ({state}) =>
179
+ export function Warmage(): FinalAction
171
180
  {
172
- const targetDistance = useParam('targetDistance', 188, {
181
+ const position = usePosition();
182
+ const enemy = useEnemy();
183
+ const status = useStatus();
184
+ const blinkCooldown = useBlinkCooldown();
185
+ const health = useHealth();
186
+ const tick = useTick();
187
+ const threats = useThreats();
188
+ const projectiles = useProjectiles();
189
+ const lastMissileConfig = useLastMissileConfig();
190
+ const castingSpell = useCastingSpell();
191
+ const castProgressData = useCastProgress();
192
+
193
+
194
+ const targetDistance = useParam('targetDistance', 154, {
173
195
  range: 150,
174
196
  min: 0,
175
197
  });
176
- const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, steps: 5});
177
- const blinkEscapeDistance = useParam('blinkEscapeDistance', 42, {
198
+ const minTurnRate = useParam('minTurnRate', 0.8, {range: 1.5, min: 0, steps: 5});
199
+ const blinkEscapeDistance = useParam('blinkEscapeDistance', 220, {
178
200
  range: 190,
179
201
  min: 0,
180
202
  steps: 7,
181
203
  });
182
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 299, {
204
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 136, {
183
205
  range: 75,
184
206
  min: 80,
185
207
  max: 300,
186
208
  });
187
- const strafeIntensity = useParam('strafeIntensity', 33, {
188
- range: 40,
189
- min: 20,
190
- max: 100,
209
+ const strafeIntensity = useParam('strafeIntensity', 0.64, {
210
+ range: 0.4,
211
+ min: 0.2,
212
+ max: 1,
191
213
  steps: 7,
192
214
  });
193
- const approachSpeed = useParam('approachSpeed', 80, {
194
- range: 30,
195
- min: 10,
196
- max: 80,
215
+ const approachSpeed = useParam('approachSpeed', 0.74, {
216
+ range: 0.3,
217
+ min: 0.1,
218
+ max: 0.8,
197
219
  steps: 7,
198
220
  });
199
- const distanceDeadZone = useParam('distanceDeadZone', 33, {
221
+ const distanceDeadZone = useParam('distanceDeadZone', 29, {
200
222
  range: 30,
201
223
  min: 10,
202
224
  max: 80,
@@ -207,7 +229,7 @@ export const Warmage: WizardFunction = ({state}) =>
207
229
  min: 50,
208
230
  max: 300,
209
231
  });
210
- const blinkWindowMax = useParam('blinkWindowMax', 37, {
232
+ const blinkWindowMax = useParam('blinkWindowMax', 32, {
211
233
  range: 30,
212
234
  min: 15,
213
235
  max: 80,
@@ -219,72 +241,89 @@ export const Warmage: WizardFunction = ({state}) =>
219
241
  max: 100,
220
242
  steps: 7,
221
243
  });
222
- const attackRange = useParam('attackRange', 300, {
244
+ const attackRange = useParam('attackRange', 425, {
223
245
  range: 150,
224
246
  min: 200,
225
247
  max: 700,
226
248
  });
227
- const flightTimeDivisor = useParam('flightTimeDivisor', 9.5, {
249
+ const flightTimeDivisor = useParam('flightTimeDivisor', 7, {
228
250
  range: 4,
229
251
  min: 2,
230
252
  max: 12,
231
253
  steps: 5,
232
254
  });
255
+ const centerBias = useParam('centerBias', 0.57, {
256
+ range: 0.4,
257
+ min: 0,
258
+ max: 0.8,
259
+ steps: 5,
260
+ });
261
+ const maxBlinkDistance = useParam('maxBlinkDistance', 224, {
262
+ range: 100,
263
+ min: 50,
264
+ max: 300,
265
+ steps: 7,
266
+ });
267
+
268
+ const distance = distanceTo(position, enemy.position);
233
269
 
234
- const enemy = state.enemies[0];
235
- if (!enemy)
270
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
236
271
  {
237
- return {move: {x: 0, y: 0}};
272
+ const dx = target.x - position.x;
273
+ const dy = target.y - position.y;
274
+ const dist = Math.sqrt(dx * dx + dy * dy);
275
+ if (dist <= maxBlinkDistance) return target;
276
+ const scale = maxBlinkDistance / dist;
277
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
238
278
  }
239
279
 
240
- const distance = distanceTo(state.position, enemy.position);
241
-
242
280
  // Analyze threats using simulation
243
- const threats = getThreats(state);
244
281
  const urgentThreat = getMostUrgentThreat(threats);
245
282
 
246
283
  // === DEFENSE: Handle channeling ===
247
- if (state.state === 'channeling')
284
+ if (status === 'channeling')
248
285
  {
249
286
  if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
250
287
  {
251
- return {move: {x: 0, y: 0}, cancel: true};
288
+ return cancel();
252
289
  }
253
- return {move: {x: 0, y: 0}};
290
+ return idle();
254
291
  }
255
292
 
256
293
  // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
257
294
  // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
258
295
  if (
259
- state.state === 'casting' &&
260
- state.castingSpell === 'missile' &&
296
+ status === 'casting' &&
297
+ castingSpell === 'missile' &&
261
298
  urgentThreat &&
262
- urgentThreat.projectile.damage >= state.health
299
+ urgentThreat.projectile.damage >= health
263
300
  )
264
301
  {
265
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
302
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
266
303
  if (
267
304
  urgentThreat.ticksToImpact <=
268
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
305
+ remainingCast + RULES.GCD_DURATION + RULES.SHIELD_CAST_TIME + 1
269
306
  )
270
307
  {
271
308
  const dodgeDirection = getDodgeDirectionForMovement(
272
309
  threats,
273
- state.position,
310
+ position,
274
311
  );
275
- const move = combatMove(
276
- state.position,
312
+ const cancelMove = combatMove(
313
+ position,
277
314
  enemy,
278
315
  distance,
279
316
  targetDistance,
280
317
  dodgeDirection,
281
- state.tick,
318
+ tick,
282
319
  strafeCyclePeriod,
283
320
  strafeIntensity,
284
321
  approachSpeed,
285
322
  distanceDeadZone,
323
+ centerBias,
286
324
  );
287
- return {move, cancel: true};
325
+ const safeCancelMove = keepOffWalls(position, cancelMove);
326
+ return cancel().move(safeCancelMove.x, safeCancelMove.y);
288
327
  }
289
328
  }
290
329
 
@@ -292,53 +331,49 @@ export const Warmage: WizardFunction = ({state}) =>
292
331
  // Blink both dodges the incoming missile AND closes distance — better than shielding.
293
332
  // Only shield when blink is on cooldown.
294
333
  if (
295
- state.state === 'idle' &&
334
+ status === 'idle' &&
296
335
  urgentThreat &&
297
- state.blinkCooldown === 0 &&
336
+ blinkCooldown === 0 &&
298
337
  distance > targetDistance - blinkDistanceOffset &&
299
338
  urgentThreat.ticksToImpact >= 10 &&
300
339
  urgentThreat.ticksToImpact <= blinkWindowMax
301
340
  )
302
341
  {
303
- const blinkTarget =
342
+ const rawBlinkTarget =
304
343
  getBlinkToDecreaseDistance(
305
- state.position,
344
+ position,
306
345
  enemy.position,
307
- state.projectiles,
346
+ projectiles,
308
347
  ) ?? enemy.position;
309
- return {
310
- move: {x: 0, y: 0},
311
- startCast: {spell: 'blink', target: blinkTarget},
312
- };
348
+ const blinkTarget = capBlinkDistance(rawBlinkTarget);
349
+ return blink(blinkTarget.x, blinkTarget.y);
313
350
  }
314
351
 
315
352
  // === BLINK ESCAPE: Enemy too close, blink away to reestablish dueling range ===
316
353
  // Warmage is a duelist at 280 range — if enemy closes to melee, blink out.
317
354
  if (
318
- state.state === 'idle' &&
355
+ status === 'idle' &&
319
356
  distance < blinkEscapeDistance &&
320
- state.blinkCooldown === 0 &&
357
+ blinkCooldown === 0 &&
321
358
  (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
322
359
  )
323
360
  {
324
361
  const escapeTarget = getBlinkToIncreaseDistance(
325
- state.position,
362
+ position,
326
363
  enemy.position,
327
- state.projectiles,
364
+ projectiles,
328
365
  MIN_BLINK_ESCAPE_DISTANCE,
329
366
  );
330
- if (escapeTarget)
367
+ if (escapeTarget)
331
368
  {
332
- return {
333
- move: {x: 0, y: 0},
334
- startCast: {spell: 'blink', target: escapeTarget},
335
- };
369
+ const capped = capBlinkDistance(escapeTarget);
370
+ return blink(capped.x, capped.y);
336
371
  }
337
372
  }
338
373
 
339
374
  // === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
340
375
  if (
341
- state.state === 'idle' &&
376
+ status === 'idle' &&
342
377
  urgentThreat &&
343
378
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
344
379
  )
@@ -346,38 +381,60 @@ export const Warmage: WizardFunction = ({state}) =>
346
381
  if (
347
382
  urgentThreat.canBlockInTime &&
348
383
  urgentThreat.ticksToStartShield <= 3 &&
349
- (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
384
+ (blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
350
385
  )
351
386
  {
352
- return {
353
- move: {x: 0, y: 0},
354
- startCast: {spell: 'shield'},
355
- };
387
+ return shield();
388
+ }
389
+ }
390
+
391
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
392
+ if (
393
+ status === 'idle' &&
394
+ distance < 300 &&
395
+ enemy.status === 'casting' &&
396
+ enemy.castingSpell === 'missile'
397
+ )
398
+ {
399
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
400
+ const estimatedFlightTicks = distance / 8;
401
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
402
+
403
+ if (
404
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
405
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
406
+ )
407
+ {
408
+ return shield();
356
409
  }
357
410
  }
358
411
 
359
412
  // Movement
360
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
413
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
361
414
  const move = combatMove(
362
- state.position,
415
+ position,
363
416
  enemy,
364
417
  distance,
365
418
  targetDistance,
366
419
  dodgeDirection,
367
- state.tick,
420
+ tick,
368
421
  strafeCyclePeriod,
369
422
  strafeIntensity,
370
423
  approachSpeed,
371
424
  distanceDeadZone,
425
+ centerBias,
372
426
  );
373
-
427
+ const safeMove = keepOffWalls(position, move);
374
428
  // === OFFENSE: Adaptive missile fitting ===
375
- if (state.state === 'idle' && distance < attackRange)
429
+ if (status === 'idle' && distance < attackRange)
376
430
  {
431
+ const reposition = getPreCastReposition(position, enemy.position);
432
+ if (reposition) return moveAction(reposition.x, reposition.y);
433
+
377
434
  let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
378
435
 
379
436
  // Account for enemy's active cast — their missile will arrive after cast completes + flight time
380
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
437
+ if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
381
438
  {
382
439
  const remainingEnemyCast =
383
440
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
@@ -389,7 +446,7 @@ export const Warmage: WizardFunction = ({state}) =>
389
446
  }
390
447
 
391
448
  const safeBudget =
392
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
449
+ nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
393
450
  const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
394
451
 
395
452
  // Warmage moves while casting, so always need homing to compensate
@@ -398,21 +455,12 @@ export const Warmage: WizardFunction = ({state}) =>
398
455
  const config = fitMissileToBudget(budgetTicks, distance, {
399
456
  minTurnRate,
400
457
  maxDamage: enemyHP,
401
- lastMissileConfig: state.lastMissileConfig,
458
+ lastMissileConfig: lastMissileConfig,
402
459
  });
403
460
 
404
461
  if (config && config.damage >= minDmg)
405
462
  {
406
- return {
407
- move,
408
- startCast: {
409
- spell: 'missile',
410
- config,
411
- missileAI:
412
- config.turnRate > 0 ? StandardHoming : (): MissileActions => ({}),
413
- direction: angleTo(state.position, enemy.position),
414
- },
415
- };
463
+ return missile(config, config.turnRate > 0 ? StandardHoming : StraightAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
416
464
  }
417
465
 
418
466
  // Budget too small for useful missile — wait for threat to pass, then fire
@@ -421,18 +469,15 @@ export const Warmage: WizardFunction = ({state}) =>
421
469
  // === FALLBACK DEFENSE: Shield when no attack window exists ===
422
470
  // If all offense checks failed and there's an undodgeable threat, shield it.
423
471
  if (
424
- state.state === 'idle' &&
472
+ status === 'idle' &&
425
473
  urgentThreat &&
426
474
  !urgentThreat.bestDodgeDirection &&
427
475
  urgentThreat.canBlockInTime &&
428
476
  urgentThreat.ticksToStartShield <= 3
429
477
  )
430
478
  {
431
- return {
432
- move: {x: 0, y: 0},
433
- startCast: {spell: 'shield'},
434
- };
479
+ return shield();
435
480
  }
436
481
 
437
- return {move};
438
- };
482
+ return moveAction(safeMove.x, safeMove.y);
483
+ }