@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,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
  fitMissileToBudget,
@@ -21,27 +16,30 @@ import {
21
16
  MIN_USEFUL_DAMAGE,
22
17
  getEnemyVulnerabilityTicks,
23
18
  isSafeToAttack,
19
+ keepOffWalls,
20
+ getPreCastReposition,
24
21
  } from '../shared.js';
25
22
  import {useParam} from '../../engine/params-runtime.js';
26
23
 
27
24
  const WALL_BUFFER = 60;
28
25
  const SHIELD_BUFFER = 3;
29
26
 
30
- const HomingAI: MissileAIFunction = ({worldState}) =>
27
+ const HomingAI: MissileFunction = () =>
31
28
  {
32
- const enemy = worldState.enemies[0];
33
- return {turnToward: enemy?.position};
29
+ const ctx = getMissileContext();
30
+ const enemy = ctx.worldState.enemies[0];
31
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
34
32
  };
35
33
 
36
- const StraightAI: MissileAIFunction = () => ({});
34
+ const StraightAI: MissileFunction = () => flyStraight();
37
35
 
38
36
  function isNearWall(position: {x: number; y: number}): boolean
39
37
  {
40
38
  return (
41
- position.x < WALL_BUFFER ||
42
- position.x > ARENA_SIZE - WALL_BUFFER ||
43
- position.y < WALL_BUFFER ||
44
- position.y > ARENA_SIZE - WALL_BUFFER
39
+ position.x < ARENA_MIN + WALL_BUFFER ||
40
+ position.x > ARENA_MAX - WALL_BUFFER ||
41
+ position.y < ARENA_MIN + WALL_BUFFER ||
42
+ position.y > ARENA_MAX - WALL_BUFFER
45
43
  );
46
44
  }
47
45
 
