@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,13 @@
1
- import {WizardFunction, MissileAIFunction} from '../../types.js';
1
+ import type {FinalAction, MissileFunction} from '../../hooks/index.js';
2
+ import {usePosition, useEnemy, useStatus, idle, missile, move, getMissileContext, turnToward, flyStraight} from '../../hooks/index.js';
2
3
  import {angleTo} from '../../utils/angles.js';
4
+ import {keepOffWalls} from '../shared.js';
3
5
 
4
- const SimpleHoming: MissileAIFunction = ({worldState}) =>
6
+ const SimpleHoming: MissileFunction = () =>
5
7
  {
6
- // Note: random is available but not used by this simple homing AI
7
- const enemy = worldState.enemies[0];
8
- return {
9
- turnToward: enemy?.position,
10
- };
8
+ const ctx = getMissileContext();
9
+ const enemy = ctx.worldState.enemies[0];
10
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
11
11
  };
12
12
 
13
13
  /**
@@ -35,33 +35,21 @@ const SimpleHoming: MissileAIFunction = ({worldState}) =>
35
35
  *
36
36
  * TIER: 1 (base)
37
37
  */
38
- export const Bonemancer: WizardFunction = ({state}) =>
38
+ export function Bonemancer(): FinalAction
39
39
  {
40
- const enemy = state.enemies[0];
41
- if (!enemy)
42
- {
43
- return {move: {x: 0, y: 0}};
44
- }
40
+ const position = usePosition();
41
+ const enemy = useEnemy();
42
+ const status = useStatus();
45
43
 
46
- if (state.state === 'idle')
44
+
45
+ if (status === 'idle')
47
46
  {
48
- return {
49
- move: {x: 0, y: 0},
50
- startCast: {
51
- spell: 'missile',
52
- config: {
53
- damage: 12,
54
- speed: 3,
55
- turnRate: 2,
56
- duration: 300,
57
- },
58
- missileAI: SimpleHoming,
59
- direction: angleTo(state.position, enemy.position),
60
- },
61
- };
47
+ return missile(
48
+ {damage: 12, speed: 3, turnRate: 2, duration: 300},
49
+ SimpleHoming,
50
+ angleTo(position, enemy.position),
51
+ ).move(keepOffWalls(position).x, keepOffWalls(position).y);
62
52
  }
63
53
 
64
- return {
65
- move: {x: 0, y: 0},
66
- };
67
- };
54
+ { const h = keepOffWalls(position); return (h.x !== 0 || h.y !== 0) ? move(h.x, h.y) : idle(); }
55
+ }
@@ -1,9 +1,9 @@
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 {
6
- getThreats,
7
7
  getMostUrgentThreat,
8
8
  getDodgeDirectionForMovement,
9
9
  getBlinkToCenter,
@@ -11,6 +11,8 @@ import {
11
11
  shouldForceShield,
12
12
  MAX_CAST_BUDGET,
13
13
  MIN_USEFUL_DAMAGE,
14
+ keepOffWalls,
15
+ getPreCastReposition,
14
16
  } from '../shared.js';
15
17
  import {useParam} from '../../engine/params-runtime.js';
16
18
 
@@ -18,19 +20,20 @@ const WALL_BUFFER = 60;
18
20
 
19
21
  const SHIELD_BUFFER = 3;
20
22
 
21
- const HomingAI: MissileAIFunction = ({worldState}) =>
23
+ const HomingAI: MissileFunction = () =>
22
24
  {
23
- const enemy = worldState.enemies[0];
24
- return {turnToward: enemy?.position};
25
+ const ctx = getMissileContext();
26
+ const enemy = ctx.worldState.enemies[0];
27
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
25
28
  };
26
29
 
27
30
  function isNearWall(position: {x: number; y: number}): boolean
28
31
  {
29
32
  return (
30
- position.x < WALL_BUFFER ||
31
- position.x > ARENA_SIZE - WALL_BUFFER ||
32
- position.y < WALL_BUFFER ||
33
- position.y > ARENA_SIZE - WALL_BUFFER
33
+ position.x < ARENA_MIN + WALL_BUFFER ||
34
+ position.x > ARENA_MAX - WALL_BUFFER ||
35
+ position.y < ARENA_MIN + WALL_BUFFER ||
36
+ position.y > ARENA_MAX - WALL_BUFFER
34
37
  );
35
38
  }
36
39
 
@@ -74,16 +77,16 @@ function smartStrafe(
74
77
  y: position.y + strafeCounterClockwise.y * 100,
75
78
  };
76
79
  const scoreClockwise = Math.min(
77
- futureClockwise.x,
78
- ARENA_SIZE - futureClockwise.x,
79
- futureClockwise.y,
80
- ARENA_SIZE - futureClockwise.y,
80
+ futureClockwise.x - ARENA_MIN,
81
+ ARENA_MAX - futureClockwise.x,
82
+ futureClockwise.y - ARENA_MIN,
83
+ ARENA_MAX - futureClockwise.y,
81
84
  );
82
85
  const scoreCounterClockwise = Math.min(
83
- futureCounterClockwise.x,
84
- ARENA_SIZE - futureCounterClockwise.x,
85
- futureCounterClockwise.y,
86
- ARENA_SIZE - futureCounterClockwise.y,
86
+ futureCounterClockwise.x - ARENA_MIN,
87
+ ARENA_MAX - futureCounterClockwise.x,
88
+ futureCounterClockwise.y - ARENA_MIN,
89
+ ARENA_MAX - futureCounterClockwise.y,
87
90
  );
88
91
  strafeDir =
89
92
  scoreClockwise > scoreCounterClockwise
@@ -96,7 +99,7 @@ function smartStrafe(
96
99
  strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
97
100
  }
98
101
 
99
- const intensity = dodgeDirection ? 100 : strafeIntensity;
102
+ const intensity = dodgeDirection ? 1 : strafeIntensity;
100
103
  return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
101
104
  }
