@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,10 +1,10 @@
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, useProjectiles, useMyProjectiles, useLastMissileConfig, 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
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
+ import {RULES, ARENA_MIN, ARENA_MAX} from '../../rules.js';
5
6
  import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
6
7
  import {
7
- getThreats,
8
8
  getMostUrgentThreat,
9
9
  getDodgeDirectionForMovement,
10
10
  getBlinkToCenter,
@@ -15,6 +15,8 @@ import {
15
15
  MAX_CAST_BUDGET,
16
16
  MIN_USEFUL_DAMAGE,
17
17
  getEnemyVulnerabilityTicks,
18
+ keepOffWalls,
19
+ getPreCastReposition,
18
20
  } from '../shared.js';
19
21
  import {useParam} from '../../engine/params-runtime.js';
20
22
  const WALL_BUFFER = 60;
@@ -24,18 +26,17 @@ const SHIELD_BUFFER = 3;
24
26
  /**
25
27
  * Standard homing missile AI.
26
28
  */
27
- const HomingAI: MissileAIFunction = ({worldState}) =>
29
+ const HomingAI: MissileFunction = () =>
28
30
  {
29
- const enemy = worldState.enemies[0];
30
- return {
31
- turnToward: enemy?.position,
32
- };
31
+ const ctx = getMissileContext();
32
+ const enemy = ctx.worldState.enemies[0];
33
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
33
34
  };
34
35
 
35
36
  /**
36
37
  * Straight missile AI — no homing, flies in initial direction.
37
38
  */
38
- const StraightAI: MissileAIFunction = () => ({});
39
+ const StraightAI: MissileFunction = () => flyStraight();
39
40
 
40
41
  /**
41
42
  * Check if near wall.
@@ -43,10 +44,10 @@ const StraightAI: MissileAIFunction = () => ({});
43
44
  function isNearWall(position: {x: number; y: number}): boolean
44
45
  {
45
46
  return (
46
- position.x < WALL_BUFFER ||
47
- position.x > ARENA_SIZE - WALL_BUFFER ||
48
- position.y < WALL_BUFFER ||
49
- position.y > ARENA_SIZE - WALL_BUFFER
47
+ position.x < ARENA_MIN + WALL_BUFFER ||
48
+ position.x > ARENA_MAX - WALL_BUFFER ||
49
+ position.y < ARENA_MIN + WALL_BUFFER ||
50
+ position.y > ARENA_MAX - WALL_BUFFER
50
51
  );
51
52
  }
52
53
 
@@ -93,16 +94,16 @@ function smartStrafe(
93
94
  y: position.y + strafeCounterClockwise.y * 100,
94
95
  };
95
96
  const scoreClockwise = Math.min(
96
- futureClockwise.x,
97
- ARENA_SIZE - futureClockwise.x,
98
- futureClockwise.y,
99
- ARENA_SIZE - futureClockwise.y,
97
+ futureClockwise.x - ARENA_MIN,
98
+ ARENA_MAX - futureClockwise.x,
99
+ futureClockwise.y - ARENA_MIN,
100
+ ARENA_MAX - futureClockwise.y,
100
101
  );
101
102
  const scoreCounterClockwise = Math.min(
102
- futureCounterClockwise.x,
103
- ARENA_SIZE - futureCounterClockwise.x,
104
- futureCounterClockwise.y,
105
- ARENA_SIZE - futureCounterClockwise.y,
103
+ futureCounterClockwise.x - ARENA_MIN,
104
+ ARENA_MAX - futureCounterClockwise.x,
105
+ futureCounterClockwise.y - ARENA_MIN,
106
+ ARENA_MAX - futureCounterClockwise.y,
106
107
  );
107
108
  strafeDir =
108
109
  scoreClockwise > scoreCounterClockwise
@@ -115,7 +116,7 @@ function smartStrafe(
115
116
  strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
116
117
  }
117
118
 
118
- const intensity = dodgeDirection ? 100 : strafeIntensity;
119
+ const intensity = dodgeDirection ? 1 : strafeIntensity;
119
120
  return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
120
121
  }
121
122
 
@@ -141,35 +142,48 @@ function smartStrafe(
141
142
  *
142
143
  * TIER: 3 (elite Spellspinner line)
143
144
  */
144
- export const Spellbinder: WizardFunction = ({state}) =>
145
+ export function Spellbinder(): FinalAction
145
146
  {
146
- const targetDistance = useParam('targetDistance', 134, {
147
+ const position = usePosition();
148
+ const enemy = useEnemy();
149
+ const status = useStatus();
150
+ const blinkCooldown = useBlinkCooldown();
151
+ const health = useHealth();
152
+ const tick = useTick();
153
+ const threats = useThreats();
154
+ const projectiles = useProjectiles();
155
+ const myProjectiles = useMyProjectiles();
156
+ const lastMissileConfig = useLastMissileConfig();
157
+ const castingSpell = useCastingSpell();
158
+ const castProgressData = useCastProgress();
159
+
160
+
161
+ const targetDistance = useParam('targetDistance', 197, {
147
162
  range: 150,
148
163
  min: 0,
149
164
  });
150
- const dangerDistance = useParam('dangerDistance', 389, {
165
+ const dangerDistance = useParam('dangerDistance', 171, {
151
166
  range: 125,
152
167
  min: 0,
153
168
  });
154
- const minTurnRate = useParam('minTurnRate', 0.2, {range: 1.5, steps: 5});
155
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 123, {
169
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 179, {
156
170
  range: 75,
157
171
  min: 80,
158
172
  max: 300,
159
173
  });
160
- const strafeIntensity = useParam('strafeIntensity', 30, {
161
- range: 40,
162
- min: 30,
163
- max: 100,
174
+ const strafeIntensity = useParam('strafeIntensity', 1, {
175
+ range: 0.4,
176
+ min: 0.3,
177
+ max: 1,
164
178
  steps: 7,
165
179
  });
166
- const approachSpeed = useParam('approachSpeed', 50, {
167
- range: 30,
168
- min: 10,
169
- max: 70,
180
+ const approachSpeed = useParam('approachSpeed', 0.4, {
181
+ range: 0.3,
182
+ min: 0.1,
183
+ max: 0.7,
170
184
  steps: 7,
171
185
  });
172
- const distanceDeadZone = useParam('distanceDeadZone', 7, {
186
+ const distanceDeadZone = useParam('distanceDeadZone', 71, {
173
187
  range: 30,
174
188
  min: 5,
175
189
  max: 80,
@@ -180,37 +194,52 @@ export const Spellbinder: WizardFunction = ({state}) =>
180
194
  min: 50,
181
195
  max: 300,
182
196
  });
183
- const flightTimeDivisor = useParam('flightTimeDivisor', 10.8, {
197
+ const flightTimeDivisor = useParam('flightTimeDivisor', 10, {
184
198
  range: 4,
185
199
  min: 2,
186
200
  max: 12,
187
201
  steps: 5,
188
202
  });
189
- const punishMinVulnerability = useParam('punishMinVulnerability', 30, {
203
+ const punishMinVulnerability = useParam('punishMinVulnerability', 91, {
190
204
  range: 80,
191
205
  min: 30,
192
206
  steps: 7,
193
207
  });
208
+ const centerBias = useParam('centerBias', 0.39, {
209
+ range: 0.4,
210
+ min: 0,
211
+ max: 0.8,
212
+ steps: 5,
213
+ });
214
+ const maxBlinkDistance = useParam('maxBlinkDistance', 258, {
215
+ range: 100,
216
+ min: 50,
217
+ max: 300,
218
+ steps: 7,
219
+ });
194
220
 
195
- const enemy = state.enemies[0];
196
- if (!enemy)
221
+ const distance = distanceTo(position, enemy.position);
222
+
223
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
197
224
  {
198
- return {move: {x: 0, y: 0}};
225
+ const dx = target.x - position.x;
226
+ const dy = target.y - position.y;
227
+ const dist = Math.sqrt(dx * dx + dy * dy);
228
+ if (dist <= maxBlinkDistance) return target;
229
+ const scale = maxBlinkDistance / dist;
230
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
199
231
  }
200
232
 
201
- const distance = distanceTo(state.position, enemy.position);
202
-
203
233
  // Analyze threats using simulation
204
- const threats = getThreats(state);
205
234
  const urgentThreat = getMostUrgentThreat(threats);
206
235
 
207
236
  // Smart strafe
208
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
237
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
209
238
  const strafe = smartStrafe(
210
- state.position,
239
+ position,
211
240
  enemy,
212
241
  dodgeDirection,
213
- state.tick,
242
+ tick,
214
243
  strafeCyclePeriod,
215
244
  strafeIntensity,
216
245
  );
@@ -221,24 +250,32 @@ export const Spellbinder: WizardFunction = ({state}) =>
221
250
 
222
251
  if (!dodgeDirection)
223
252
  {
224
- const deltaX = enemy.position.x - state.position.x;
225
- const deltaY = enemy.position.y - state.position.y;
253
+ const deltaX = enemy.position.x - position.x;
254
+ const deltaY = enemy.position.y - position.y;
226
255
  const normalizedDistance = distance > 0 ? distance : 1;
227
256
 
228
257
  if (distance > targetDistance + distanceDeadZone)
229
258
  {
230
- moveX += (deltaX / normalizedDistance) * approachSpeed;
231
- moveY += (deltaY / normalizedDistance) * approachSpeed;
259
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
260
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
261
+ const toCenterX = center - enemy.position.x;
262
+ const toCenterY = center - enemy.position.y;
263
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
264
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
265
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
266
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
267
+ moveX += (approachDirX / approachMag) * approachSpeed;
268
+ moveY += (approachDirY / approachMag) * approachSpeed;
232
269
  }
233
270
  else if (distance < targetDistance - distanceDeadZone)
234
271
  {
235
272
  let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
236
273
  let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
237
- if (state.position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
238
- if (state.position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0)
274
+ if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
275
+ if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0)
239
276
  retreatX = 0;
240
- if (state.position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
241
- if (state.position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0)
277
+ if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
278
+ if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0)
242
279
  retreatY = 0;
243
280
  moveX += retreatX;
244
281
  moveY += retreatY;
@@ -246,95 +283,119 @@ export const Spellbinder: WizardFunction = ({state}) =>
246
283
  }
247
284
 
248
285
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
249
- if (magnitude > 100)
286
+ if (magnitude > 1)
250
287
  {
251
- moveX = (moveX / magnitude) * 100;
252
- moveY = (moveY / magnitude) * 100;
288
+ moveX = moveX / magnitude;
289
+ moveY = moveY / magnitude;
253
290
  }
254
291
 
255
292
  const move = {x: moveX, y: moveY};
293
+ const safeMove = keepOffWalls(position, move);
256
294
 
257
295
  // === DEFENSE: Handle channeling ===
258
- if (state.state === 'channeling')
296
+ if (status === 'channeling')
259
297
  {
260
298
  if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
261
299
  {
262
- return {move, cancel: true};
300
+ return cancel().move(safeMove.x, safeMove.y);
263
301
  }
264
- return {move: {x: 0, y: 0}};
302
+ return idle();
265
303
  }
266
304
 
267
305
  // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
268
306
  // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
269
307
  if (
270
- state.state === 'casting' &&
271
- state.castingSpell === 'missile' &&
308
+ status === 'casting' &&
309
+ castingSpell === 'missile' &&
272
310
  urgentThreat &&
273
- urgentThreat.projectile.damage >= state.health
311
+ urgentThreat.projectile.damage >= health
274
312
  )
275
313
  {
276
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
314
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
277
315
  if (
278
316
  urgentThreat.ticksToImpact <=
279
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
317
+ remainingCast + RULES.GCD_DURATION + RULES.SHIELD_CAST_TIME + SHIELD_BUFFER
280
318
  )
281
319
  {
282
- return {move, cancel: true};
320
+ return cancel().move(safeMove.x, safeMove.y);
283
321
  }
284
322
  }
285
323
 
286
324
  // === DEFENSE: Shield undodgeable or high-damage homing threats ===
287
325
  if (
288
- state.state === 'idle' &&
326
+ status === 'idle' &&
289
327
  urgentThreat &&
290
328
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
291
329
  )
292
330
  {
331
+ if (urgentThreat.canBlockInTime && urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
332
+ {
333
+ return shield();
334
+ }
335
+ if (blinkCooldown === 0)
336
+ {
337
+ const blinkTarget = capBlinkDistance(getBlinkToCenter(position));
338
+ return blink(blinkTarget.x, blinkTarget.y);
339
+ }
293
340
  if (urgentThreat.canBlockInTime)
294
341
  {
295
- if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
296
- {
297
- return {
298
- move: {x: 0, y: 0},
299
- startCast: {spell: 'shield'},
300
- };
301
- }
342
+ return shield();
302
343
  }
303
- else if (state.blinkCooldown === 0)
344
+ }
345
+
346
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
347
+ if (
348
+ status === 'idle' &&
349
+ distance < 300 &&
350
+ enemy.status === 'casting' &&
351
+ enemy.castingSpell === 'missile'
352
+ )
353
+ {
354
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
355
+ const estimatedFlightTicks = distance / 8;
356
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
357
+
358
+ if (
359
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
360
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
361
+ )
304
362
  {
305
- return {
306
- move: {x: 0, y: 0},
307
- startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
308
- };
363
+ return shield();
309
364
  }
310
365
  }
311
366
 
312
367
  // === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
313
368
  if (
314
- state.state === 'idle' &&
369
+ status === 'idle' &&
315
370
  distance < dangerDistance &&
316
- state.blinkCooldown === 0 &&
371
+ blinkCooldown === 0 &&
317
372
  (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
318
373
  )
319
374
  {
320
375
  const blinkTarget = getBlinkToIncreaseDistance(
321
- state.position,
376
+ position,
322
377
  enemy.position,
323
- state.projectiles,
378
+ projectiles,
324
379
  MIN_BLINK_ESCAPE_DISTANCE,
325
380
  );
326
381
  if (blinkTarget)
327
382
  {
328
- return {
329
- move: {x: 0, y: 0},
330
- startCast: {spell: 'blink', target: blinkTarget},
331
- };
383
+ const capped = capBlinkDistance(blinkTarget);
384
+ return blink(capped.x, capped.y);
332
385
  }
333
386
  }
334
387
 
388
+ // Pre-cast wall discipline: don't commit to a cast we can't react out of while
389
+ // pinned near a wall — step toward center first so knockback can't reach lava.
390
+ if (status === 'idle')
391
+ {
392
+ const reposition = getPreCastReposition(position, enemy.position);
393
+ if (reposition) return moveAction(reposition.x, reposition.y);
394
+ }
395
+
335
396
  // === OFFENSE: Punish mode — fast straight missiles during enemy vulnerability ===
336
397
  if (
337
- state.state === 'idle' &&
398
+ status === 'idle' &&
338
399
  getEnemyVulnerabilityTicks(enemy) >= punishMinVulnerability &&
339
400
  distance < 500
340
401
  )
@@ -344,7 +405,7 @@ export const Spellbinder: WizardFunction = ({state}) =>
344
405
  const punishBudget = vulnerabilityTicks - flightTimeEstimate;
345
406
  const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
346
407
  const safeBudget =
347
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
408
+ nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
348
409
  const effectiveBudget = Math.min(
349
410
  punishBudget,
350
411
  safeBudget,
@@ -357,12 +418,12 @@ export const Spellbinder: WizardFunction = ({state}) =>
357
418
  const config = fitMissileToBudget(effectiveBudget, distance, {
358
419
  minTurnRate: 0,
359
420
  maxDamage: enemyHP,
360
- lastMissileConfig: state.lastMissileConfig,
421
+ lastMissileConfig: lastMissileConfig,
361
422
  });
362
423
 
363
424
  if (config && config.damage >= MIN_USEFUL_DAMAGE)
364
425
  {
365
- const castTime = getMissileCastTime(config, state.lastMissileConfig);
426
+ const castTime = getMissileCastTime(config, lastMissileConfig);
366
427
  const actualFlight = distance / config.speed;
367
428
 
368
429
  if (castTime + actualFlight < vulnerabilityTicks)
@@ -374,17 +435,9 @@ export const Spellbinder: WizardFunction = ({state}) =>
374
435
  enemy.position,
375
436
  enemy.velocity,
376
437
  config.speed,
377
- state.position,
438
+ position,
378
439
  );
379
- return {
380
- move,
381
- startCast: {
382
- spell: 'missile',
383
- config,
384
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
385
- direction: angleTo(state.position, aimTarget),
386
- },
387
- };
440
+ return missile(config, config.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, aimTarget)).move(safeMove.x, safeMove.y);
388
441
  }
389
442
  }
390
443
  }
@@ -392,20 +445,20 @@ export const Spellbinder: WizardFunction = ({state}) =>
392
445
 
393
446
  // === OFFENSE: Adaptive missile fitting (identical to Spellweaver) ===
394
447
  // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
395
- const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
396
- const hasNearbyLethalMissile = state.projectiles.some(
448
+ const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
449
+ const hasNearbyLethalMissile = projectiles.some(
397
450
  (p) =>
398
451
  !myProjectileIds.has(p.id) &&
399
- p.damage >= state.health &&
400
- distanceTo(state.position, p.position) / p.speed <
401
- MAX_CAST_BUDGET + GCD_DURATION,
452
+ p.damage >= health &&
453
+ distanceTo(position, p.position) / p.speed <
454
+ MAX_CAST_BUDGET + RULES.GCD_DURATION,
402
455
  );
403
- if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
456
+ if (status === 'idle' && distance < 600 && !hasNearbyLethalMissile)
404
457
  {
405
458
  let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
406
459
 
407
460
  // Account for enemy's active cast
408
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
461
+ if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
409
462
  {
410
463
  const remainingEnemyCast =
411
464
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
@@ -416,33 +469,25 @@ export const Spellbinder: WizardFunction = ({state}) =>
416
469
  );
417
470
  }
418
471
  const safeBudget =
419
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
472
+ nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
420
473
  const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
421
474
 
422
475
  // Spellbinder always uses homing in standard mode (kiting means enemies are always moving)
423
476
  const enemyHP = Math.ceil(enemy.health);
424
477
  const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
425
478
  const config = fitMissileToBudget(budgetTicks, distance, {
426
- minTurnRate,
479
+ minTurnRate: 0,
427
480
  maxDamage: enemyHP,
428
- lastMissileConfig: state.lastMissileConfig,
481
+ lastMissileConfig: lastMissileConfig,
429
482
  });
430
483
 
431
484
  if (config && config.damage >= minDmg)
432
485
  {
433
- return {
434
- move,
435
- startCast: {
436
- spell: 'missile',
437
- config,
438
- missileAI: HomingAI,
439
- direction: angleTo(state.position, enemy.position),
440
- },
441
- };
486
+ return missile(config, HomingAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
442
487
  }
443
488
 
444
489
  // Budget too small for useful missile — wait for threat to pass, then fire
445
490
  }
446
491
 
447
- return {move};
448
- };
492
+ return moveAction(safeMove.x, safeMove.y);
493
+ }