@vibemancer/core 0.1.0

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 (78) hide show
  1. package/README.md +28 -0
  2. package/dist/chunk-L7Z7OFXD.js +9140 -0
  3. package/dist/chunk-L7Z7OFXD.js.map +1 -0
  4. package/dist/index-browser.d.ts +2602 -0
  5. package/dist/index-browser.js +407 -0
  6. package/dist/index-browser.js.map +1 -0
  7. package/dist/index.d.ts +150 -0
  8. package/dist/index.js +750 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +79 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +457 -0
  12. package/src/bots/berserker/02_Stormcaller.ts +417 -0
  13. package/src/bots/berserker/03_Stormforger.ts +481 -0
  14. package/src/bots/caster/01_Flamecaller.ts +286 -0
  15. package/src/bots/caster/02_Pyromancer.ts +350 -0
  16. package/src/bots/caster/03_Infernalist.ts +492 -0
  17. package/src/bots/defensive/01_Turtle.ts +151 -0
  18. package/src/bots/defensive/02_Sentinel.ts +134 -0
  19. package/src/bots/defensive/03_Golem.ts +357 -0
  20. package/src/bots/duelist/01_Battlemage.ts +433 -0
  21. package/src/bots/duelist/02_Warmage.ts +438 -0
  22. package/src/bots/duelist/03_Archmage.ts +588 -0
  23. package/src/bots/homing/01_Bonemancer.ts +67 -0
  24. package/src/bots/homing/02_Lich.ts +356 -0
  25. package/src/bots/homing/03_Archlich.ts +220 -0
  26. package/src/bots/index.ts +30 -0
  27. package/src/bots/kiter/01_Spellspinner.ts +398 -0
  28. package/src/bots/kiter/02_Spellweaver.ts +378 -0
  29. package/src/bots/kiter/03_Spellbinder.ts +448 -0
  30. package/src/bots/melee/01_Shadowblade.ts +270 -0
  31. package/src/bots/melee/02_Nightblade.ts +437 -0
  32. package/src/bots/melee/03_Voidblade.ts +582 -0
  33. package/src/bots/registry.ts +207 -0
  34. package/src/bots/shared.ts +472 -0
  35. package/src/bots/sniper/01_Spellshot.ts +385 -0
  36. package/src/bots/sniper/02_Spelltracer.ts +441 -0
  37. package/src/bots/sniper/03_Spellseeker.ts +546 -0
  38. package/src/bots/standalone/Critter.ts +89 -0
  39. package/src/bots/standalone/Doombringer.ts +91 -0
  40. package/src/bots/standalone/Hogger.ts +228 -0
  41. package/src/bots/standalone/Rookie.ts +50 -0
  42. package/src/bots/standalone/TargetDummy.ts +21 -0
  43. package/src/bots/test/cheater.ts +405 -0
  44. package/src/bots/test/crasher.ts +81 -0
  45. package/src/engine/hooks-runtime.ts +394 -0
  46. package/src/engine/manual-match.ts +289 -0
  47. package/src/engine/missile-templates.ts +155 -0
  48. package/src/engine/optimizer.ts +220 -0
  49. package/src/engine/params-runtime.ts +189 -0
  50. package/src/engine/physics.ts +143 -0
  51. package/src/engine/sandbox-browser.ts +671 -0
  52. package/src/engine/sandbox-compile.ts +197 -0
  53. package/src/engine/sandbox-harness.ts +367 -0
  54. package/src/engine/sandbox.ts +332 -0
  55. package/src/engine/simulation.ts +828 -0
  56. package/src/engine/spells.ts +128 -0
  57. package/src/engine-version.ts +11 -0
  58. package/src/hooks/action-builders.ts +210 -0
  59. package/src/hooks/bot-wrapper.ts +84 -0
  60. package/src/hooks/index.ts +75 -0
  61. package/src/hooks/state-hooks.ts +354 -0
  62. package/src/hooks/threat-analysis.ts +365 -0
  63. package/src/hooks/types.ts +142 -0
  64. package/src/index-browser.ts +30 -0
  65. package/src/index.ts +24 -0
  66. package/src/rules.ts +254 -0
  67. package/src/stats.ts +262 -0
  68. package/src/testing.ts +207 -0
  69. package/src/trace.ts +430 -0
  70. package/src/types.ts +193 -0
  71. package/src/utils/angles.ts +47 -0
  72. package/src/utils/combat.ts +279 -0
  73. package/src/utils/distance.ts +21 -0
  74. package/src/utils/index.ts +7 -0
  75. package/src/utils/movement.ts +108 -0
  76. package/src/utils/random.ts +65 -0
  77. package/src/utils/spatial.ts +63 -0
  78. package/src/utils/targeting.ts +45 -0
