@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,15 +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, 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 {
5
- ARENA_SIZE,
6
- GCD_DURATION,
7
- SHIELD_CAST_TIME,
8
- BLINK_CAST_TIME,
9
- } from '../../rules.js';
5
+ import {RULES, ARENA_MIN, ARENA_MAX} from '../../rules.js';
10
6
  import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
11
7
  import {
12
- getThreats,
13
8
  getMostUrgentThreat,
14
9
  getDodgeDirectionForMovement,
15
10
  getBlinkToCenter,
@@ -22,16 +17,19 @@ import {
22
17
  getClosingRate,
23
18
  getEnemyVulnerabilityTicks,
24
19
  isSafeToAttack,
20
+ keepOffWalls,
21
+ getPreCastReposition,
25
22
  } from '../shared.js';
26
23
  import {useParam} from '../../engine/params-runtime.js';
27
24
 
28
- const HomingAI: MissileAIFunction = ({worldState}) =>
25
+ const HomingAI: MissileFunction = () =>
29
26
  {
30
- const enemy = worldState.enemies[0];
31
- return {turnToward: enemy?.position};
27
+ const ctx = getMissileContext();
28
+ const enemy = ctx.worldState.enemies[0];
29
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
32
30
  };
33
31
 
34
- const StraightAI: MissileAIFunction = () => ({});
32
+ const StraightAI: MissileFunction = () => flyStraight();
35
33
 
36
34
  const WALL_BUFFER = 50;
37
35
  const SHIELD_BUFFER = 3;
@@ -50,12 +48,22 @@ function combatMove(
50
48
  strafeCyclePeriod: number,
51
49
  strafeIntensity: number,
52
50
  approachSpeed: number,
53
- ): {x: number; y: number}
51
+ centerBias: number,
52
+ ): {x: number; y: number}
54
53
  {
55
54
  const deltaX = enemy.position.x - position.x;
56
55
  const deltaY = enemy.position.y - position.y;
57
56
  const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
58
57
 
58
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
59
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
60
+ const toCenterX = center - enemy.position.x;
61
+ const toCenterY = center - enemy.position.y;
62
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
63
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
64
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
65
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
66
+
59
67
  const strafeClockwise = {
60
68
  x: -deltaY / normalizedDistance,
61
69
  y: deltaX / normalizedDistance,
@@ -72,10 +80,10 @@ function combatMove(
72
80
  strafeDirection = dodgeDirection;
73
81
  }
74
82
  else if (
75
- position.x < WALL_BUFFER ||
76
- position.x > ARENA_SIZE - WALL_BUFFER ||
77
- position.y < WALL_BUFFER ||
78
- position.y > ARENA_SIZE - WALL_BUFFER
83
+ position.x < ARENA_MIN + WALL_BUFFER ||
84
+ position.x > ARENA_MAX - WALL_BUFFER ||
85
+ position.y < ARENA_MIN + WALL_BUFFER ||
86
+ position.y > ARENA_MAX - WALL_BUFFER
79
87
  )
80
88
  {
81
89
  const futureClockwise = {
@@ -87,16 +95,16 @@ function combatMove(
87
95
  y: position.y + strafeCounterClockwise.y * 100,
88
96
  };
89
97
  const scoreClockwise = Math.min(
90
- futureClockwise.x,
91
- ARENA_SIZE - futureClockwise.x,
92
- futureClockwise.y,
93
- ARENA_SIZE - futureClockwise.y,
98
+ futureClockwise.x - ARENA_MIN,
99
+ ARENA_MAX - futureClockwise.x,
100
+ futureClockwise.y - ARENA_MIN,
101
+ ARENA_MAX - futureClockwise.y,
94
102
  );
95
103
  const scoreCounterClockwise = Math.min(
96
- futureCounterClockwise.x,
97
- ARENA_SIZE - futureCounterClockwise.x,
98
- futureCounterClockwise.y,
99
- ARENA_SIZE - futureCounterClockwise.y,
104
+ futureCounterClockwise.x - ARENA_MIN,
105
+ ARENA_MAX - futureCounterClockwise.x,
106
+ futureCounterClockwise.y - ARENA_MIN,
107
+ ARENA_MAX - futureCounterClockwise.y,
100
108
  );
101
109
  strafeDirection =
102
110
  scoreClockwise > scoreCounterClockwise
@@ -111,34 +119,34 @@ function combatMove(
111
119
 
112
120
  if (dodgeDirection)
113
121
  {
114
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
122
+ return {x: strafeDirection.x, y: strafeDirection.y};
115
123
  }
116
124
 
117
125
  let moveX = strafeDirection.x * strafeIntensity;
118
126
  let moveY = strafeDirection.y * strafeIntensity;
119
127
 
120
- if (currentDistance > maxDistance)
128
+ if (currentDistance > maxDistance)
121
129
  {
122
- moveX += (deltaX / normalizedDistance) * approachSpeed;
123
- moveY += (deltaY / normalizedDistance) * approachSpeed;
130
+ moveX += (approachDirX / approachMag) * approachSpeed;
131
+ moveY += (approachDirY / approachMag) * approachSpeed;
124
132
  }
125
133
  else if (currentDistance < minDistance)
126
134
  {
127
135
  let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
128
136
  let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
129
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
130
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
131
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
132
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
137
+ if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
138
+ if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0) retreatX = 0;
139
+ if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
140
+ if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0) retreatY = 0;
133
141
  moveX += retreatX;
134
142
  moveY += retreatY;
135
143
  }
136
144
 
137
145
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
138
- if (magnitude > 100)
146
+ if (magnitude > 1)
139
147
  {
140
- moveX = (moveX / magnitude) * 100;
141
- moveY = (moveY / magnitude) * 100;
148
+ moveX = moveX / magnitude;
149
+ moveY = moveY / magnitude;
142
150
  }
143
151
 
144
152
  return {x: moveX, y: moveY};
@@ -161,17 +169,30 @@ function combatMove(
161
169
  * PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
162
170
  * TIER: 3 (elite Sniper line)
163
171
  */
164
- export const Spellseeker: WizardFunction = ({state}) =>
172
+ export function Spellseeker(): FinalAction
165
173
  {
166
- const dangerDistance = useParam('dangerDistance', 65, {
174
+ const position = usePosition();
175
+ const enemy = useEnemy();
176
+ const status = useStatus();
177
+ const blinkCooldown = useBlinkCooldown();
178
+ const health = useHealth();
179
+ const tick = useTick();
180
+ const threats = useThreats();
181
+ const projectiles = useProjectiles();
182
+ const lastMissileConfig = useLastMissileConfig();
183
+ const castingSpell = useCastingSpell();
184
+ const castProgressData = useCastProgress();
185
+
186
+
187
+ const dangerDistance = useParam('dangerDistance', 362, {
167
188
  range: 175,
168
189
  min: 0,
169
190
  });
170
- const targetDistance = useParam('targetDistance', 289, {
191
+ const targetDistance = useParam('targetDistance', 515, {
171
192
  range: 200,
172
193
  min: 0,
173
194
  });
174
- const distanceDeadZone = useParam('distanceDeadZone', 22, {
195
+ const distanceDeadZone = useParam('distanceDeadZone', 55, {
175
196
  range: 50,
176
197
  min: 0,
177
198
  });
@@ -183,28 +204,28 @@ export const Spellseeker: WizardFunction = ({state}) =>
183
204
  min: 0,
184
205
  steps: 5,
185
206
  });
186
- const punishMinVulnerability = useParam('punishMinVulnerability', 279, {
207
+ const punishMinVulnerability = useParam('punishMinVulnerability', 249, {
187
208
  range: 30,
188
209
  min: 10,
189
210
  steps: 5,
190
211
  });
191
212
  const preferBlinkDodge =
192
213
  useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
193
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
214
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 112, {
194
215
  range: 75,
195
216
  min: 80,
196
217
  max: 250,
197
218
  });
198
- const strafeIntensity = useParam('strafeIntensity', 87, {
199
- range: 40,
200
- min: 20,
201
- max: 100,
219
+ const strafeIntensity = useParam('strafeIntensity', 0.99, {
220
+ range: 0.4,
221
+ min: 0.2,
222
+ max: 1,
202
223
  steps: 7,
203
224
  });
204
- const approachSpeed = useParam('approachSpeed', 80, {
205
- range: 30,
206
- min: 10,
207
- max: 80,
225
+ const approachSpeed = useParam('approachSpeed', 0.34, {
226
+ range: 0.3,
227
+ min: 0.1,
228
+ max: 0.8,
208
229
  steps: 7,
209
230
  });
210
231
  const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
@@ -212,13 +233,13 @@ export const Spellseeker: WizardFunction = ({state}) =>
212
233
  min: 50,
213
234
  max: 300,
214
235
  });
215
- const blinkWindowMax = useParam('blinkWindowMax', 88, {
236
+ const blinkWindowMax = useParam('blinkWindowMax', 93, {
216
237
  range: 40,
217
238
  min: 20,
218
239
  max: 120,
219
240
  steps: 7,
220
241
  });
221
- const proactiveBlinkRange = useParam('proactiveBlinkRange', 433, {
242
+ const proactiveBlinkRange = useParam('proactiveBlinkRange', 283, {
222
243
  range: 150,
223
244
  min: 100,
224
245
  max: 500,
@@ -248,80 +269,90 @@ export const Spellseeker: WizardFunction = ({state}) =>
248
269
  max: 400,
249
270
  steps: 7,
250
271
  });
251
- const punishAttackRange = useParam('punishAttackRange', 213, {
272
+ const punishAttackRange = useParam('punishAttackRange', 288, {
252
273
  range: 150,
253
274
  min: 200,
254
275
  max: 700,
255
276
  steps: 7,
256
277
  });
257
- const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
258
- range: 25,
259
- min: 10,
260
- max: 80,
278
+ const centerBias = useParam('centerBias', 0.69, {
279
+ range: 0.4,
280
+ min: 0,
281
+ max: 0.8,
261
282
  steps: 5,
262
283
  });
284
+ const maxBlinkDistance = useParam('maxBlinkDistance', 283, {
285
+ range: 100,
286
+ min: 50,
287
+ max: 300,
288
+ steps: 7,
289
+ });
263
290
 
264
- const enemy = state.enemies[0];
265
- if (!enemy)
291
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
266
292
  {
267
- return {move: {x: 0, y: 0}};
293
+ const dx = target.x - position.x;
294
+ const dy = target.y - position.y;
295
+ const dist = Math.sqrt(dx * dx + dy * dy);
296
+ if (dist <= maxBlinkDistance) return target;
297
+ const scale = maxBlinkDistance / dist;
298
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
268
299
  }
269
300
 
270
- const distance = distanceTo(state.position, enemy.position);
271
- const threats = getThreats(state);
301
+ const distance = distanceTo(position, enemy.position);
272
302
  const urgentThreat = getMostUrgentThreat(threats);
273
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
303
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
274
304
  const move = combatMove(
275
- state.position,
305
+ position,
276
306
  enemy,
277
307
  distance,
278
308
  rangeMin,
279
309
  rangeMax,
280
310
  dodgeDirection,
281
- state.tick,
311
+ tick,
282
312
  strafeCyclePeriod,
283
313
  strafeIntensity,
284
314
  approachSpeed,
315
+ centerBias,
285
316
  );
286
-
317
+ const safeMove = keepOffWalls(position, move);
287
318
  // === STEP 1: CHANNELING — cancel shield when safe ===
288
- if (state.state === 'channeling')
319
+ if (status === 'channeling')
289
320
  {
290
321
  if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
291
322
  {
292
- return {move, cancel: true};
323
+ return cancel().move(safeMove.x, safeMove.y);
293
324
  }
294
- return {move: {x: 0, y: 0}};
325
+ return idle();
295
326
  }
296
327
 
297
328
  // === STEP 2: PROGRESSIVE CAST-CANCEL ===
298
329
  if (
299
- state.state === 'casting' &&
300
- state.castingSpell === 'missile' &&
330
+ status === 'casting' &&
331
+ castingSpell === 'missile' &&
301
332
  urgentThreat &&
302
333
  !urgentThreat.bestDodgeDirection
303
334
  )
304
335
  {
305
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
336
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
306
337
  const defenseTime =
307
- state.blinkCooldown === 0
308
- ? BLINK_CAST_TIME
309
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
338
+ blinkCooldown === 0
339
+ ? RULES.BLINK_CAST_TIME
340
+ : RULES.SHIELD_CAST_TIME + SHIELD_BUFFER;
310
341
  const willArriveInWindow =
311
- urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
342
+ urgentThreat.ticksToImpact <= remainingCast + RULES.GCD_DURATION + defenseTime;
312
343
 
313
344
  if (willArriveInWindow)
314
345
  {
315
346
  const incomingDamage = urgentThreat.projectile.damage;
316
- const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
347
+ const castRatio = ((castProgressData?.current) ?? 0) / ((castProgressData?.total) ?? 1);
317
348
 
318
- if (incomingDamage >= state.health || incomingDamage >= 20)
349
+ if (incomingDamage >= health || incomingDamage >= 20)
319
350
  {
320
- return {move, cancel: true};
351
+ return cancel().move(safeMove.x, safeMove.y);
321
352
  }
322
353
  if (incomingDamage >= 10 && castRatio < 0.8)
323
354
  {
324
- return {move, cancel: true};
355
+ return cancel().move(safeMove.x, safeMove.y);
325
356
  }
326
357
  }
327
358
  }
@@ -329,11 +360,11 @@ export const Spellseeker: WizardFunction = ({state}) =>
329
360
  // === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
330
361
  if (
331
362
  preferBlinkDodge &&
332
- state.state === 'idle' &&
363
+ status === 'idle' &&
333
364
  urgentThreat &&
334
- state.blinkCooldown === 0 &&
365
+ blinkCooldown === 0 &&
335
366
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
336
- urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
367
+ urgentThreat.ticksToImpact >= RULES.BLINK_CAST_TIME &&
337
368
  urgentThreat.ticksToImpact <= blinkWindowMax
338
369
  )
339
370
  {
@@ -341,20 +372,18 @@ export const Spellseeker: WizardFunction = ({state}) =>
341
372
  const target =
342
373
  distance < midRange
343
374
  ? (getBlinkToIncreaseDistance(
344
- state.position,
375
+ position,
345
376
  enemy.position,
346
- state.projectiles,
347
- ) ?? getBlinkToCenter(state.position))
348
- : getBlinkToCenter(state.position);
349
- return {
350
- move: {x: 0, y: 0},
351
- startCast: {spell: 'blink', target},
352
- };
377
+ projectiles,
378
+ ) ?? getBlinkToCenter(position))
379
+ : getBlinkToCenter(position);
380
+ const capped = capBlinkDistance(target);
381
+ return blink(capped.x, capped.y);
353
382
  }
354
383
 
355
384
  // === STEP 4: SHIELD — undodgeable or critical threats ===
356
385
  if (
357
- state.state === 'idle' &&
386
+ status === 'idle' &&
358
387
  urgentThreat &&
359
388
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
360
389
  )
@@ -364,22 +393,40 @@ export const Spellseeker: WizardFunction = ({state}) =>
364
393
  urgentThreat.ticksToStartShield <= SHIELD_BUFFER
365
394
  )
366
395
  {
367
- return {
368
- move: {x: 0, y: 0},
369
- startCast: {spell: 'shield'},
370
- };
396
+ return shield();
397
+ }
398
+ }
399
+
400
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
401
+ if (
402
+ status === 'idle' &&
403
+ distance < 300 &&
404
+ enemy.status === 'casting' &&
405
+ enemy.castingSpell === 'missile'
406
+ )
407
+ {
408
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
409
+ const estimatedFlightTicks = distance / 8;
410
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
411
+
412
+ if (
413
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
414
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
415
+ )
416
+ {
417
+ return shield();
371
418
  }
372
419
  }
373
420
 
374
421
  // === STEP 5: PROACTIVE DISTANCE BLINK ===
375
422
  if (
376
- state.state === 'idle' &&
377
- state.blinkCooldown === 0 &&
423
+ status === 'idle' &&
424
+ blinkCooldown === 0 &&
378
425
  (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
379
426
  )
380
427
  {
381
428
  const closingRate = getClosingRate(
382
- state.position,
429
+ position,
383
430
  enemy.position,
384
431
  enemy.velocity,
385
432
  );
@@ -389,28 +436,29 @@ export const Spellseeker: WizardFunction = ({state}) =>
389
436
  )
390
437
  {
391
438
  const blinkTarget = getBlinkToIncreaseDistance(
392
- state.position,
439
+ position,
393
440
  enemy.position,
394
- state.projectiles,
441
+ projectiles,
395
442
  MIN_BLINK_ESCAPE_DISTANCE,
396
443
  );
397
- if (blinkTarget)
444
+ if (blinkTarget)
398
445
  {
399
- return {
400
- move: {x: 0, y: 0},
401
- startCast: {spell: 'blink', target: blinkTarget},
402
- };
446
+ const capped = capBlinkDistance(blinkTarget);
447
+ return blink(capped.x, capped.y);
403
448
  }
404
449
  }
405
450
  }
406
451
 
407
452
  // === STEP 6: OFFENSE ===
408
- if (state.state === 'idle' && distance < 600)
453
+ if (status === 'idle' && distance < 600)
409
454
  {
455
+ const reposition = getPreCastReposition(position, enemy.position);
456
+ if (reposition) return moveAction(reposition.x, reposition.y);
457
+
410
458
  const offenseDefenseTime =
411
- state.blinkCooldown === 0
412
- ? BLINK_CAST_TIME
413
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
459
+ blinkCooldown === 0
460
+ ? RULES.BLINK_CAST_TIME
461
+ : RULES.SHIELD_CAST_TIME + SHIELD_BUFFER;
414
462
  const enemyHP = Math.ceil(enemy.health);
415
463
  const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
416
464
 
@@ -423,7 +471,7 @@ export const Spellseeker: WizardFunction = ({state}) =>
423
471
  const flightTimeEstimate = distance / flightTimeDivisor;
424
472
  const punishBudget = vulnerabilityTicks - flightTimeEstimate;
425
473
  const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
426
- const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
474
+ const safeBudget = nextThreatTime - RULES.GCD_DURATION - offenseDefenseTime;
427
475
  const effectiveBudget = Math.min(
428
476
  punishBudget,
429
477
  safeBudget,
@@ -435,12 +483,12 @@ export const Spellseeker: WizardFunction = ({state}) =>
435
483
  const config = fitMissileToBudget(effectiveBudget, distance, {
436
484
  minTurnRate: 0,
437
485
  maxDamage: enemyHP,
438
- lastMissileConfig: state.lastMissileConfig,
486
+ lastMissileConfig: lastMissileConfig,
439
487
  });
440
488
 
441
489
  if (config && config.damage >= MIN_USEFUL_DAMAGE)
442
490
  {
443
- const castTime = getMissileCastTime(config, state.lastMissileConfig);
491
+ const castTime = getMissileCastTime(config, lastMissileConfig);
444
492
  const actualFlight = distance / config.speed;
445
493
 
446
494
  if (
@@ -455,17 +503,9 @@ export const Spellseeker: WizardFunction = ({state}) =>
455
503
  enemy.position,
456
504
  enemy.velocity,
457
505
  config.speed,
458
- state.position,
506
+ position,
459
507
  );
460
- return {
461
- move,
462
- startCast: {
463
- spell: 'missile',
464
- config,
465
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
466
- direction: angleTo(state.position, aimTarget),
467
- },
468
- };
508
+ return missile(config, config.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, aimTarget)).move(safeMove.x, safeMove.y);
469
509
  }
470
510
  }
471
511
  }
@@ -475,7 +515,7 @@ export const Spellseeker: WizardFunction = ({state}) =>
475
515
  let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
476
516
 
477
517
  // Anticipate enemy's next missile based on their current state
478
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
518
+ if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
479
519
  {
480
520
  const remainingEnemyCast =
481
521
  (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
@@ -497,50 +537,35 @@ export const Spellseeker: WizardFunction = ({state}) =>
497
537
  }
498
538
 
499
539
  const safeBudget =
500
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
540
+ nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
501
541
  const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
502
542
 
503
543
  const config = fitMissileToBudget(budgetTicks, distance, {
504
544
  minTurnRate,
505
545
  maxDamage: enemyHP,
506
- lastMissileConfig: state.lastMissileConfig,
546
+ lastMissileConfig: lastMissileConfig,
507
547
  });
508
548
 
509
549
  // HIGH-DAMAGE PRIORITY: Sniper missiles need meaningful shield chip.
510
550
  // Fixed big-shot fallback ensures Spellseeker always does significant damage.
511
551
  const BIG_SHOT = {damage: 28, speed: 10, turnRate: 0.5, duration: 80};
512
552
 
513
- if (
514
- config &&
515
- config.damage >= 15 &&
516
- isSafeToAttack(threats, safeAttackCastEstimate)
517
- )
553
+ if (config && config.damage >= 15)
518
554
  {
519
- return {
520
- move,
521
- startCast: {
522
- spell: 'missile',
523
- config,
524
- missileAI: HomingAI,
525
- direction: angleTo(state.position, enemy.position),
526
- },
527
- };
555
+ const actualCastTime = getMissileCastTime(config, lastMissileConfig);
556
+ if (isSafeToAttack(threats, actualCastTime))
557
+ {
558
+ return missile(config, HomingAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
559
+ }
528
560
  }
529
561
 
530
562
  // Adaptive fitting weak or failed — fire big shot for meaningful damage
531
- if (!urgentThreat || urgentThreat.ticksToImpact > 50)
563
+ const bigShotCast = getMissileCastTime(BIG_SHOT, lastMissileConfig);
564
+ if (isSafeToAttack(threats, bigShotCast))
532
565
  {
533
- return {
534
- move,
535
- startCast: {
536
- spell: 'missile',
537
- config: BIG_SHOT,
538
- missileAI: HomingAI,
539
- direction: angleTo(state.position, enemy.position),
540
- },
541
- };
566
+ return missile(BIG_SHOT, HomingAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
542
567
  }
543
568
  }
544
569
 
545
- return {move};
546
- };
570
+ return moveAction(safeMove.x, safeMove.y);
571
+ }