@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,286 @@
1
+ import {WizardFunction, MissileAIFunction} from '../../types.js';
2
+ import {angleTo} from '../../utils/angles.js';
3
+ import {distanceTo} from '../../utils/distance.js';
4
+ import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
+ import {getMissileCastTime} from '../../utils/combat.js';
6
+ import {
7
+ getThreats,
8
+ getMostUrgentThreat,
9
+ getDodgeDirectionForMovement,
10
+ isSafeToAttack,
11
+ getBlinkToCenter,
12
+ shouldForceShield,
13
+ } from '../shared.js';
14
+ import {useParam} from '../../engine/params-runtime.js';
15
+
16
+ const WALL_BUFFER = 60;
17
+
18
+ const SHIELD_BUFFER = 3;
19
+
20
+ // Fixed homing missile: damage 12, speed 5, turnRate 1, duration 180
21
+ // Range: 5 × 180 = 900 units (covers arena)
22
+ const MISSILE_CONFIG = {damage: 12, speed: 5, turnRate: 1, duration: 180};
23
+
24
+ const HomingAI: MissileAIFunction = ({worldState}) =>
25
+ {
26
+ const enemy = worldState.enemies[0];
27
+ return {turnToward: enemy?.position};
28
+ };
29
+
30
+ function isNearWall(position: {x: number; y: number}): boolean
31
+ {
32
+ return (
33
+ position.x < WALL_BUFFER ||
34
+ position.x > ARENA_SIZE - WALL_BUFFER ||
35
+ position.y < WALL_BUFFER ||
36
+ position.y > ARENA_SIZE - WALL_BUFFER
37
+ );
38
+ }
39
+
40
+ function smartStrafe(
41
+ position: {x: number; y: number},
42
+ enemy: {position: {x: number; y: number}},
43
+ dodgeDirection: {x: number; y: number} | null,
44
+ tick: number,
45
+ strafeCyclePeriod: number,
46
+ strafeIntensity: number,
47
+ ): {x: number; y: number}
48
+ {
49
+ const deltaX = enemy.position.x - position.x;
50
+ const deltaY = enemy.position.y - position.y;
51
+ const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
52
+ if (normalizedDistance === 0) return {x: 0, y: 0};
53
+
54
+ const strafeClockwise = {
55
+ x: -deltaY / normalizedDistance,
56
+ y: deltaX / normalizedDistance,
57
+ };
58
+ const strafeCounterClockwise = {
59
+ x: deltaY / normalizedDistance,
60
+ y: -deltaX / normalizedDistance,
61
+ };
62
+
63
+ let strafeDir: {x: number; y: number};
64
+
65
+ if (dodgeDirection)
66
+ {
67
+ strafeDir = dodgeDirection;
68
+ }
69
+ else if (isNearWall(position))
70
+ {
71
+ const futureClockwise = {
72
+ x: position.x + strafeClockwise.x * 100,
73
+ y: position.y + strafeClockwise.y * 100,
74
+ };
75
+ const futureCounterClockwise = {
76
+ x: position.x + strafeCounterClockwise.x * 100,
77
+ y: position.y + strafeCounterClockwise.y * 100,
78
+ };
79
+ const scoreClockwise = Math.min(
80
+ futureClockwise.x,
81
+ ARENA_SIZE - futureClockwise.x,
82
+ futureClockwise.y,
83
+ ARENA_SIZE - futureClockwise.y,
84
+ );
85
+ const scoreCounterClockwise = Math.min(
86
+ futureCounterClockwise.x,
87
+ ARENA_SIZE - futureCounterClockwise.x,
88
+ futureCounterClockwise.y,
89
+ ARENA_SIZE - futureCounterClockwise.y,
90
+ );
91
+ strafeDir =
92
+ scoreClockwise > scoreCounterClockwise
93
+ ? strafeClockwise
94
+ : strafeCounterClockwise;
95
+ }
96
+ else
97
+ {
98
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
99
+ strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
100
+ }
101
+
102
+ const intensity = dodgeDirection ? 100 : strafeIntensity;
103
+ return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
104
+ }
105
+
106
+ /**
107
+ * Bot: Flamecaller
108
+ *
109
+ * BEHAVIOR: Long-range homing missile caster with fixed missile config.
110
+ * Maintains 350u distance, strafes to dodge, and fires standard homing
111
+ * missiles (d=10, s=5, t=1, dur=180). Shields undodgeable threats,
112
+ * emergency blinks. A straightforward ranged caster that trades
113
+ * consistency for adaptability.
114
+ *
115
+ * PROGRESSION LINE: Flamecaller → Pyromancer → Infernalist
116
+ * - Flamecaller (tier 1): Fixed homing missiles, basic strafe and defense
117
+ * - Pyromancer (tier 2): + adaptive fitting, cast canceling, smart fallbacks
118
+ * - Infernalist (tier 3): Future — overwhelming adaptive fire
119
+ *
120
+ * TIER: 1 (base)
121
+ */
122
+ export const Flamecaller: WizardFunction = ({state}) =>
123
+ {
124
+ const targetDistance = useParam('targetDistance', 753, {
125
+ range: 150,
126
+ min: 0,
127
+ });
128
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 273, {
129
+ range: 75,
130
+ min: 80,
131
+ max: 300,
132
+ });
133
+ const strafeIntensity = useParam('strafeIntensity', 20, {
134
+ range: 40,
135
+ min: 20,
136
+ max: 100,
137
+ steps: 7,
138
+ });
139
+ const approachSpeed = useParam('approachSpeed', 40, {
140
+ range: 30,
141
+ min: 10,
142
+ max: 70,
143
+ steps: 7,
144
+ });
145
+ const distanceDeadZone = useParam('distanceDeadZone', 56, {
146
+ range: 30,
147
+ min: 5,
148
+ max: 80,
149
+ steps: 7,
150
+ });
151
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
152
+ range: 75,
153
+ min: 50,
154
+ max: 300,
155
+ });
156
+ const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
157
+ range: 25,
158
+ min: 10,
159
+ max: 80,
160
+ steps: 5,
161
+ });
162
+
163
+ const enemy = state.enemies[0];
164
+ if (!enemy)
165
+ {
166
+ return {move: {x: 0, y: 0}};
167
+ }
168
+
169
+ const distance = distanceTo(state.position, enemy.position);
170
+
171
+ const threats = getThreats(state);
172
+ const urgentThreat = getMostUrgentThreat(threats);
173
+
174
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
175
+ const strafe = smartStrafe(
176
+ state.position,
177
+ enemy,
178
+ dodgeDirection,
179
+ state.tick,
180
+ strafeCyclePeriod,
181
+ strafeIntensity,
182
+ );
183
+
184
+ // Distance management (retreat if too close, approach if too far)
185
+ let moveX = strafe.x;
186
+ let moveY = strafe.y;
187
+
188
+ if (!dodgeDirection)
189
+ {
190
+ const deltaX = enemy.position.x - state.position.x;
191
+ const deltaY = enemy.position.y - state.position.y;
192
+ const normalizedDistance = distance > 0 ? distance : 1;
193
+
194
+ if (distance > targetDistance + distanceDeadZone)
195
+ {
196
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
197
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
198
+ }
199
+ else if (distance < targetDistance - distanceDeadZone)
200
+ {
201
+ moveX -= (deltaX / normalizedDistance) * approachSpeed;
202
+ moveY -= (deltaY / normalizedDistance) * approachSpeed;
203
+ }
204
+ }
205
+
206
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
207
+ if (magnitude > 100)
208
+ {
209
+ moveX = (moveX / magnitude) * 100;
210
+ moveY = (moveY / magnitude) * 100;
211
+ }
212
+
213
+ const move = {x: moveX, y: moveY};
214
+
215
+ // === DEFENSE: Handle channeling ===
216
+ if (state.state === 'channeling')
217
+ {
218
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
219
+ {
220
+ return {move, cancel: true};
221
+ }
222
+ return {move: {x: 0, y: 0}};
223
+ }
224
+
225
+ // === DEFENSE: Let shield cast finish ===
226
+ if (state.state === 'casting' && state.castingSpell === 'shield')
227
+ {
228
+ return {move: {x: 0, y: 0}};
229
+ }
230
+
231
+ // === DEFENSE: Shield undodgeable or high-damage homing threats ===
232
+ if (
233
+ state.state === 'idle' &&
234
+ urgentThreat &&
235
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
236
+ )
237
+ {
238
+ if (urgentThreat.canBlockInTime)
239
+ {
240
+ if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
241
+ {
242
+ return {
243
+ move: {x: 0, y: 0},
244
+ startCast: {spell: 'shield'},
245
+ };
246
+ }
247
+ }
248
+ else if (state.blinkCooldown === 0)
249
+ {
250
+ return {
251
+ move: {x: 0, y: 0},
252
+ startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
253
+ };
254
+ }
255
+ }
256
+
257
+ // === OFFENSE: Fire fixed homing missile when safe ===
258
+ if (state.state === 'idle')
259
+ {
260
+ const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
261
+ const castTime = getMissileCastTime(
262
+ MISSILE_CONFIG,
263
+ state.lastMissileConfig,
264
+ );
265
+ const safeWindow =
266
+ castTime + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER;
267
+
268
+ if (
269
+ nextThreatTime > safeWindow &&
270
+ isSafeToAttack(threats, safeAttackCastEstimate)
271
+ )
272
+ {
273
+ return {
274
+ move,
275
+ startCast: {
276
+ spell: 'missile',
277
+ config: MISSILE_CONFIG,
278
+ missileAI: HomingAI,
279
+ direction: angleTo(state.position, enemy.position),
280
+ },
281
+ };
282
+ }
283
+ }
284
+
285
+ return {move};
286
+ };
@@ -0,0 +1,350 @@
1
+ import {WizardFunction, MissileAIFunction} from '../../types.js';
2
+ import {angleTo} from '../../utils/angles.js';
3
+ import {distanceTo} from '../../utils/distance.js';
4
+ import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
+ import {
6
+ getThreats,
7
+ getMostUrgentThreat,
8
+ getDodgeDirectionForMovement,
9
+ isSafeToAttack,
10
+ getBlinkToCenter,
11
+ fitMissileToBudget,
12
+ shouldForceShield,
13
+ MAX_CAST_BUDGET,
14
+ MIN_USEFUL_DAMAGE,
15
+ } from '../shared.js';
16
+ import {useParam} from '../../engine/params-runtime.js';
17
+ const WALL_BUFFER = 60;
18
+
19
+ const SHIELD_BUFFER = 3;
20
+
21
+ const HomingAI: MissileAIFunction = ({worldState}) =>
22
+ {
23
+ const enemy = worldState.enemies[0];
24
+ return {turnToward: enemy?.position};
25
+ };
26
+
27
+ function isNearWall(position: {x: number; y: number}): boolean
28
+ {
29
+ return (
30
+ position.x < WALL_BUFFER ||
31
+ position.x > ARENA_SIZE - WALL_BUFFER ||
32
+ position.y < WALL_BUFFER ||
33
+ position.y > ARENA_SIZE - WALL_BUFFER
34
+ );
35
+ }
36
+
37
+ function smartStrafe(
38
+ position: {x: number; y: number},
39
+ enemy: {position: {x: number; y: number}},
40
+ dodgeDirection: {x: number; y: number} | null,
41
+ tick: number,
42
+ strafeCyclePeriod: number,
43
+ strafeIntensity: number,
44
+ ): {x: number; y: number}
45
+ {
46
+ const deltaX = enemy.position.x - position.x;
47
+ const deltaY = enemy.position.y - position.y;
48
+ const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
49
+ if (normalizedDistance === 0) return {x: 0, y: 0};
50
+
51
+ const strafeClockwise = {
52
+ x: -deltaY / normalizedDistance,
53
+ y: deltaX / normalizedDistance,
54
+ };
55
+ const strafeCounterClockwise = {
56
+ x: deltaY / normalizedDistance,
57
+ y: -deltaX / normalizedDistance,
58
+ };
59
+
60
+ let strafeDir: {x: number; y: number};
61
+
62
+ if (dodgeDirection)
63
+ {
64
+ strafeDir = dodgeDirection;
65
+ }
66
+ else if (isNearWall(position))
67
+ {
68
+ const futureClockwise = {
69
+ x: position.x + strafeClockwise.x * 100,
70
+ y: position.y + strafeClockwise.y * 100,
71
+ };
72
+ const futureCounterClockwise = {
73
+ x: position.x + strafeCounterClockwise.x * 100,
74
+ y: position.y + strafeCounterClockwise.y * 100,
75
+ };
76
+ const scoreClockwise = Math.min(
77
+ futureClockwise.x,
78
+ ARENA_SIZE - futureClockwise.x,
79
+ futureClockwise.y,
80
+ ARENA_SIZE - futureClockwise.y,
81
+ );
82
+ const scoreCounterClockwise = Math.min(
83
+ futureCounterClockwise.x,
84
+ ARENA_SIZE - futureCounterClockwise.x,
85
+ futureCounterClockwise.y,
86
+ ARENA_SIZE - futureCounterClockwise.y,
87
+ );
88
+ strafeDir =
89
+ scoreClockwise > scoreCounterClockwise
90
+ ? strafeClockwise
91
+ : strafeCounterClockwise;
92
+ }
93
+ else
94
+ {
95
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
96
+ strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
97
+ }
98
+
99
+ const intensity = dodgeDirection ? 100 : strafeIntensity;
100
+ return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
101
+ }
102
+
103
+ /**
104
+ * Bot: Pyromancer
105
+ *
106
+ * BEHAVIOR: Adaptive homing missile specialist at long range. Maintains 400u
107
+ * distance, strafes to dodge, and uses fitMissileToBudget with minTurnRate 0.5
108
+ * to fire the highest-damage homing missile that fits in the safe window.
109
+ * Shields undodgeable threats, emergency blinks. A versatile ranged caster
110
+ * that adapts its missiles to the situation.
111
+ *
112
+ * PROGRESSION LINE: Flamecaller → Pyromancer → Infernalist
113
+ * - Flamecaller (tier 1): Fixed homing missiles, basic strafe and defense
114
+ * - Pyromancer (tier 2): + adaptive fitting, cast canceling, smart fallbacks
115
+ * - Infernalist (tier 3): Future — overwhelming adaptive fire
116
+ *
117
+ * TIER: 2 (enhanced Flamecaller)
118
+ */
119
+ export const Pyromancer: WizardFunction = ({state}) =>
120
+ {
121
+ const targetDistance = useParam('targetDistance', 643, {
122
+ range: 225,
123
+ min: 0,
124
+ });
125
+ const minTurnRate = useParam('minTurnRate', 0.2, {range: 1.5, steps: 5});
126
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 129, {
127
+ range: 75,
128
+ min: 80,
129
+ max: 300,
130
+ });
131
+ const strafeIntensity = useParam('strafeIntensity', 33, {
132
+ range: 40,
133
+ min: 20,
134
+ max: 100,
135
+ steps: 7,
136
+ });
137
+ const approachSpeed = useParam('approachSpeed', 69, {
138
+ range: 30,
139
+ min: 10,
140
+ max: 70,
141
+ steps: 7,
142
+ });
143
+ const distanceDeadZone = useParam('distanceDeadZone', 18, {
144
+ range: 30,
145
+ min: 5,
146
+ max: 80,
147
+ steps: 7,
148
+ });
149
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
150
+ range: 75,
151
+ min: 50,
152
+ max: 300,
153
+ });
154
+ const blinkWindowMax = useParam('blinkWindowMax', 26, {
155
+ range: 40,
156
+ min: 20,
157
+ max: 120,
158
+ steps: 7,
159
+ });
160
+ const flightTimeDivisor = useParam('flightTimeDivisor', 5.3, {
161
+ range: 4,
162
+ min: 2,
163
+ max: 12,
164
+ steps: 5,
165
+ });
166
+ const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
167
+ range: 25,
168
+ min: 10,
169
+ max: 80,
170
+ steps: 5,
171
+ });
172
+
173
+ const enemy = state.enemies[0];
174
+ if (!enemy)
175
+ {
176
+ return {move: {x: 0, y: 0}};
177
+ }
178
+
179
+ const distance = distanceTo(state.position, enemy.position);
180
+
181
+ const threats = getThreats(state);
182
+ const urgentThreat = getMostUrgentThreat(threats);
183
+
184
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
185
+ const strafe = smartStrafe(
186
+ state.position,
187
+ enemy,
188
+ dodgeDirection,
189
+ state.tick,
190
+ strafeCyclePeriod,
191
+ strafeIntensity,
192
+ );
193
+
194
+ // Distance management (retreat if too close, approach if too far)
195
+ let moveX = strafe.x;
196
+ let moveY = strafe.y;
197
+
198
+ if (!dodgeDirection)
199
+ {
200
+ const deltaX = enemy.position.x - state.position.x;
201
+ const deltaY = enemy.position.y - state.position.y;
202
+ const normalizedDistance = distance > 0 ? distance : 1;
203
+
204
+ if (distance > targetDistance + distanceDeadZone)
205
+ {
206
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
207
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
208
+ }
209
+ else if (distance < targetDistance - distanceDeadZone)
210
+ {
211
+ moveX -= (deltaX / normalizedDistance) * approachSpeed;
212
+ moveY -= (deltaY / normalizedDistance) * approachSpeed;
213
+ }
214
+ }
215
+
216
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
217
+ if (magnitude > 100)
218
+ {
219
+ moveX = (moveX / magnitude) * 100;
220
+ moveY = (moveY / magnitude) * 100;
221
+ }
222
+
223
+ const move = {x: moveX, y: moveY};
224
+
225
+ // === DEFENSE: Handle channeling ===
226
+ if (state.state === 'channeling')
227
+ {
228
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
229
+ {
230
+ return {move, cancel: true};
231
+ }
232
+ return {move: {x: 0, y: 0}};
233
+ }
234
+
235
+ // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
236
+ // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
237
+ if (
238
+ state.state === 'casting' &&
239
+ state.castingSpell === 'missile' &&
240
+ urgentThreat &&
241
+ urgentThreat.projectile.damage >= state.health
242
+ )
243
+ {
244
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
245
+ if (
246
+ urgentThreat.ticksToImpact <=
247
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
248
+ )
249
+ {
250
+ return {move, cancel: true};
251
+ }
252
+ }
253
+
254
+ // === BLINK: Dodge missile + reposition ===
255
+ // Blink dodges the incoming missile — no GCD after, so can immediately fire back.
256
+ // Wide window (10-80) prevents idle gaps where budget is too small for a missile
257
+ // but the threat is too far for the old narrow blink window.
258
+ if (
259
+ state.state === 'idle' &&
260
+ urgentThreat &&
261
+ state.blinkCooldown === 0 &&
262
+ urgentThreat.ticksToImpact >= 10 &&
263
+ urgentThreat.ticksToImpact <= blinkWindowMax
264
+ )
265
+ {
266
+ return {
267
+ move: {x: 0, y: 0},
268
+ startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
269
+ };
270
+ }
271
+
272
+ // === DEFENSE: Shield undodgeable or high-damage homing threats (when blink on cooldown) ===
273
+ if (
274
+ state.state === 'idle' &&
275
+ urgentThreat &&
276
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
277
+ )
278
+ {
279
+ if (urgentThreat.canBlockInTime)
280
+ {
281
+ if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
282
+ {
283
+ return {
284
+ move: {x: 0, y: 0},
285
+ startCast: {spell: 'shield'},
286
+ };
287
+ }
288
+ }
289
+ }
290
+
291
+ // === OFFENSE: Adaptive homing missiles ===
292
+ // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
293
+ const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
294
+ const hasNearbyLethalMissile = state.projectiles.some(
295
+ (p) =>
296
+ !myProjectileIds.has(p.id) &&
297
+ p.damage >= state.health &&
298
+ distanceTo(state.position, p.position) / p.speed <
299
+ MAX_CAST_BUDGET + GCD_DURATION,
300
+ );
301
+ if (state.state === 'idle' && !hasNearbyLethalMissile)
302
+ {
303
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
304
+
305
+ // Account for enemy's active cast — their missile will arrive after cast completes + flight time
306
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
307
+ {
308
+ const remainingEnemyCast =
309
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
310
+ const estimatedFlightTime = distance / flightTimeDivisor;
311
+ nextThreatTime = Math.min(
312
+ nextThreatTime,
313
+ remainingEnemyCast + estimatedFlightTime,
314
+ );
315
+ }
316
+
317
+ const safeBudget =
318
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
319
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
320
+
321
+ // Pyromancer wants homing missiles — no maxDamage cap so missiles punch through shields
322
+ const enemyHP = Math.ceil(enemy.health);
323
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
324
+ const config = fitMissileToBudget(budgetTicks, distance, {
325
+ minTurnRate,
326
+ lastMissileConfig: state.lastMissileConfig,
327
+ });
328
+
329
+ if (
330
+ config &&
331
+ config.damage >= minDmg &&
332
+ isSafeToAttack(threats, safeAttackCastEstimate)
333
+ )
334
+ {
335
+ return {
336
+ move,
337
+ startCast: {
338
+ spell: 'missile',
339
+ config,
340
+ missileAI: HomingAI,
341
+ direction: angleTo(state.position, enemy.position),
342
+ },
343
+ };
344
+ }
345
+
346
+ // Budget too small for useful missile — wait for threat to pass, then fire
347
+ }
348
+
349
+ return {move};
350
+ };