102
105
 
@@ -122,36 +125,50 @@ function smartStrafe(
122
125
  *
123
126
  * TIER: 2 (enhanced Bonemancer)
124
127
  */
125
- export const Lich: WizardFunction = ({state}) =>
128
+ export function Lich(): FinalAction
126
129
  {
127
- const targetDistance = useParam('targetDistance', 768, {
130
+ const position = usePosition();
131
+ const enemy = useEnemy();
132
+ const status = useStatus();
133
+ const blinkCooldown = useBlinkCooldown();
134
+ const health = useHealth();
135
+ const tick = useTick();
136
+ const threats = useThreats();
137
+ const projectiles = useProjectiles();
138
+ const myProjectiles = useMyProjectiles();
139
+ const lastMissileConfig = useLastMissileConfig();
140
+ const castingSpell = useCastingSpell();
141
+ const castProgressData = useCastProgress();
142
+
143
+
144
+ const targetDistance = useParam('targetDistance', 254, {
128
145
  range: 175,
129
146
  min: 0,
130
147
  });
131
- const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, steps: 5});
148
+ const minTurnRate = useParam('minTurnRate', 0.2, {range: 1.5, steps: 5});
132
149
  const shieldHealthThreshold = useParam('shieldHealthThreshold', 99, {
133
150
  range: 23,
134
151
  min: 1,
135
152
  steps: 7,
136
153
  });
137
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 138, {
154
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 132, {
138
155
  range: 75,
139
156
  min: 80,
140
157
  max: 300,
141
158
  });
142
- const strafeIntensity = useParam('strafeIntensity', 100, {
143
- range: 40,
144
- min: 20,
145
- max: 100,
159
+ const strafeIntensity = useParam('strafeIntensity', 0.78, {
160
+ range: 0.4,
161
+ min: 0.2,
162
+ max: 1,
146
163
  steps: 7,
147
164
  });
148
- const approachSpeed = useParam('approachSpeed', 62, {
149
- range: 30,
150
- min: 10,
151
- max: 70,
165
+ const approachSpeed = useParam('approachSpeed', 0.7, {
166
+ range: 0.3,
167
+ min: 0.1,
168
+ max: 0.7,
152
169
  steps: 7,
153
170
  });
154
- const distanceDeadZone = useParam('distanceDeadZone', 7, {
171
+ const distanceDeadZone = useParam('distanceDeadZone', 6, {
155
172
  range: 30,
156
173
  min: 5,
157
174
  max: 80,
@@ -162,36 +179,51 @@ export const Lich: WizardFunction = ({state}) =>
162
179
  min: 50,
163
180
  max: 300,
164
181
  });
165
- const blinkWindowMax = useParam('blinkWindowMax', 27, {
182
+ const blinkWindowMax = useParam('blinkWindowMax', 35, {
166
183
  range: 30,
167
184
  min: 15,
168
185
  max: 80,
169
186
  steps: 7,
170
187
  });
171
- const flightTimeDivisor = useParam('flightTimeDivisor', 5.8, {
188
+ const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
172
189
  range: 4,
173
190
  min: 2,
174
191
  max: 12,
175
192
  steps: 5,
176
193
  });
194
+ const centerBias = useParam('centerBias', 0.22, {
195
+ range: 0.4,
196
+ min: 0,
197
+ max: 0.8,
198
+ steps: 5,
199
+ });
200
+ const maxBlinkDistance = useParam('maxBlinkDistance', 244, {
201
+ range: 100,
202
+ min: 50,
203
+ max: 300,
204
+ steps: 7,
205
+ });
206
+
207
+ const distance = distanceTo(position, enemy.position);
177
208
 
178
- const enemy = state.enemies[0];
179
- if (!enemy)
209
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
180
210
  {
181
- return {move: {x: 0, y: 0}};
211
+ const dx = target.x - position.x;
212
+ const dy = target.y - position.y;
213
+ const dist = Math.sqrt(dx * dx + dy * dy);
214
+ if (dist <= maxBlinkDistance) return target;
215
+ const scale = maxBlinkDistance / dist;
216
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
182
217
  }
183
218
 
184
- const distance = distanceTo(state.position, enemy.position);
185
-
186
- const threats = getThreats(state);
187
219
  const urgentThreat = getMostUrgentThreat(threats);
188
220
 
189
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
221
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
190
222
  const strafe = smartStrafe(
191
- state.position,
223
+ position,
192
224
  enemy,
193
225
  dodgeDirection,
194
- state.tick,
226
+ tick,
195
227
  strafeCyclePeriod,
196
228
  strafeIntensity,
197
229
  );
@@ -202,14 +234,22 @@ export const Lich: WizardFunction = ({state}) =>
202
234
 
203
235
  if (!dodgeDirection)
204
236
  {
205
- const deltaX = enemy.position.x - state.position.x;
206
- const deltaY = enemy.position.y - state.position.y;
237
+ const deltaX = enemy.position.x - position.x;
238
+ const deltaY = enemy.position.y - position.y;
207
239
  const normalizedDistance = distance > 0 ? distance : 1;
208
240
 
209
- if (distance > targetDistance + distanceDeadZone)
241
+ if (distance > targetDistance + distanceDeadZone)
210
242
  {
211
- moveX += (deltaX / normalizedDistance) * approachSpeed;
212
- moveY += (deltaY / normalizedDistance) * approachSpeed;
243
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
244
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
245
+ const toCenterX = center - enemy.position.x;
246
+ const toCenterY = center - enemy.position.y;
247
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
248
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
249
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
250
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
251
+ moveX += (approachDirX / approachMag) * approachSpeed;
252
+ moveY += (approachDirY / approachMag) * approachSpeed;
213
253
  }
214
254
  else if (distance < targetDistance - distanceDeadZone)
215
255
  {
@@ -219,39 +259,40 @@ export const Lich: WizardFunction = ({state}) =>
219
259
  }
220
260
 
221
261
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
222
- if (magnitude > 100)
262
+ if (magnitude > 1)
223
263
  {
224
- moveX = (moveX / magnitude) * 100;
225
- moveY = (moveY / magnitude) * 100;
264
+ moveX = moveX / magnitude;
265
+ moveY = moveY / magnitude;
226
266
  }
227
267
 
228
268
  const move = {x: moveX, y: moveY};
269
+ const safeMove = keepOffWalls(position, move);
229
270
 
230
271
  // === DEFENSE: Handle channeling ===
231
- if (state.state === 'channeling')
272
+ if (status === 'channeling')
232
273
  {
233
274
  if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
234
275
  {
235
- return {move, cancel: true};
276
+ return cancel().move(safeMove.x, safeMove.y);
236
277
  }
237
- return {move: {x: 0, y: 0}};
278
+ return idle();
238
279
  }
239
280
 
240
281
  // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
241
282
  if (
242
- state.state === 'casting' &&
243
- state.castingSpell === 'missile' &&
283
+ status === 'casting' &&
284
+ castingSpell === 'missile' &&
244
285
  urgentThreat &&
245
- urgentThreat.projectile.damage >= state.health
286
+ urgentThreat.projectile.damage >= health
246
287
  )
247
288
  {
248
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
289
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
249
290
  if (
250
291
  urgentThreat.ticksToImpact <=
251
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
292
+ remainingCast + RULES.GCD_DURATION + RULES.SHIELD_CAST_TIME + SHIELD_BUFFER
252
293
  )
253
294
  {
254
- return {move, cancel: true};
295
+ return cancel().move(safeMove.x, safeMove.y);
255
296
  }
256
297
  }
257
298
 
@@ -259,28 +300,26 @@ export const Lich: WizardFunction = ({state}) =>
259
300
  // Blink dodges the incoming missile — no GCD after, so can immediately resume casting.
260
301
  // Only shield when blink is on cooldown.
261
302
  if (
262
- state.state === 'idle' &&
303
+ status === 'idle' &&
263
304
  urgentThreat &&
264
- state.blinkCooldown === 0 &&
305
+ blinkCooldown === 0 &&
265
306
  urgentThreat.ticksToImpact >= 10 &&
266
307
  urgentThreat.ticksToImpact <= blinkWindowMax
267
308
  )
268
309
  {
269
- return {
270
- move: {x: 0, y: 0},
271
- startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
272
- };
310
+ const blinkTarget = capBlinkDistance(getBlinkToCenter(position));
311
+ return blink(blinkTarget.x, blinkTarget.y);
273
312
  }
274
313
 
275
314
  // === DEFENSE: Shield threats (when blink on cooldown) ===
276
315
  // Shield undodgeable, high-damage homing, or when health is critical
277
- if (state.state === 'idle' && urgentThreat)
316
+ if (status === 'idle' && urgentThreat)
278
317
  {
279
318
  const forceShield = shouldForceShield(urgentThreat);
280
319
  const undodgeable = !urgentThreat.bestDodgeDirection;
281
- const healthLow = state.health < shieldHealthThreshold;
320
+ const healthLow = health < shieldHealthThreshold;
282
321
  const healthCritical =
283
- state.health <= urgentThreat.projectile.damage * 2 + 1;
322
+ health <= urgentThreat.projectile.damage * 2 + 1;
284
323
  const doShield =
285
324
  forceShield || (undodgeable && healthLow) || healthCritical;
286
325
 
@@ -290,30 +329,51 @@ export const Lich: WizardFunction = ({state}) =>
290
329
  urgentThreat.ticksToStartShield <= SHIELD_BUFFER
291
330
  )
292
331
  {
293
- return {
294
- move: {x: 0, y: 0},
295
- startCast: {spell: 'shield'},
296
- };
332
+ return shield();
333
+ }
334
+ }
335
+
336
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
337
+ if (
338
+ status === 'idle' &&
339
+ distance < 300 &&
340
+ enemy.status === 'casting' &&
341
+ enemy.castingSpell === 'missile'
342
+ )
343
+ {
344
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
345
+ const estimatedFlightTicks = distance / 8;
346
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
347
+
348
+ if (
349
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
350
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
351
+ )
352
+ {
353
+ return shield();
297
354
  }
298
355
  }
299
356
 
300
357
  // === OFFENSE: Adaptive strong-tracking homing missiles ===
301
358
  // minTurnRate 1.0 gives better tracking than other bots (0.5) at the cost of damage/speed.
302
359
  // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
303
- const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
304
- const hasNearbyLethalMissile = state.projectiles.some(
360
+ const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
361
+ const hasNearbyLethalMissile = projectiles.some(
305
362
  (p) =>
306
363
  !myProjectileIds.has(p.id) &&
307
- p.damage >= state.health &&
308
- distanceTo(state.position, p.position) / p.speed <
309
- MAX_CAST_BUDGET + GCD_DURATION,
364
+ p.damage >= health &&
365
+ distanceTo(position, p.position) / p.speed <
366
+ MAX_CAST_BUDGET + RULES.GCD_DURATION,
310
367
  );
311
- if (state.state === 'idle' && !hasNearbyLethalMissile)
368
+ if (status === 'idle' && !hasNearbyLethalMissile)
312
369
  {
370
+ const reposition = getPreCastReposition(position, enemy.position);
371
+ if (reposition) return moveAction(reposition.x, reposition.y);
372
+
313
373
  let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
314
374
 
315
375
  // Account for enemy's active cast — their missile will arrive after cast completes + flight time
316
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
376
+ if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
317
377
  {
318
378
  const remainingEnemyCast =
319
379
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
@@ -325,7 +385,7 @@ export const Lich: WizardFunction = ({state}) =>
325
385
  }
326
386
 
327
387
  const safeBudget =
328
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
388
+ nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
329
389
  const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
330
390
 
331
391
  // Lich specializes in strong tracking (minTurnRate 1.0 vs typical 0.5)
@@ -333,24 +393,16 @@ export const Lich: WizardFunction = ({state}) =>
333
393
  const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
334
394
  const config = fitMissileToBudget(budgetTicks, distance, {
335
395
  minTurnRate,
336
- lastMissileConfig: state.lastMissileConfig,
396
+ lastMissileConfig: lastMissileConfig,
337
397
  });
338
398
 
339
399
  if (config && config.damage >= minDmg)
340
400
  {
341
- return {
342
- move,
343
- startCast: {
344
- spell: 'missile',
345
- config,
346
- missileAI: HomingAI,
347
- direction: angleTo(state.position, enemy.position),
348
- },
349
- };
401
+ return missile(config, HomingAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
350
402
  }
351
403
 
352
404
  // Budget too small for useful missile — wait for threat to pass, then fire
353
405
  }
354
406
 
355
- return {move};
356
- };
407
+ return moveAction(safeMove.x, safeMove.y);
408
+ }