@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,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,
@@ -13,13 +13,16 @@ import {
13
13
  shouldForceShield,
14
14
  MAX_CAST_BUDGET,
15
15
  getEnemyVulnerabilityTicks,
16
+ keepOffWalls,
17
+ getPreCastReposition,
16
18
  } from '../shared.js';
17
19
  import {useParam} from '../../engine/params-runtime.js';
18
20
 
19
- const HomingAI: MissileAIFunction = ({worldState}) =>
21
+ const HomingAI: MissileFunction = () =>
20
22
  {
21
- const enemy = worldState.enemies[0];
22
- return {turnToward: enemy?.position};
23
+ const ctx = getMissileContext();
24
+ const enemy = ctx.worldState.enemies[0];
25
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
23
26
  };
24
27
  const WALL_BUFFER = 50;
25
28
 
@@ -39,12 +42,22 @@ function combatMove(
39
42
  strafeCyclePeriod: number,
40
43
  strafeIntensity: number,
41
44
  approachSpeed: number,
42
- ): {x: number; y: number}
45
+ centerBias: number,
46
+ ): {x: number; y: number}
43
47
  {
44
48
  const deltaX = enemy.position.x - position.x;
45
49
  const deltaY = enemy.position.y - position.y;
46
50
  const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
47
51
 
52
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
53
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
54
+ const toCenterX = center - enemy.position.x;
55
+ const toCenterY = center - enemy.position.y;
56
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
57
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
58
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
59
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
60
+
48
61
  const strafeClockwise = {
49
62
  x: -deltaY / normalizedDistance,
50
63
  y: deltaX / normalizedDistance,
@@ -61,10 +74,10 @@ function combatMove(
61
74
  strafeDirection = dodgeDirection;
62
75
  }
63
76
  else if (
64
- position.x < WALL_BUFFER ||
65
- position.x > ARENA_SIZE - WALL_BUFFER ||
66
- position.y < WALL_BUFFER ||
67
- position.y > ARENA_SIZE - WALL_BUFFER
77
+ position.x < ARENA_MIN + WALL_BUFFER ||
78
+ position.x > ARENA_MAX - WALL_BUFFER ||
79
+ position.y < ARENA_MIN + WALL_BUFFER ||
80
+ position.y > ARENA_MAX - WALL_BUFFER
68
81
  )
69
82
  {
70
83
  const futureClockwise = {
@@ -76,16 +89,16 @@ function combatMove(
76
89
  y: position.y + strafeCounterClockwise.y * 100,
77
90
  };
78
91
  const scoreClockwise = Math.min(
79
- futureClockwise.x,
80
- ARENA_SIZE - futureClockwise.x,
81
- futureClockwise.y,
82
- ARENA_SIZE - futureClockwise.y,
92
+ futureClockwise.x - ARENA_MIN,
93
+ ARENA_MAX - futureClockwise.x,
94
+ futureClockwise.y - ARENA_MIN,
95
+ ARENA_MAX - futureClockwise.y,
83
96
  );
84
97
  const scoreCounterClockwise = Math.min(
85
- futureCounterClockwise.x,
86
- ARENA_SIZE - futureCounterClockwise.x,
87
- futureCounterClockwise.y,
88
- ARENA_SIZE - futureCounterClockwise.y,
98
+ futureCounterClockwise.x - ARENA_MIN,
99
+ ARENA_MAX - futureCounterClockwise.x,
100
+ futureCounterClockwise.y - ARENA_MIN,
101
+ ARENA_MAX - futureCounterClockwise.y,
89
102
  );
90
103
  strafeDirection =
91
104
  scoreClockwise > scoreCounterClockwise
@@ -101,35 +114,35 @@ function combatMove(
101
114
  // When actively dodging, commit fully
102
115
  if (dodgeDirection)
103
116
  {
104
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
117
+ return {x: strafeDirection.x, y: strafeDirection.y};
105
118
  }
106
119
 
107
120
  let moveX = strafeDirection.x * strafeIntensity;
108
121
  let moveY = strafeDirection.y * strafeIntensity;
109
122
 
110
123
  // Distance management
111
- if (currentDistance > maxDistance)
124
+ if (currentDistance > maxDistance)
112
125
  {
113
- moveX += (deltaX / normalizedDistance) * approachSpeed;
114
- moveY += (deltaY / normalizedDistance) * approachSpeed;
126
+ moveX += (approachDirX / approachMag) * approachSpeed;
127
+ moveY += (approachDirY / approachMag) * approachSpeed;
115
128
  }
116
129
  else if (currentDistance < minDistance)
117
130
  {
118
131
  let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
119
132
  let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
120
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
121
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
122
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
123
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
133
+ if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
134
+ if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0) retreatX = 0;
135
+ if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
136
+ if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0) retreatY = 0;
124
137
  moveX += retreatX;
125
138
  moveY += retreatY;
126
139
  }
127
140
 
128
141
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
129
- if (magnitude > 100)
142
+ if (magnitude > 1)
130
143
  {
131
- moveX = (moveX / magnitude) * 100;
132
- moveY = (moveY / magnitude) * 100;
144
+ moveX = moveX / magnitude;
145
+ moveY = moveY / magnitude;
133
146
  }
134
147
 
135
148
  return {x: moveX, y: moveY};
@@ -152,36 +165,50 @@ function combatMove(
152
165
  *
153
166
  * TIER: 2 (enhanced Spellshot)
154
167
  */
155
- export const Spelltracer: WizardFunction = ({state}) =>
168
+ export function Spelltracer(): FinalAction
156
169
  {
170
+ const position = usePosition();
171
+ const enemy = useEnemy();
172
+ const status = useStatus();
173
+ const blinkCooldown = useBlinkCooldown();
174
+ const health = useHealth();
175
+ const tick = useTick();
176
+ const threats = useThreats();
177
+ const projectiles = useProjectiles();
178
+ const myProjectiles = useMyProjectiles();
179
+ const lastMissileConfig = useLastMissileConfig();
180
+ const castingSpell = useCastingSpell();
181
+ const castProgressData = useCastProgress();
182
+
183
+
157
184
  // Tunable parameters (run optimizer script to find optimal values)
158
- const dangerDistance = useParam('dangerDistance', 199, {
185
+ const dangerDistance = useParam('dangerDistance', 65, {
159
186
  range: 175,
160
187
  min: 0,
161
188
  });
162
- const targetDistance = useParam('targetDistance', 555, {range: 200, min: 0});
163
- const distanceDeadZone = useParam('distanceDeadZone', 12, {
189
+ const targetDistance = useParam('targetDistance', 179, {range: 200, min: 0});
190
+ const distanceDeadZone = useParam('distanceDeadZone', 128, {
164
191
  range: 50,
165
192
  min: 0,
166
193
  });
167
194
  const rangeMin = targetDistance - distanceDeadZone;
168
195
  const rangeMax = targetDistance + distanceDeadZone;
169
- const minTurnRate = useParam('minTurnRate', 0.9, {range: 1.5, steps: 5});
170
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 140, {
196
+ const minTurnRate = useParam('minTurnRate', -0.6, {range: 1.5, steps: 5});
197
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 221, {
171
198
  range: 75,
172
199
  min: 80,
173
200
  max: 300,
174
201
  });
175
- const strafeIntensity = useParam('strafeIntensity', 48, {
176
- range: 40,
177
- min: 20,
178
- max: 100,
202
+ const strafeIntensity = useParam('strafeIntensity', 0.92, {
203
+ range: 0.4,
204
+ min: 0.2,
205
+ max: 1,
179
206
  steps: 7,
180
207
  });
181
- const approachSpeed = useParam('approachSpeed', 80, {
182
- range: 30,
183
- min: 10,
184
- max: 80,
208
+ const approachSpeed = useParam('approachSpeed', 0.46, {
209
+ range: 0.3,
210
+ min: 0.1,
211
+ max: 0.8,
185
212
  steps: 7,
186
213
  });
187
214
  const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
@@ -189,19 +216,19 @@ export const Spelltracer: WizardFunction = ({state}) =>
189
216
  min: 50,
190
217
  max: 300,
191
218
  });
192
- const blinkWindowMax = useParam('blinkWindowMax', 23, {
219
+ const blinkWindowMax = useParam('blinkWindowMax', 34, {
193
220
  range: 40,
194
221
  min: 20,
195
222
  max: 120,
196
223
  steps: 7,
197
224
  });
198
- const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
225
+ const flightTimeDivisor = useParam('flightTimeDivisor', 5.8, {
199
226
  range: 4,
200
227
  min: 3,
201
228
  max: 12,
202
229
  steps: 5,
203
230
  });
204
- const enemyResponseEstimate = useParam('enemyResponseEstimate', 217, {
231
+ const enemyResponseEstimate = useParam('enemyResponseEstimate', 145, {
205
232
  range: 100,
206
233
  min: 50,
207
234
  max: 300,
@@ -213,57 +240,74 @@ export const Spelltracer: WizardFunction = ({state}) =>
213
240
  max: 400,
214
241
  steps: 7,
215
242
  });
243
+ const centerBias = useParam('centerBias', 0.11, {
244
+ range: 0.4,
245
+ min: 0,
246
+ max: 0.8,
247
+ steps: 5,
248
+ });
249
+ const maxBlinkDistance = useParam('maxBlinkDistance', 287, {
250
+ range: 100,
251
+ min: 50,
252
+ max: 300,
253
+ steps: 7,
254
+ });
255
+
256
+ const distance = distanceTo(position, enemy.position);
216
257
 
217
- const enemy = state.enemies[0];
218
- if (!enemy)
258
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
219
259
  {
220
- return {move: {x: 0, y: 0}};
260
+ const dx = target.x - position.x;
261
+ const dy = target.y - position.y;
262
+ const dist = Math.sqrt(dx * dx + dy * dy);
263
+ if (dist <= maxBlinkDistance) return target;
264
+ const scale = maxBlinkDistance / dist;
265
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
221
266
  }
222
267
 
223
- const distance = distanceTo(state.position, enemy.position);
224
-
225
- const threats = getThreats(state);
226
268
  const urgentThreat = getMostUrgentThreat(threats);
227
269
 
228
270
  // === DEFENSE: Handle channeling ===
229
- if (state.state === 'channeling')
271
+ if (status === 'channeling')
230
272
  {
231
273
  if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
232
274
  {
233
- return {move: {x: 0, y: 0}, cancel: true};
275
+ return cancel();
234
276
  }
235
- return {move: {x: 0, y: 0}};
277
+ return idle();
236
278
  }
237
279
 
238
280
  // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
239
281
  // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
240
282
  if (
241
- state.state === 'casting' &&
242
- state.castingSpell === 'missile' &&
283
+ status === 'casting' &&
284
+ castingSpell === 'missile' &&
243
285
  urgentThreat &&
244
- urgentThreat.projectile.damage >= state.health
286
+ urgentThreat.projectile.damage >= health
245
287
  )
246
288
  {
247
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
289
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
248
290
  if (
249
291
  urgentThreat.ticksToImpact <=
250
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
292
+ remainingCast + RULES.GCD_DURATION + RULES.SHIELD_CAST_TIME + SHIELD_BUFFER
251
293
  )
252
294
  {
253
- const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
295
+ const dodgeDir = getDodgeDirectionForMovement(threats, position);
254
296
  const cancelMove = combatMove(
255
- state.position,
297
+ position,
256
298
  enemy,
257
299
  distance,
258
300
  rangeMin,
259
301
  rangeMax,
260
302
  dodgeDir,
261
- state.tick,
303
+ tick,
262
304
  strafeCyclePeriod,
263
305
  strafeIntensity,
264
306
  approachSpeed,
307
+ centerBias,
265
308
  );
266
- return {move: cancelMove, cancel: true};
309
+ const safeCancelMove = keepOffWalls(position, cancelMove);
310
+ return cancel().move(safeCancelMove.x, safeCancelMove.y);
267
311
  }
268
312
  }
269
313
 
@@ -271,33 +315,30 @@ export const Spelltracer: WizardFunction = ({state}) =>
271
315
  // When we would otherwise shield, blink instead — no GCD after blink, so can fire back immediately.
272
316
  // Only for threats that can't be dodged by movement (homing/undodgeable).
273
317
  if (
274
- state.state === 'idle' &&
318
+ status === 'idle' &&
275
319
  urgentThreat &&
276
- state.blinkCooldown === 0 &&
320
+ blinkCooldown === 0 &&
277
321
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
278
322
  urgentThreat.ticksToImpact >= 10 &&
279
323
  urgentThreat.ticksToImpact <= blinkWindowMax
280
324
  )
281
325
  {
282
- return {
283
- move: {x: 0, y: 0},
284
- startCast: {
285
- spell: 'blink',
286
- target:
287
- distance < targetDistance
288
- ? (getBlinkToIncreaseDistance(
289
- state.position,
290
- enemy.position,
291
- state.projectiles,
292
- ) ?? getBlinkToCenter(state.position))
293
- : getBlinkToCenter(state.position),
294
- },
295
- };
326
+ {
327
+ const _bt = distance < targetDistance
328
+ ? (getBlinkToIncreaseDistance(
329
+ position,
330
+ enemy.position,
331
+ projectiles,
332
+ ) ?? getBlinkToCenter(position))
333
+ : getBlinkToCenter(position);
334
+ const capped = capBlinkDistance(_bt);
335
+ return blink(capped.x, capped.y);
336
+ }
296
337
  }
297
338
 
298
339
  // === DEFENSE: Shield undodgeable or high-damage homing threats (when blink on cooldown) ===
299
340
  if (
300
- state.state === 'idle' &&
341
+ status === 'idle' &&
301
342
  urgentThreat &&
302
343
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
303
344
  )
@@ -306,68 +347,88 @@ export const Spelltracer: WizardFunction = ({state}) =>
306
347
  {
307
348
  if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
308
349
  {
309
- return {
310
- move: {x: 0, y: 0},
311
- startCast: {spell: 'shield'},
312
- };
350
+ return shield();
313
351
  }
314
352
  }
315
353
  }
316
354
 
355
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
356
+ if (
357
+ status === 'idle' &&
358
+ distance < 300 &&
359
+ enemy.status === 'casting' &&
360
+ enemy.castingSpell === 'missile'
361
+ )
362
+ {
363
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
364
+ const estimatedFlightTicks = distance / 8;
365
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
366
+
367
+ if (
368
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
369
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
370
+ )
371
+ {
372
+ return shield();
373
+ }
374
+ }
375
+
317
376
  // === DISTANCE BLINK: enemy too close, blink to reestablish range ===
318
377
  if (
319
- state.state === 'idle' &&
378
+ status === 'idle' &&
320
379
  distance < dangerDistance &&
321
- state.blinkCooldown === 0 &&
380
+ blinkCooldown === 0 &&
322
381
  (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
323
382
  )
324
383
  {
325
384
  const blinkTarget = getBlinkToIncreaseDistance(
326
- state.position,
385
+ position,
327
386
  enemy.position,
328
- state.projectiles,
387
+ projectiles,
329
388
  MIN_BLINK_ESCAPE_DISTANCE,
330
389
  );
331
- if (blinkTarget)
390
+ if (blinkTarget)
332
391
  {
333
- return {
334
- move: {x: 0, y: 0},
335
- startCast: {spell: 'blink', target: blinkTarget},
336
- };
392
+ const capped = capBlinkDistance(blinkTarget);
393
+ return blink(capped.x, capped.y);
337
394
  }
338
395
  }
339
396
 
340
397
  // Smart combat movement
341
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
398
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
342
399
  const move = combatMove(
343
- state.position,
400
+ position,
344
401
  enemy,
345
402
  distance,
346
403
  rangeMin,
347
404
  rangeMax,
348
405
  dodgeDirection,
349
- state.tick,
406
+ tick,
350
407
  strafeCyclePeriod,
351
408
  strafeIntensity,
352
409
  approachSpeed,
410
+ centerBias,
353
411
  );
354
-
412
+ const safeMove = keepOffWalls(position, move);
355
413
  // === OFFENSE: Adaptive homing missiles ===
356
414
  // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
357
- const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
358
- const hasNearbyLethalMissile = state.projectiles.some(
415
+ const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
416
+ const hasNearbyLethalMissile = projectiles.some(
359
417
  (p) =>
360
418
  !myProjectileIds.has(p.id) &&
361
- p.damage >= state.health &&
362
- distanceTo(state.position, p.position) / p.speed <
363
- MAX_CAST_BUDGET + GCD_DURATION,
419
+ p.damage >= health &&
420
+ distanceTo(position, p.position) / p.speed <
421
+ MAX_CAST_BUDGET + RULES.GCD_DURATION,
364
422
  );
365
- if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
423
+ if (status === 'idle' && distance < 600 && !hasNearbyLethalMissile)
366
424
  {
425
+ const reposition = getPreCastReposition(position, enemy.position);
426
+ if (reposition) return moveAction(reposition.x, reposition.y);
427
+
367
428
  let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
368
429
 
369
430
  // Anticipate enemy's next missile based on their current state
370
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
431
+ if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
371
432
  {
372
433
  const remainingEnemyCast =
373
434
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
@@ -387,7 +448,7 @@ export const Spelltracer: WizardFunction = ({state}) =>
387
448
  }
388
449
 
389
450
  const safeBudget =
390
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
451
+ nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
391
452
  const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
392
453
 
393
454
  // Try adaptive fitting for the safe budget
@@ -395,7 +456,7 @@ export const Spelltracer: WizardFunction = ({state}) =>
395
456
  const config = fitMissileToBudget(budgetTicks, distance, {
396
457
  minTurnRate,
397
458
  maxDamage: enemyHP,
398
- lastMissileConfig: state.lastMissileConfig,
459
+ lastMissileConfig: lastMissileConfig,
399
460
  });
400
461
 
401
462
  // HIGH-DAMAGE PRIORITY: Sniper missiles need to chip through shields.
@@ -407,15 +468,7 @@ export const Spelltracer: WizardFunction = ({state}) =>
407
468
 
408
469
  if (config && config.damage >= 15)
409
470
  {
410
- return {
411
- move,
412
- startCast: {
413
- spell: 'missile',
414
- config,
415
- missileAI: HomingAI,
416
- direction: angleTo(state.position, enemy.position),
417
- },
418
- };
471
+ return missile(config, HomingAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
419
472
  }
420
473
 
421
474
  // Adaptive fitting produced weak missile or failed — fire big shot instead
@@ -423,19 +476,11 @@ export const Spelltracer: WizardFunction = ({state}) =>
423
476
  // a hit during the longer cast (trade is worthwhile at high damage).
424
477
  if (!urgentThreat || urgentThreat.ticksToImpact > 50)
425
478
  {
426
- return {
427
- move,
428
- startCast: {
429
- spell: 'missile',
430
- config: BIG_SHOT,
431
- missileAI: HomingAI,
432
- direction: angleTo(state.position, enemy.position),
433
- },
434
- };
479
+ return missile(BIG_SHOT, HomingAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
435
480
  }
436
481
 
437
482
  // Imminent threat — wait for it to pass, then fire
438
483
  }
439
484
 
440
- return {move};
441
- };
485
+ return moveAction(safeMove.x, safeMove.y);
486
+ }