@@ -0,0 +1,582 @@
1
+ import {WizardFunction} from '../../types.js';
2
+ import {distanceTo} from '../../utils/distance.js';
3
+ import {angleTo} from '../../utils/angles.js';
4
+ import {
5
+ ARENA_SIZE,
6
+ GCD_DURATION,
7
+ SHIELD_CAST_TIME,
8
+ BLINK_CAST_TIME,
9
+ } from '../../rules.js';
10
+ import {
11
+ getThreats,
12
+ getMostUrgentThreat,
13
+ getDodgeDirectionForMovement,
14
+ getBlinkToDecreaseDistance,
15
+ shouldForceShield,
16
+ fitMissileToBudget,
17
+ MIN_USEFUL_DAMAGE,
18
+ getEnemyVulnerabilityTicks,
19
+ } from '../shared.js';
20
+ import {getMissileCastTime} from '../../utils/combat.js';
21
+ import {useParam} from '../../engine/params-runtime.js';
22
+
23
+ const WALL_BUFFER = 50;
24
+ const SHIELD_BUFFER = 3;
25
+
26
+ /**
27
+ * Aggressive movement: dodge missiles, close distance, light strafe.
28
+ */
29
+ function aggressiveMove(
30
+ position: {x: number; y: number},
31
+ enemy: {position: {x: number; y: number}},
32
+ currentDistance: number,
33
+ dodgeDirection: {x: number; y: number} | null,
34
+ tick: number,
35
+ strafeCyclePeriod: number,
36
+ strafeIntensity: number,
37
+ approachSpeed: number,
38
+ ): {x: number; y: number}
39
+ {
40
+ const deltaX = enemy.position.x - position.x;
41
+ const deltaY = enemy.position.y - position.y;
42
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
43
+
44
+ const strafeClockwise = {
45
+ x: -deltaY / normalizedDistance,
46
+ y: deltaX / normalizedDistance,
47
+ };
48
+ const strafeCounterClockwise = {
49
+ x: deltaY / normalizedDistance,
50
+ y: -deltaX / normalizedDistance,
51
+ };
52
+
53
+ let strafeDirection: {x: number; y: number};
54
+
55
+ if (dodgeDirection)
56
+ {
57
+ strafeDirection = dodgeDirection;
58
+ }
59
+ else if (
60
+ position.x < WALL_BUFFER ||
61
+ position.x > ARENA_SIZE - WALL_BUFFER ||
62
+ position.y < WALL_BUFFER ||
63
+ position.y > ARENA_SIZE - WALL_BUFFER
64
+ )
65
+ {
66
+ const futureClockwise = {
67
+ x: position.x + strafeClockwise.x * 100,
68
+ y: position.y + strafeClockwise.y * 100,
69
+ };
70
+ const futureCounterClockwise = {
71
+ x: position.x + strafeCounterClockwise.x * 100,
72
+ y: position.y + strafeCounterClockwise.y * 100,
73
+ };
74
+ const scoreClockwise = Math.min(
75
+ futureClockwise.x,
76
+ ARENA_SIZE - futureClockwise.x,
77
+ futureClockwise.y,
78
+ ARENA_SIZE - futureClockwise.y,
79
+ );
80
+ const scoreCounterClockwise = Math.min(
81
+ futureCounterClockwise.x,
82
+ ARENA_SIZE - futureCounterClockwise.x,
83
+ futureCounterClockwise.y,
84
+ ARENA_SIZE - futureCounterClockwise.y,
85
+ );
86
+ strafeDirection =
87
+ scoreClockwise > scoreCounterClockwise
88
+ ? strafeClockwise
89
+ : strafeCounterClockwise;
90
+ }
91
+ else
92
+ {
93
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
94
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
95
+ }
96
+
97
+ if (dodgeDirection)
98
+ {
99
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
100
+ }
101
+
102
+ let moveX = strafeDirection.x * strafeIntensity;
103
+ let moveY = strafeDirection.y * strafeIntensity;
104
+
105
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
106
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
107
+
108
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
109
+ if (magnitude > 100)
110
+ {
111
+ moveX = (moveX / magnitude) * 100;
112
+ moveY = (moveY / magnitude) * 100;
113
+ }
114
+
115
+ return {x: moveX, y: moveY};
116
+ }
117
+
118
+ /**
119
+ * Bot: Voidblade
120
+ *
121
+ * BEHAVIOR: Reactive counter-puncher. Shields everything, then fires quick stabs
122
+ * during enemy vulnerability windows (GCD/casting) when they can't shield back.
123
+ * At melee range, the shield blocks ~90% of incoming damage while Voidblade's
124
+ * counter-stabs land at full damage — winning through attrition.
125
+ *
126
+ * CORE LOOP (melee range):
127
+ * 1. Enemy casts missile → Voidblade blink-dodges (100% avoid) or shields (90% block)
128
+ * 2. Enemy enters GCD → Voidblade fires quick stab (lands unblocked)
129
+ * 3. Voidblade enters GCD → enemy recovers → repeat
130
+ *
131
+ * KEY IMPROVEMENTS OVER NIGHTBLADE:
132
+ * - Blink-dodge priority: avoids 100% of damage when blink available, shields as fallback
133
+ * - Reads enemy vulnerability to time counter-stabs perfectly
134
+ * - Cancel-into-defense: aborts own cast if enemy missile incoming
135
+ * - Punish budget: sizes stabs to fit exactly in the vulnerability window
136
+ *
137
+ * PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
138
+ * TIER: 3 (elite Melee line)
139
+ */
140
+ export const Voidblade: WizardFunction = ({state}) =>
141
+ {
142
+ const preemptiveShieldRange = useParam('preemptiveShieldRange', 348, {
143
+ range: 85,
144
+ min: 50,
145
+ steps: 7,
146
+ });
147
+ const shieldWindow = useParam('shieldWindow', 18, {
148
+ range: 10,
149
+ min: 5,
150
+ max: 30,
151
+ steps: 5,
152
+ });
153
+ const punishMinVulnerability = useParam('punishMinVulnerability', 10, {
154
+ range: 15,
155
+ min: 10,
156
+ steps: 5,
157
+ });
158
+ const preferBlinkDodge =
159
+ useParam('preferBlinkDodge', 0, {min: 0, max: 1, steps: 2}) >= 0.5;
160
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
161
+ range: 75,
162
+ min: 80,
163
+ max: 250,
164
+ });
165
+ const strafeIntensity = useParam('strafeIntensity', 45, {
166
+ range: 30,
167
+ min: 10,
168
+ max: 80,
169
+ steps: 7,
170
+ });
171
+ const approachSpeed = useParam('approachSpeed', 92, {
172
+ range: 20,
173
+ min: 50,
174
+ max: 100,
175
+ steps: 7,
176
+ });
177
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
178
+ range: 75,
179
+ min: 50,
180
+ max: 300,
181
+ });
182
+ const blinkWindowMax = useParam('blinkWindowMax', 40, {
183
+ range: 30,
184
+ min: 15,
185
+ max: 80,
186
+ steps: 7,
187
+ });
188
+ const blinkPastMargin = useParam('blinkPastMargin', 50, {
189
+ range: 30,
190
+ min: 20,
191
+ max: 100,
192
+ steps: 7,
193
+ });
194
+ const blinkPastDistance = useParam('blinkPastDistance', 200, {
195
+ range: 100,
196
+ min: 50,
197
+ max: 350,
198
+ steps: 7,
199
+ });
200
+ const engageBlinkRange = useParam('engageBlinkRange', 30, {
201
+ range: 75,
202
+ min: 30,
203
+ max: 200,
204
+ steps: 7,
205
+ });
206
+ const counterStabRange = useParam('counterStabRange', 27, {
207
+ range: 20,
208
+ min: 10,
209
+ max: 60,
210
+ steps: 7,
211
+ });
212
+ const flightTimeDivisor = useParam('flightTimeDivisor', 7.5, {
213
+ range: 4,
214
+ min: 3,
215
+ max: 12,
216
+ steps: 5,
217
+ });
218
+ const enemyFlightTimeDivisor = useParam('enemyFlightTimeDivisor', 10, {
219
+ range: 4,
220
+ min: 2,
221
+ max: 10,
222
+ steps: 5,
223
+ });
224
+ const idleProvokeBudget = useParam('idleProvokeBudget', 37, {
225
+ range: 40,
226
+ min: 20,
227
+ max: 120,
228
+ steps: 7,
229
+ });
230
+ const meleeAbortRange = useParam('meleeAbortRange', 50, {
231
+ range: 50,
232
+ min: 50,
233
+ max: 200,
234
+ steps: 7,
235
+ });
236
+ const enemyFiringSoonThreshold = useParam('enemyFiringSoonThreshold', 50, {
237
+ range: 30,
238
+ min: 10,
239
+ max: 100,
240
+ steps: 7,
241
+ });
242
+
243
+ const enemy = state.enemies[0];
244
+ if (!enemy)
245
+ {
246
+ return {move: {x: 0, y: 0}};
247
+ }
248
+
249
+ const distance = distanceTo(state.position, enemy.position);
250
+ const threats = getThreats(state);
251
+ const urgentThreat = getMostUrgentThreat(threats);
252
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
253
+ const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
254
+
255
+ const move = aggressiveMove(
256
+ state.position,
257
+ enemy,
258
+ distance,
259
+ dodgeDirection,
260
+ state.tick,
261
+ strafeCyclePeriod,
262
+ strafeIntensity,
263
+ approachSpeed,
264
+ );
265
+
266
+ // Precompute blink targets — used by blink-dodge and engage-blink.
267
+ // blinkCloseTarget: from getBlinkToDecreaseDistance (dodge + close gap). Null at melee range.
268
+ // blinkPastTarget: blink ~200u past enemy (dodge only, loses position). For in-flight missiles only.
269
+ let blinkCloseTarget: {x: number; y: number} | null = null;
270
+ let blinkPastTarget: {x: number; y: number} | null = null;
271
+ if (state.blinkCooldown === 0)
272
+ {
273
+ blinkCloseTarget = getBlinkToDecreaseDistance(
274
+ state.position,
275
+ enemy.position,
276
+ state.projectiles,
277
+ );
278
+ if (!blinkCloseTarget)
279
+ {
280
+ const dx = enemy.position.x - state.position.x;
281
+ const dy = enemy.position.y - state.position.y;
282
+ const norm = Math.sqrt(dx * dx + dy * dy) || 1;
283
+ blinkPastTarget = {
284
+ x: Math.max(
285
+ blinkPastMargin,
286
+ Math.min(
287
+ ARENA_SIZE - blinkPastMargin,
288
+ state.position.x + (dx / norm) * blinkPastDistance,
289
+ ),
290
+ ),
291
+ y: Math.max(
292
+ blinkPastMargin,
293
+ Math.min(
294
+ ARENA_SIZE - blinkPastMargin,
295
+ state.position.y + (dy / norm) * blinkPastDistance,
296
+ ),
297
+ ),
298
+ };
299
+ }
300
+ }
301
+
302
+ // === STEP 1: CHANNELING — hold shield until missile passes ===
303
+ if (state.state === 'channeling')
304
+ {
305
+ // Hold shield while there's an active threat OR enemy is about to fire
306
+ const enemyFiringSoon =
307
+ distance < preemptiveShieldRange &&
308
+ enemy.state === 'casting' &&
309
+ enemy.castingSpell === 'missile' &&
310
+ (enemy.castDuration ?? 0) -
311
+ (enemy.castProgress ?? 0) +
312
+ distance / enemyFlightTimeDivisor <
313
+ enemyFiringSoonThreshold;
314
+
315
+ if (
316
+ (!urgentThreat && !enemyFiringSoon) ||
317
+ (urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
318
+ )
319
+ {
320
+ return {move, cancel: true};
321
+ }
322
+ return {move: {x: 0, y: 0}};
323
+ }
324
+
325
+ // === STEP 2: CAST-CANCEL INTO DEFENSE ===
326
+ // If we're casting a stab and a missile is incoming, cancel so we can
327
+ // blink-dodge (if available) or shield. Defense time depends on blink availability.
328
+ if (state.state === 'casting' && state.castingSpell === 'missile')
329
+ {
330
+ // Cancel if target escaped melee range
331
+ if (distance > meleeAbortRange)
332
+ {
333
+ return {move, cancel: true};
334
+ }
335
+
336
+ // Defense time: blink (10t) is faster than shield (20t + 3t buffer)
337
+ const defenseTime =
338
+ state.blinkCooldown === 0
339
+ ? BLINK_CAST_TIME
340
+ : SHIELD_CAST_TIME + SHIELD_BUFFER;
341
+
342
+ // Cancel for any undodgeable incoming threat we can't finish casting before
343
+ if (urgentThreat && !urgentThreat.bestDodgeDirection)
344
+ {
345
+ const remainingCast =
346
+ (state.castDuration ?? 0) - (state.castProgress ?? 0);
347
+ const willArriveInWindow =
348
+ urgentThreat.ticksToImpact <=
349
+ remainingCast + GCD_DURATION + defenseTime;
350
+ if (willArriveInWindow)
351
+ {
352
+ return {move, cancel: true};
353
+ }
354
+ }
355
+
356
+ // Cancel when enemy starts casting a missile at close range —
357
+ // at melee range their missile will arrive too fast to react after our cast finishes
358
+ if (
359
+ distance < preemptiveShieldRange &&
360
+ enemy.state === 'casting' &&
361
+ enemy.castingSpell === 'missile'
362
+ )
363
+ {
364
+ const remainingEnemyCast =
365
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
366
+ const remainingOurCast =
367
+ (state.castDuration ?? 0) - (state.castProgress ?? 0);
368
+ const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
369
+ const ticksUntilEnemyHit = remainingEnemyCast + estimatedFlightTicks;
370
+ const ticksAfterOurCast = remainingOurCast + GCD_DURATION + defenseTime;
371
+
372
+ // If enemy missile will arrive before we can defend after our cast, cancel now
373
+ if (ticksUntilEnemyHit < ticksAfterOurCast)
374
+ {
375
+ return {move, cancel: true};
376
+ }
377
+ }
378
+ }
379
+
380
+ // === STEP 3: BLINK-DODGE — 100% damage avoidance (preferred over shield) ===
381
+ if (state.state === 'idle' && state.blinkCooldown === 0)
382
+ {
383
+ // Blink target for dodging: close-gap target if available, else blink-past for in-flight only
384
+ const dodgeBlinkTarget = blinkCloseTarget ?? blinkPastTarget;
385
+
386
+ // 3a: Blink-dodge an in-flight missile (missile already fired, can't be cancelled)
387
+ if (
388
+ preferBlinkDodge &&
389
+ dodgeBlinkTarget &&
390
+ urgentThreat &&
391
+ urgentThreat.ticksToImpact >= 10 &&
392
+ urgentThreat.ticksToImpact <= blinkWindowMax
393
+ )
394
+ {
395
+ return {
396
+ move: {x: 0, y: 0},
397
+ startCast: {spell: 'blink', target: dodgeBlinkTarget},
398
+ };
399
+ }
400
+
401
+ // 3b: Preemptive blink-dodge — only when we have a close-gap target.
402
+ // At melee range, preemptive blink loses positioning (200u walk back) and
403
+ // the enemy can cancel their cast. Shield is better for preemptive defense.
404
+ if (
405
+ preferBlinkDodge &&
406
+ blinkCloseTarget &&
407
+ distance < preemptiveShieldRange &&
408
+ enemy.state === 'casting' &&
409
+ enemy.castingSpell === 'missile'
410
+ )
411
+ {
412
+ const remainingCast =
413
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
414
+ const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
415
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
416
+
417
+ if (
418
+ ticksUntilHit >= BLINK_CAST_TIME &&
419
+ ticksUntilHit < BLINK_CAST_TIME + shieldWindow
420
+ )
421
+ {
422
+ return {
423
+ move: {x: 0, y: 0},
424
+ startCast: {spell: 'blink', target: blinkCloseTarget},
425
+ };
426
+ }
427
+ }
428
+
429
+ // 3c: Engage blink — close distance during enemy vulnerability (they can't react)
430
+ if (
431
+ blinkCloseTarget &&
432
+ distance > engageBlinkRange &&
433
+ vulnerabilityTicks >= punishMinVulnerability &&
434
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
435
+ )
436
+ {
437
+ return {
438
+ move: {x: 0, y: 0},
439
+ startCast: {spell: 'blink', target: blinkCloseTarget},
440
+ };
441
+ }
442
+ }
443
+
444
+ // === STEP 4: SHIELD — fallback when blink can't help (90% block) ===
445
+
446
+ // 4a: Shield in-flight missiles
447
+ // Shield when: no blink target at all, or missile too close for blink (<10 ticks)
448
+ if (
449
+ state.state === 'idle' &&
450
+ urgentThreat &&
451
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
452
+ urgentThreat.canBlockInTime &&
453
+ ((!blinkCloseTarget && !blinkPastTarget) || urgentThreat.ticksToImpact < 10)
454
+ )
455
+ {
456
+ return {
457
+ move: {x: 0, y: 0},
458
+ startCast: {spell: 'shield'},
459
+ };
460
+ }
461
+
462
+ // 4b: Preemptive shield — when blink can't close gap (melee range or cooldown)
463
+ if (
464
+ state.state === 'idle' &&
465
+ !blinkCloseTarget &&
466
+ distance < preemptiveShieldRange &&
467
+ enemy.state === 'casting' &&
468
+ enemy.castingSpell === 'missile'
469
+ )
470
+ {
471
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
472
+ const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
473
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
474
+
475
+ if (
476
+ ticksUntilHit >= SHIELD_CAST_TIME &&
477
+ ticksUntilHit < SHIELD_CAST_TIME + shieldWindow
478
+ )
479
+ {
480
+ return {
481
+ move: {x: 0, y: 0},
482
+ startCast: {spell: 'shield'},
483
+ };
484
+ }
485
+ }
486
+
487
+ // === STEP 5: COUNTER-STAB — fire during enemy vulnerability ===
488
+ // Only fire when enemy CAN'T shield (in GCD or mid-cast).
489
+ // Use small, fast stabs that fit within the vulnerability window.
490
+ if (state.state === 'idle' && distance < counterStabRange)
491
+ {
492
+ const enemyHP = Math.ceil(enemy.health);
493
+ const flightTime = Math.ceil(distance / flightTimeDivisor) + 1;
494
+
495
+ if (vulnerabilityTicks >= punishMinVulnerability)
496
+ {
497
+ // Budget: stab must land while enemy is still vulnerable
498
+ // castTime + flightTime < vulnerabilityTicks
499
+ const punishBudget = vulnerabilityTicks - flightTime;
500
+
501
+ // Also need time to shield if another missile is incoming
502
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
503
+
504
+ // Account for enemy's active cast — their missile will arrive after cast completes + flight time
505
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
506
+ {
507
+ const remainingEnemyCast =
508
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
509
+ const estimatedFlightTime = distance / enemyFlightTimeDivisor;
510
+ nextThreatTime = Math.min(
511
+ nextThreatTime,
512
+ remainingEnemyCast + estimatedFlightTime,
513
+ );
514
+ }
515
+
516
+ // Defense time: blink (10t) is faster than shield (20t + 3t buffer)
517
+ const defenseTime =
518
+ state.blinkCooldown === 0
519
+ ? BLINK_CAST_TIME
520
+ : SHIELD_CAST_TIME + SHIELD_BUFFER;
521
+ const safeBudget = nextThreatTime - GCD_DURATION - defenseTime;
522
+ const effectiveBudget = Math.min(punishBudget, safeBudget);
523
+
524
+ if (effectiveBudget > 0)
525
+ {
526
+ const config = fitMissileToBudget(effectiveBudget, distance, {
527
+ minTurnRate: 0,
528
+ maxDamage: enemyHP,
529
+ lastMissileConfig: state.lastMissileConfig,
530
+ });
531
+
532
+ if (config && config.damage >= MIN_USEFUL_DAMAGE)
533
+ {
534
+ const castTime = getMissileCastTime(config, state.lastMissileConfig);
535
+ if (castTime + flightTime <= vulnerabilityTicks)
536
+ {
537
+ return {
538
+ move,
539
+ startCast: {
540
+ spell: 'missile',
541
+ config,
542
+ missileAI: () => ({}),
543
+ direction: angleTo(state.position, enemy.position),
544
+ },
545
+ };
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ // Enemy is idle or channeling shield at close range — fire a quick stab to provoke.
552
+ // Idle: they must shield or eat damage.
553
+ // Channeling: shield blocks ~90% but 10% still lands. Stabs whittle HP so they
554
+ // can't just hold shield forever. Why: otherwise the bot waits for a
555
+ // vulnerability window that a channeling player never opens.
556
+ if ((enemy.state === 'idle' || enemy.state === 'channeling') && !urgentThreat)
557
+ {
558
+ // Use a small budget to keep stabs fast and stay reactive
559
+ const quickBudget = idleProvokeBudget;
560
+ const config = fitMissileToBudget(quickBudget, distance, {
561
+ minTurnRate: 0,
562
+ maxDamage: enemyHP,
563
+ lastMissileConfig: state.lastMissileConfig,
564
+ });
565
+
566
+ if (config)
567
+ {
568
+ return {
569
+ move,
570
+ startCast: {
571
+ spell: 'missile',
572
+ config,
573
+ missileAI: () => ({}),
574
+ direction: angleTo(state.position, enemy.position),
575
+ },
576
+ };
577
+ }
578
+ }
579
+ }
580
+
581
+ return {move};
582
+ };