@@ -59,12 +57,22 @@ function combatMove(
59
57
  strafeIntensity: number,
60
58
  approachSpeed: number,
61
59
  distanceDeadZone: number,
62
- ): {x: number; y: number}
60
+ centerBias: number,
61
+ ): {x: number; y: number}
63
62
  {
64
63
  const deltaX = enemy.position.x - position.x;
65
64
  const deltaY = enemy.position.y - position.y;
66
65
  const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
67
66
 
67
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
68
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
69
+ const toCenterX = center - enemy.position.x;
70
+ const toCenterY = center - enemy.position.y;
71
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
72
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
73
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
74
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
75
+
68
76
  const strafeClockwise = {
69
77
  x: -deltaY / normalizedDistance,
70
78
  y: deltaX / normalizedDistance,
@@ -91,16 +99,16 @@ function combatMove(
91
99
  y: position.y + strafeCounterClockwise.y * 100,
92
100
  };
93
101
  const scoreClockwise = Math.min(
94
- futureClockwise.x,
95
- ARENA_SIZE - futureClockwise.x,
96
- futureClockwise.y,
97
- ARENA_SIZE - futureClockwise.y,
102
+ futureClockwise.x - ARENA_MIN,
103
+ ARENA_MAX - futureClockwise.x,
104
+ futureClockwise.y - ARENA_MIN,
105
+ ARENA_MAX - futureClockwise.y,
98
106
  );
99
107
  const scoreCounterClockwise = Math.min(
100
- futureCounterClockwise.x,
101
- ARENA_SIZE - futureCounterClockwise.x,
102
- futureCounterClockwise.y,
103
- ARENA_SIZE - futureCounterClockwise.y,
108
+ futureCounterClockwise.x - ARENA_MIN,
109
+ ARENA_MAX - futureCounterClockwise.x,
110
+ futureCounterClockwise.y - ARENA_MIN,
111
+ ARENA_MAX - futureCounterClockwise.y,
104
112
  );
105
113
  strafeDirection =
106
114
  scoreClockwise > scoreCounterClockwise
@@ -115,34 +123,34 @@ function combatMove(
115
123
 
116
124
  if (dodgeDirection)
117
125
  {
118
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
126
+ return {x: strafeDirection.x, y: strafeDirection.y};
119
127
  }
120
128
 
121
129
  let moveX = strafeDirection.x * strafeIntensity;
122
130
  let moveY = strafeDirection.y * strafeIntensity;
123
131
 
124
- if (currentDistance > targetDistance + distanceDeadZone)
132
+ if (currentDistance > targetDistance + distanceDeadZone)
125
133
  {
126
- moveX += (deltaX / normalizedDistance) * approachSpeed;
127
- moveY += (deltaY / normalizedDistance) * approachSpeed;
134
+ moveX += (approachDirX / approachMag) * approachSpeed;
135
+ moveY += (approachDirY / approachMag) * approachSpeed;
128
136
  }
129
137
  else if (currentDistance < targetDistance - distanceDeadZone)
130
138
  {
131
139
  let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
132
140
  let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
133
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
134
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
135
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
136
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
141
+ if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
142
+ if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0) retreatX = 0;
143
+ if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
144
+ if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0) retreatY = 0;
137
145
  moveX += retreatX;
138
146
  moveY += retreatY;
139
147
  }
140
148
 
141
149
  const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
142
- if (magnitude > 100)
150
+ if (magnitude > 1)
143
151
  {
144
- moveX = (moveX / magnitude) * 100;
145
- moveY = (moveY / magnitude) * 100;
152
+ moveX = moveX / magnitude;
153
+ moveY = moveY / magnitude;
146
154
  }
147
155
 
148
156
  return {x: moveX, y: moveY};
@@ -165,78 +173,91 @@ function combatMove(
165
173
  * PROGRESSION LINE: Battlemage → Warmage → Archmage
166
174
  * TIER: 3 (elite Duelist line)
167
175
  */
168
- export const Archmage: WizardFunction = ({state}) =>
176
+ export function Archmage(): FinalAction
169
177
  {
170
- const targetDistance = useParam('targetDistance', 214, {
178
+ const position = usePosition();
179
+ const enemy = useEnemy();
180
+ const status = useStatus();
181
+ const blinkCooldown = useBlinkCooldown();
182
+ const health = useHealth();
183
+ const tick = useTick();
184
+ const threats = useThreats();
185
+ const projectiles = useProjectiles();
186
+ const lastMissileConfig = useLastMissileConfig();
187
+ const castingSpell = useCastingSpell();
188
+ const castProgressData = useCastProgress();
189
+
190
+
191
+ const targetDistance = useParam('targetDistance', 341, {
171
192
  range: 150,
172
193
  min: 0,
173
194
  });
174
- const minTurnRate = useParam('minTurnRate', -14.6, {range: 1.5, steps: 5});
175
- const blinkEscapeDistance = useParam('blinkEscapeDistance', 442, {
195
+ const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, min: 0, steps: 5});
196
+ const blinkEscapeDistance = useParam('blinkEscapeDistance', 233, {
176
197
  range: 190,
177
198
  min: 0,
178
199
  steps: 7,
179
200
  });
180
- const punishMinVulnerability = useParam('punishMinVulnerability', 85, {
201
+ const punishMinVulnerability = useParam('punishMinVulnerability', 70, {
181
202
  range: 30,
182
203
  min: 10,
183
204
  steps: 5,
184
205
  });
185
- const aggressiveHPThreshold = useParam('aggressiveHPThreshold', 4, {
206
+ const aggressiveHPThreshold = useParam('aggressiveHPThreshold', 8, {
186
207
  range: 20,
187
208
  min: 0,
188
209
  steps: 5,
189
210
  });
190
211
  const preferBlinkDodge =
191
212
  useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
192
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 248, {
213
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 249, {
193
214
  range: 75,
194
215
  min: 80,
195
216
  max: 250,
196
217
  });
197
- const strafeIntensity = useParam('strafeIntensity', 100, {
198
- range: 40,
199
- min: 20,
200
- max: 100,
218
+ const strafeIntensity = useParam('strafeIntensity', 0.97, {
219
+ range: 0.4,
220
+ min: 0.2,
221
+ max: 1,
201
222
  steps: 7,
202
223
  });
203
- const approachSpeed = useParam('approachSpeed', 57, {
204
- range: 30,
205
- min: 10,
206
- max: 80,
224
+ const approachSpeed = useParam('approachSpeed', 0.7, {
225
+ range: 0.3,
226
+ min: 0.1,
227
+ max: 0.8,
207
228
  steps: 7,
208
229
  });
209
- const distanceDeadZone = useParam('distanceDeadZone', 37, {
230
+ const distanceDeadZone = useParam('distanceDeadZone', 30, {
210
231
  range: 30,
211
232
  min: 10,
212
233
  max: 80,
213
234
  steps: 7,
214
235
  });
215
- const aggressiveDistanceOffset = useParam('aggressiveDistanceOffset', 99, {
236
+ const aggressiveDistanceOffset = useParam('aggressiveDistanceOffset', 11, {
216
237
  range: 50,
217
238
  min: 0,
218
239
  max: 100,
219
240
  steps: 7,
220
241
  });
221
- const cautiousDistanceOffset = useParam('cautiousDistanceOffset', 42, {
242
+ const cautiousDistanceOffset = useParam('cautiousDistanceOffset', 31, {
222
243
  range: 50,
223
244
  min: 0,
224
245
  max: 120,
225
246
  steps: 7,
226
247
  });
227
- const cancelHeavyDmg = useParam('cancelHeavyDmg', 40, {
248
+ const cancelHeavyDmg = useParam('cancelHeavyDmg', 30, {
228
249
  range: 15,
229
250
  min: 5,
230
251
  max: 40,
231
252
  steps: 5,
232
253
  });
233
- const cancelMediumDmg = useParam('cancelMediumDmg', 20, {
254
+ const cancelMediumDmg = useParam('cancelMediumDmg', 21, {
234
255
  range: 10,
235
256
  min: 3,
236
257
  max: 25,
237
258
  steps: 5,
238
259
  });
239
- const cancelCastRatio = useParam('cancelCastRatio', 0.74, {
260
+ const cancelCastRatio = useParam('cancelCastRatio', 0.6, {
240
261
  range: 0.4,
241
262
  min: 0.3,
242
263
  max: 1.0,
@@ -247,19 +268,19 @@ export const Archmage: WizardFunction = ({state}) =>
247
268
  min: 50,
248
269
  max: 300,
249
270
  });
250
- const blinkWindowMax = useParam('blinkWindowMax', 29, {
271
+ const blinkWindowMax = useParam('blinkWindowMax', 69, {
251
272
  range: 40,
252
273
  min: 20,
253
274
  max: 120,
254
275
  steps: 7,
255
276
  });
256
- const blinkGapThreshold = useParam('blinkGapThreshold', 80, {
277
+ const blinkGapThreshold = useParam('blinkGapThreshold', 111, {
257
278
  range: 75,
258
279
  min: 20,
259
280
  max: 200,
260
281
  steps: 7,
261
282
  });
262
- const flightTimeDivisor = useParam('flightTimeDivisor', 4.5, {
283
+ const flightTimeDivisor = useParam('flightTimeDivisor', 8.9, {
263
284
  range: 4,
264
285
  min: 2,
265
286
  max: 12,
@@ -270,82 +291,92 @@ export const Archmage: WizardFunction = ({state}) =>
270
291
  50,
271
292
  {range: 100, min: 50, max: 300, steps: 7},
272
293
  );
273
- const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
274
- range: 25,
275
- min: 10,
276
- max: 80,
294
+ const centerBias = useParam('centerBias', 0.48, {
295
+ range: 0.4,
296
+ min: 0,
297
+ max: 0.8,
277
298
  steps: 5,
278
299
  });
300
+ const maxBlinkDistance = useParam('maxBlinkDistance', 200, {
301
+ range: 100,
302
+ min: 50,
303
+ max: 300,
304
+ steps: 7,
305
+ });
279
306
 
280
- const enemy = state.enemies[0];
281
- if (!enemy)
307
+ function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
282
308
  {
283
- return {move: {x: 0, y: 0}};
309
+ const dx = target.x - position.x;
310
+ const dy = target.y - position.y;
311
+ const dist = Math.sqrt(dx * dx + dy * dy);
312
+ if (dist <= maxBlinkDistance) return target;
313
+ const scale = maxBlinkDistance / dist;
314
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
284
315
  }
285
316
 
286
- const distance = distanceTo(state.position, enemy.position);
287
- const threats = getThreats(state);
317
+ const distance = distanceTo(position, enemy.position);
288
318
  const urgentThreat = getMostUrgentThreat(threats);
289
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
319
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
290
320
 
291
321
  // HP-aware distance: ahead → fight closer, behind → kite at range
292
- const hpDiff = state.health - enemy.health;
322
+ const hpDiff = health - enemy.health;
293
323
  const isAggressive = hpDiff >= -aggressiveHPThreshold;
294
324
  const effectiveTargetDistance = isAggressive
295
325
  ? targetDistance - aggressiveDistanceOffset
296
326
  : targetDistance + cautiousDistanceOffset;
297
327
 
298
328
  const move = combatMove(
299
- state.position,
329
+ position,
300
330
  enemy,
301
331
  distance,
302
332
  effectiveTargetDistance,
303
333
  dodgeDirection,
304
- state.tick,
334
+ tick,
305
335
  strafeCyclePeriod,
306
336
  strafeIntensity,
307
337
  approachSpeed,
308
338
  distanceDeadZone,
339
+ centerBias,
309
340
  );
310
-
341
+ const safeMove = keepOffWalls(position, move);
311
342
  // === STEP 1: CHANNELING ===
312
- if (state.state === 'channeling')
343
+ if (status === 'channeling')
313
344
  {
314
345
  if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
315
346
  {
316
- return {move, cancel: true};
347
+ return cancel().move(safeMove.x, safeMove.y);
317
348
  }
318
- return {move: {x: 0, y: 0}};
349
+ return idle();
319
350
  }
320
351
 
321
352
  // === STEP 2: PROGRESSIVE CAST-CANCEL ===
322
353
  if (
323
- state.state === 'casting' &&
324
- state.castingSpell === 'missile' &&
354
+ status === 'casting' &&
355
+ castingSpell === 'missile' &&
325
356
  urgentThreat &&
326
357
  !urgentThreat.bestDodgeDirection
327
358
  )
328
359
  {
329
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
360
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
330
361
  const defenseTime =
331
- state.blinkCooldown === 0
332
- ? BLINK_CAST_TIME
333
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
362
+ blinkCooldown === 0
363
+ ? RULES.BLINK_CAST_TIME
364
+ : RULES.SHIELD_CAST_TIME + SHIELD_BUFFER;
334
365
  const willArriveInWindow =
335
- urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
366
+ urgentThreat.ticksToImpact <= remainingCast + RULES.GCD_DURATION + defenseTime;
336
367
 
337
368
  if (willArriveInWindow)
338
369
  {
339
370
  const incomingDamage = urgentThreat.projectile.damage;
340
- const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
371
+ const castRatio = ((castProgressData?.current) ?? 0) / ((castProgressData?.total) ?? 1);
341
372
 
342
- if (incomingDamage >= state.health || incomingDamage >= cancelHeavyDmg)
373
+ if (incomingDamage >= health || incomingDamage >= cancelHeavyDmg)
343
374
  {
344
- return {move, cancel: true};
375
+ return cancel().move(safeMove.x, safeMove.y);
345
376
  }
346
377
  if (incomingDamage >= cancelMediumDmg && castRatio < cancelCastRatio)
347
378
  {
348
- return {move, cancel: true};
379
+ return cancel().move(safeMove.x, safeMove.y);
349
380
  }
350
381
  }
351
382
  }
@@ -353,72 +384,66 @@ export const Archmage: WizardFunction = ({state}) =>
353
384
  // === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
354
385
  if (
355
386
  preferBlinkDodge &&
356
- state.state === 'idle' &&
387
+ status === 'idle' &&
357
388
  urgentThreat &&
358
- state.blinkCooldown === 0 &&
389
+ blinkCooldown === 0 &&
359
390
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
360
- urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
391
+ urgentThreat.ticksToImpact >= RULES.BLINK_CAST_TIME &&
361
392
  urgentThreat.ticksToImpact <= blinkWindowMax
362
393
  )
363
394
  {
364
395
  // Aggressive: blink toward enemy to dodge + close gap
365
396
  // Defensive: blink away to dodge + increase distance
366
- if (isAggressive && distance > effectiveTargetDistance)
397
+ if (isAggressive && distance > effectiveTargetDistance)
367
398
  {
368
399
  const blinkTarget = getBlinkToDecreaseDistance(
369
- state.position,
400
+ position,
370
401
  enemy.position,
371
- state.projectiles,
402
+ projectiles,
372
403
  );
373
- if (blinkTarget)
404
+ if (blinkTarget)
374
405
  {
375
- return {
376
- move: {x: 0, y: 0},
377
- startCast: {spell: 'blink', target: blinkTarget},
378
- };
406
+ const capped = capBlinkDistance(blinkTarget);
407
+ return blink(capped.x, capped.y);
379
408
  }
380
409
  }
381
410
  const blinkTarget = getBlinkToIncreaseDistance(
382
- state.position,
411
+ position,
383
412
  enemy.position,
384
- state.projectiles,
413
+ projectiles,
385
414
  MIN_BLINK_ESCAPE_DISTANCE,
386
415
  );
387
- if (blinkTarget)
416
+ if (blinkTarget)
388
417
  {
389
- return {
390
- move: {x: 0, y: 0},
391
- startCast: {spell: 'blink', target: blinkTarget},
392
- };
418
+ const capped = capBlinkDistance(blinkTarget);
419
+ return blink(capped.x, capped.y);
393
420
  }
394
421
  }
395
422
 
396
423
  // === STEP 4: BLINK ESCAPE — enemy too close ===
397
424
  if (
398
- state.state === 'idle' &&
425
+ status === 'idle' &&
399
426
  distance < blinkEscapeDistance &&
400
- state.blinkCooldown === 0 &&
427
+ blinkCooldown === 0 &&
401
428
  (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
402
429
  )
403
430
  {
404
431
  const escapeTarget = getBlinkToIncreaseDistance(
405
- state.position,
432
+ position,
406
433
  enemy.position,
407
- state.projectiles,
434
+ projectiles,
408
435
  MIN_BLINK_ESCAPE_DISTANCE,
409
436
  );
410
- if (escapeTarget)
437
+ if (escapeTarget)
411
438
  {
412
- return {
413
- move: {x: 0, y: 0},
414
- startCast: {spell: 'blink', target: escapeTarget},
415
- };
439
+ const capped = capBlinkDistance(escapeTarget);
440
+ return blink(capped.x, capped.y);
416
441
  }
417
442
  }
418
443
 
419
444
  // === STEP 5: SHIELD ===
420
445
  if (
421
- state.state === 'idle' &&
446
+ status === 'idle' &&
422
447
  urgentThreat &&
423
448
  (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
424
449
  )
@@ -426,20 +451,38 @@ export const Archmage: WizardFunction = ({state}) =>
426
451
  if (
427
452
  urgentThreat.canBlockInTime &&
428
453
  urgentThreat.ticksToStartShield <= SHIELD_BUFFER &&
429
- (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
454
+ (blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
430
455
  )
431
456
  {
432
- return {
433
- move: {x: 0, y: 0},
434
- startCast: {spell: 'shield'},
435
- };
457
+ return shield();
458
+ }
459
+ }
460
+
461
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
462
+ if (
463
+ status === 'idle' &&
464
+ distance < 300 &&
465
+ enemy.status === 'casting' &&
466
+ enemy.castingSpell === 'missile'
467
+ )
468
+ {
469
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
470
+ const estimatedFlightTicks = distance / 8;
471
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
472
+
473
+ if (
474
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
475
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
476
+ )
477
+ {
478
+ return shield();
436
479
  }
437
480
  }
438
481
 
439
482
  // === STEP 6: VULNERABILITY BLINK — gap-close during enemy cast/GCD ===
440
483
  if (
441
- state.state === 'idle' &&
442
- state.blinkCooldown === 0 &&
484
+ status === 'idle' &&
485
+ blinkCooldown === 0 &&
443
486
  isAggressive &&
444
487
  distance > effectiveTargetDistance + blinkGapThreshold &&
445
488
  (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
@@ -449,27 +492,28 @@ export const Archmage: WizardFunction = ({state}) =>
449
492
  if (vulnerabilityTicks >= punishMinVulnerability)
450
493
  {
451
494
  const blinkTarget = getBlinkToDecreaseDistance(
452
- state.position,
495
+ position,
453
496
  enemy.position,
454
- state.projectiles,
497
+ projectiles,
455
498
  );
456
- if (blinkTarget)
499
+ if (blinkTarget)
457
500
  {
458
- return {
459
- move: {x: 0, y: 0},
460
- startCast: {spell: 'blink', target: blinkTarget},
461
- };
501
+ const capped = capBlinkDistance(blinkTarget);
502
+ return blink(capped.x, capped.y);
462
503
  }
463
504
  }
464
505
  }
465
506
 
466
507
  // === STEP 7: OFFENSE ===
467
- if (state.state === 'idle' && distance < 500)
508
+ if (status === 'idle' && distance < 500)
468
509
  {
510
+ const reposition = getPreCastReposition(position, enemy.position);
511
+ if (reposition) return moveAction(reposition.x, reposition.y);
512
+
469
513
  const offenseDefenseTime =
470
- state.blinkCooldown === 0
471
- ? BLINK_CAST_TIME
472
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
514
+ blinkCooldown === 0
515
+ ? RULES.BLINK_CAST_TIME
516
+ : RULES.SHIELD_CAST_TIME + SHIELD_BUFFER;
473
517
  const enemyHP = Math.ceil(enemy.health);
474
518
  const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
475
519
 
@@ -479,7 +523,7 @@ export const Archmage: WizardFunction = ({state}) =>
479
523
  const flightTimeEstimate = distance / flightTimeDivisor;
480
524
  const punishBudget = vulnerabilityTicks - flightTimeEstimate;
481
525
  const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
482
- const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
526
+ const safeBudget = nextThreatTime - RULES.GCD_DURATION - offenseDefenseTime;
483
527
  const effectiveBudget = Math.min(
484
528
  punishBudget,
485
529
  safeBudget,
@@ -493,12 +537,12 @@ export const Archmage: WizardFunction = ({state}) =>
493
537
  const config = fitMissileToBudget(effectiveBudget, distance, {
494
538
  minTurnRate: punishTurnRate,
495
539
  maxDamage: enemyHP,
496
- lastMissileConfig: state.lastMissileConfig,
540
+ lastMissileConfig: lastMissileConfig,
497
541
  });
498
542
 
499
543
  if (config && config.damage >= MIN_USEFUL_DAMAGE)
500
544
  {
501
- const castTime = getMissileCastTime(config, state.lastMissileConfig);
545
+ const castTime = getMissileCastTime(config, lastMissileConfig);
502
546
  const actualFlight = distance / config.speed;
503
547
 
504
548
  if (
@@ -513,17 +557,9 @@ export const Archmage: WizardFunction = ({state}) =>
513
557
  enemy.position,
514
558
  enemy.velocity,
515
559
  config.speed,
516
- state.position,
560
+ position,
517
561
  );
518
- return {
519
- move,
520
- startCast: {
521
- spell: 'missile',
522
- config,
523
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
524
- direction: angleTo(state.position, aimTarget),
525
- },
526
- };
562
+ return missile(config, config.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, aimTarget)).move(safeMove.x, safeMove.y);
527
563
  }
528
564
  }
529
565
  }
@@ -531,7 +567,7 @@ export const Archmage: WizardFunction = ({state}) =>
531
567
 
532
568
  // --- STANDARD MODE: range-adaptive missiles ---
533
569
  const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
534
- const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
570
+ const safeBudget = nextThreatTime - RULES.GCD_DURATION - offenseDefenseTime;
535
571
  const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
536
572
 
537
573
  // Adapt turn rate to distance: close → straight, far → homing
@@ -541,48 +577,32 @@ export const Archmage: WizardFunction = ({state}) =>
541
577
  let config = fitMissileToBudget(budgetTicks, distance, {
542
578
  minTurnRate: effectiveMinTurnRate,
543
579
  maxDamage: enemyHP,
544
- lastMissileConfig: state.lastMissileConfig,
580
+ lastMissileConfig: lastMissileConfig,
545
581
  });
546
582
 
547
- if (
548
- config &&
549
- config.damage >= minDmg &&
550
- isSafeToAttack(threats, safeAttackCastEstimate)
551
- )
583
+ if (config && config.damage >= minDmg)
552
584
  {
553
- return {
554
- move,
555
- startCast: {
556
- spell: 'missile',
557
- config,
558
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
559
- direction: angleTo(state.position, enemy.position),
560
- },
561
- };
585
+ const actualCastTime = getMissileCastTime(config, lastMissileConfig);
586
+ if (isSafeToAttack(threats, actualCastTime))
587
+ {
588
+ return missile(config, config.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
589
+ }
562
590
  }
563
591
 
564
- // Fallback: fire bigger missile, accept incoming hit
565
- if (!config || config.damage < minDmg)
592
+ // Fallback: fire bigger missile, accept incoming hit — but only if no undodgeable threat
593
+ if (!config || config.damage < minDmg)
566
594
  {
567
595
  config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
568
596
  minTurnRate: effectiveMinTurnRate,
569
597
  maxDamage: enemyHP,
570
- lastMissileConfig: state.lastMissileConfig,
598
+ lastMissileConfig: lastMissileConfig,
571
599
  });
572
- if (config)
600
+ if (config && (!urgentThreat || urgentThreat.bestDodgeDirection !== null))
573
601
  {
574
- return {
575
- move,
576
- startCast: {
577
- spell: 'missile',
578
- config,
579
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
580
- direction: angleTo(state.position, enemy.position),
581
- },
582
- };
602
+ return missile(config, config.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
583
603
  }
584
604
  }
585
605
  }
586
606
 
587
- return {move};
588
- };
607
+ return moveAction(safeMove.x, safeMove.y);
608
+ }