@vibemancer/core 0.1.0 → 0.1.1

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 (50) hide show
  1. package/README.md +28 -28
  2. package/dist/{chunk-L7Z7OFXD.js → chunk-7VDJHO22.js} +3 -3
  3. package/dist/chunk-7VDJHO22.js.map +1 -0
  4. package/dist/index-browser.d.ts +1 -1
  5. package/dist/index-browser.js +1 -1
  6. package/dist/index.js +1 -1
  7. package/package.json +79 -78
  8. package/src/bots/berserker/01_Stormchaser.ts +457 -457
  9. package/src/bots/berserker/02_Stormcaller.ts +417 -417
  10. package/src/bots/berserker/03_Stormforger.ts +481 -481
  11. package/src/bots/caster/01_Flamecaller.ts +286 -286
  12. package/src/bots/caster/02_Pyromancer.ts +350 -350
  13. package/src/bots/caster/03_Infernalist.ts +492 -492
  14. package/src/bots/defensive/01_Turtle.ts +151 -151
  15. package/src/bots/defensive/02_Sentinel.ts +134 -134
  16. package/src/bots/defensive/03_Golem.ts +357 -357
  17. package/src/bots/duelist/01_Battlemage.ts +433 -433
  18. package/src/bots/duelist/02_Warmage.ts +438 -438
  19. package/src/bots/duelist/03_Archmage.ts +588 -588
  20. package/src/bots/homing/01_Bonemancer.ts +67 -67
  21. package/src/bots/homing/02_Lich.ts +356 -356
  22. package/src/bots/homing/03_Archlich.ts +220 -220
  23. package/src/bots/kiter/01_Spellspinner.ts +398 -398
  24. package/src/bots/kiter/02_Spellweaver.ts +378 -378
  25. package/src/bots/kiter/03_Spellbinder.ts +448 -448
  26. package/src/bots/melee/01_Shadowblade.ts +270 -270
  27. package/src/bots/melee/02_Nightblade.ts +437 -437
  28. package/src/bots/melee/03_Voidblade.ts +582 -582
  29. package/src/bots/sniper/01_Spellshot.ts +385 -385
  30. package/src/bots/sniper/02_Spelltracer.ts +441 -441
  31. package/src/bots/sniper/03_Spellseeker.ts +546 -546
  32. package/src/bots/standalone/Critter.ts +89 -89
  33. package/src/bots/standalone/Doombringer.ts +91 -91
  34. package/src/bots/standalone/Hogger.ts +228 -228
  35. package/src/bots/standalone/Rookie.ts +50 -50
  36. package/src/bots/standalone/TargetDummy.ts +21 -21
  37. package/src/bots/test/cheater.ts +405 -405
  38. package/src/bots/test/crasher.ts +81 -81
  39. package/src/engine/hooks-runtime.ts +394 -394
  40. package/src/engine/manual-match.ts +289 -289
  41. package/src/engine/missile-templates.ts +155 -155
  42. package/src/engine/physics.ts +143 -143
  43. package/src/engine/simulation.ts +828 -828
  44. package/src/engine/spells.ts +128 -128
  45. package/src/engine-version.ts +1 -1
  46. package/src/index.ts +23 -23
  47. package/src/rules.ts +254 -254
  48. package/src/types.ts +193 -193
  49. package/src/utils/index.ts +6 -6
  50. package/dist/chunk-L7Z7OFXD.js.map +0 -1
@@ -1,417 +1,417 @@
1
- import {
2
- WizardFunction,
3
- MissileAIFunction,
4
- MissileActions,
5
- } from '../../types.js';
6
- import {angleTo} from '../../utils/angles.js';
7
- import {distanceTo} from '../../utils/distance.js';
8
- import {interceptAngle, moveInDirection} from '../../utils/movement.js';
9
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
10
- import {
11
- getThreats,
12
- getMostUrgentThreat,
13
- getDodgeDirectionForMovement,
14
- getBlinkToCenter,
15
- fitMissileToBudget,
16
- shouldForceShield,
17
- MAX_CAST_BUDGET,
18
- MIN_USEFUL_DAMAGE,
19
- } from '../shared.js';
20
- import {useParam} from '../../engine/params-runtime.js';
21
- const WALL_BUFFER = 80;
22
-
23
- const SHIELD_BUFFER = 3;
24
-
25
- /**
26
- * Predictive homing missile AI - leads the target.
27
- */
28
- const StormcallerMissileAI: MissileAIFunction = ({
29
- worldState,
30
- missileState,
31
- }) =>
32
- {
33
- const enemy = worldState.enemies[0];
34
- if (!enemy) return {};
35
-
36
- const angle = interceptAngle(
37
- missileState.position,
38
- enemy.position,
39
- enemy.velocity,
40
- missileState.speed,
41
- );
42
- if (angle !== null)
43
- {
44
- return {turnToward: moveInDirection(missileState.position, angle, 100)};
45
- }
46
- return {turnToward: enemy.position};
47
- };
48
-
49
- /**
50
- * Check if position is near a wall.
51
- */
52
- function isNearWall(position: {x: number; y: number}): boolean
53
- {
54
- return (
55
- position.x < WALL_BUFFER ||
56
- position.x > ARENA_SIZE - WALL_BUFFER ||
57
- position.y < WALL_BUFFER ||
58
- position.y > ARENA_SIZE - WALL_BUFFER
59
- );
60
- }
61
-
62
- /**
63
- * Smart combat movement with threat-aware dodging.
64
- */
65
- function combatMove(
66
- position: {x: number; y: number},
67
- enemy: {position: {x: number; y: number}},
68
- currentDistance: number,
69
- targetDistance: number,
70
- dodgeDirection: {x: number; y: number} | null,
71
- tick: number,
72
- strafeCyclePeriod: number,
73
- strafeIntensity: number,
74
- approachSpeed: number,
75
- distanceDeadZone: number,
76
- ): {x: number; y: number}
77
- {
78
- const deltaX = enemy.position.x - position.x;
79
- const deltaY = enemy.position.y - position.y;
80
- const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
81
-
82
- const strafeClockwise = {
83
- x: -deltaY / normalizedDistance,
84
- y: deltaX / normalizedDistance,
85
- };
86
- const strafeCounterClockwise = {
87
- x: deltaY / normalizedDistance,
88
- y: -deltaX / normalizedDistance,
89
- };
90
-
91
- let strafeDirection: {x: number; y: number};
92
-
93
- if (dodgeDirection)
94
- {
95
- strafeDirection = dodgeDirection;
96
- }
97
- else if (isNearWall(position))
98
- {
99
- const futureClockwise = {
100
- x: position.x + strafeClockwise.x * 100,
101
- y: position.y + strafeClockwise.y * 100,
102
- };
103
- const futureCounterClockwise = {
104
- x: position.x + strafeCounterClockwise.x * 100,
105
- y: position.y + strafeCounterClockwise.y * 100,
106
- };
107
- const scoreClockwise = Math.min(
108
- futureClockwise.x,
109
- ARENA_SIZE - futureClockwise.x,
110
- futureClockwise.y,
111
- ARENA_SIZE - futureClockwise.y,
112
- );
113
- const scoreCounterClockwise = Math.min(
114
- futureCounterClockwise.x,
115
- ARENA_SIZE - futureCounterClockwise.x,
116
- futureCounterClockwise.y,
117
- ARENA_SIZE - futureCounterClockwise.y,
118
- );
119
- strafeDirection =
120
- scoreClockwise > scoreCounterClockwise
121
- ? strafeClockwise
122
- : strafeCounterClockwise;
123
- }
124
- else
125
- {
126
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
127
- strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
128
- }
129
-
130
- if (dodgeDirection)
131
- {
132
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
133
- }
134
-
135
- let moveX = strafeDirection.x * strafeIntensity;
136
- let moveY = strafeDirection.y * strafeIntensity;
137
-
138
- if (currentDistance > targetDistance + distanceDeadZone)
139
- {
140
- moveX += (deltaX / normalizedDistance) * approachSpeed;
141
- moveY += (deltaY / normalizedDistance) * approachSpeed;
142
- }
143
- else if (currentDistance < targetDistance - distanceDeadZone)
144
- {
145
- let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
146
- let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
147
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
148
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
149
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
150
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
151
- moveX += retreatX;
152
- moveY += retreatY;
153
- }
154
-
155
- const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
156
- if (magnitude > 100)
157
- {
158
- moveX = (moveX / magnitude) * 100;
159
- moveY = (moveY / magnitude) * 100;
160
- }
161
-
162
- return {x: moveX, y: moveY};
163
- }
164
-
165
- /**
166
- * Bot: Stormcaller
167
- *
168
- * BEHAVIOR: Enhanced Stormchaser with adaptive missile fitting (fitMissileToBudget)
169
- * AND predictive homing missiles. Combines aggressive fighting philosophy with
170
- * optimized damage output. Uses budget-based missile fitting to maximize damage
171
- * within safe windows. Falls back to quick missiles under pressure. Same smart
172
- * trade/shield decisions as Stormchaser but with better resource usage.
173
- *
174
- * PROGRESSION LINE: Stormchaser → Stormcaller → Stormforger
175
- * - Stormchaser (tier 1): Adaptive missiles, smart trading, aggressive defense
176
- * - Stormcaller (tier 2): + fitMissileToBudget, predictive homing, optimized damage
177
- * - Stormforger (tier 3): Future — supreme berserker, perfect aggression
178
- *
179
- * TIER: 2 (enhanced Stormchaser)
180
- */
181
- export const Stormcaller: WizardFunction = ({state}) =>
182
- {
183
- // Tunable parameters (run optimizer script to find optimal values)
184
- const targetDistance = useParam('targetDistance', 129, {
185
- range: 200,
186
- min: 0,
187
- });
188
- const minTurnRate = useParam('minTurnRate', 0.5, {range: 1.5, steps: 5});
189
- const shieldHealthThreshold = useParam('shieldHealthThreshold', 20, {
190
- range: 23,
191
- min: 1,
192
- steps: 7,
193
- });
194
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 281, {
195
- range: 75,
196
- min: 80,
197
- max: 300,
198
- });
199
- const strafeIntensity = useParam('strafeIntensity', 69, {
200
- range: 40,
201
- min: 20,
202
- max: 100,
203
- steps: 7,
204
- });
205
- const approachSpeed = useParam('approachSpeed', 69, {
206
- range: 30,
207
- min: 10,
208
- max: 70,
209
- steps: 7,
210
- });
211
- const distanceDeadZone = useParam('distanceDeadZone', 18, {
212
- range: 25,
213
- min: 10,
214
- max: 60,
215
- steps: 7,
216
- });
217
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
218
- range: 75,
219
- min: 50,
220
- max: 300,
221
- });
222
- const blinkWindowMax = useParam('blinkWindowMax', 70, {
223
- range: 40,
224
- min: 20,
225
- max: 120,
226
- steps: 7,
227
- });
228
- const attackRange = useParam('attackRange', 430, {
229
- range: 200,
230
- min: 300,
231
- max: 800,
232
- });
233
- const flightTimeDivisor = useParam('flightTimeDivisor', 10.8, {
234
- range: 4,
235
- min: 2,
236
- max: 12,
237
- steps: 5,
238
- });
239
-
240
- const enemy = state.enemies[0];
241
- if (!enemy)
242
- {
243
- return {move: {x: 0, y: 0}};
244
- }
245
-
246
- const distance = distanceTo(state.position, enemy.position);
247
-
248
- // Analyze threats using simulation
249
- const threats = getThreats(state);
250
- const urgentThreat = getMostUrgentThreat(threats);
251
-
252
- // === DEFENSE: Handle channeling state ===
253
- if (state.state === 'channeling')
254
- {
255
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
256
- {
257
- return {move: {x: 0, y: 0}, cancel: true};
258
- }
259
- return {move: {x: 0, y: 0}};
260
- }
261
-
262
- // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
263
- // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
264
- if (
265
- state.state === 'casting' &&
266
- state.castingSpell === 'missile' &&
267
- urgentThreat &&
268
- urgentThreat.projectile.damage >= state.health
269
- )
270
- {
271
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
272
- if (
273
- urgentThreat.ticksToImpact <=
274
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
275
- )
276
- {
277
- const dodgeDirection = getDodgeDirectionForMovement(
278
- threats,
279
- state.position,
280
- );
281
- const move = combatMove(
282
- state.position,
283
- enemy,
284
- distance,
285
- targetDistance,
286
- dodgeDirection,
287
- state.tick,
288
- strafeCyclePeriod,
289
- strafeIntensity,
290
- approachSpeed,
291
- distanceDeadZone,
292
- );
293
- return {move, cancel: true};
294
- }
295
- }
296
-
297
- // === BLINK: Dodge missile + reposition (first priority) ===
298
- // Blink dodges the incoming missile — no GCD after, so can immediately fire back.
299
- // Only shield when blink is on cooldown.
300
- if (
301
- state.state === 'idle' &&
302
- urgentThreat &&
303
- state.blinkCooldown === 0 &&
304
- urgentThreat.ticksToImpact >= 10 &&
305
- urgentThreat.ticksToImpact <= blinkWindowMax
306
- )
307
- {
308
- return {
309
- move: {x: 0, y: 0},
310
- startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
311
- };
312
- }
313
-
314
- // === DEFENSE: Shield threats (when blink is on cooldown) ===
315
- if (state.state === 'idle' && urgentThreat)
316
- {
317
- const forceShield = shouldForceShield(urgentThreat);
318
- const undodgeable = !urgentThreat.bestDodgeDirection;
319
- const healthLow = state.health < shieldHealthThreshold;
320
- const healthCritical =
321
- state.health <= urgentThreat.projectile.damage * 2 + 1;
322
- const doShield =
323
- forceShield || (undodgeable && healthLow) || healthCritical;
324
-
325
- if (doShield && urgentThreat.canBlockInTime)
326
- {
327
- if (urgentThreat.ticksToStartShield <= 3)
328
- {
329
- return {
330
- move: {x: 0, y: 0},
331
- startCast: {spell: 'shield'},
332
- };
333
- }
334
- }
335
- }
336
-
337
- // === MOVEMENT: Smart combat movement ===
338
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
339
- const move = combatMove(
340
- state.position,
341
- enemy,
342
- distance,
343
- targetDistance,
344
- dodgeDirection,
345
- state.tick,
346
- strafeCyclePeriod,
347
- strafeIntensity,
348
- approachSpeed,
349
- distanceDeadZone,
350
- );
351
-
352
- // === OFFENSE: Adaptive missile fitting with predictive homing ===
353
- if (state.state === 'idle' && distance < attackRange)
354
- {
355
- let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
356
-
357
- // Account for enemy's active cast — their missile will arrive after cast completes + flight time
358
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
359
- {
360
- const remainingEnemyCast =
361
- (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
362
- const estimatedFlightTime = distance / flightTimeDivisor;
363
- nextThreatTime = Math.min(
364
- nextThreatTime,
365
- remainingEnemyCast + estimatedFlightTime,
366
- );
367
- }
368
-
369
- const safeBudget =
370
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
371
- const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
372
-
373
- // Predictive homing compensates for movement during cast
374
- const enemyHP = Math.ceil(enemy.health);
375
- const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
376
- const config = fitMissileToBudget(budgetTicks, distance, {
377
- minTurnRate,
378
- maxDamage: enemyHP,
379
- lastMissileConfig: state.lastMissileConfig,
380
- });
381
-
382
- if (config && config.damage >= minDmg)
383
- {
384
- return {
385
- move,
386
- startCast: {
387
- spell: 'missile',
388
- config,
389
- missileAI:
390
- config.turnRate > 0
391
- ? StormcallerMissileAI
392
- : (): MissileActions => ({}),
393
- direction: angleTo(state.position, enemy.position),
394
- },
395
- };
396
- }
397
-
398
- // Budget too small for useful missile — wait for threat to pass, then fire
399
- }
400
-
401
- // === FALLBACK DEFENSE: Shield when no attack window exists ===
402
- if (
403
- state.state === 'idle' &&
404
- urgentThreat &&
405
- !urgentThreat.bestDodgeDirection &&
406
- urgentThreat.canBlockInTime &&
407
- urgentThreat.ticksToStartShield <= 3
408
- )
409
- {
410
- return {
411
- move: {x: 0, y: 0},
412
- startCast: {spell: 'shield'},
413
- };
414
- }
415
-
416
- return {move};
417
- };
1
+ import {
2
+ WizardFunction,
3
+ MissileAIFunction,
4
+ MissileActions,
5
+ } from '../../types.js';
6
+ import {angleTo} from '../../utils/angles.js';
7
+ import {distanceTo} from '../../utils/distance.js';
8
+ import {interceptAngle, moveInDirection} from '../../utils/movement.js';
9
+ import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
10
+ import {
11
+ getThreats,
12
+ getMostUrgentThreat,
13
+ getDodgeDirectionForMovement,
14
+ getBlinkToCenter,
15
+ fitMissileToBudget,
16
+ shouldForceShield,
17
+ MAX_CAST_BUDGET,
18
+ MIN_USEFUL_DAMAGE,
19
+ } from '../shared.js';
20
+ import {useParam} from '../../engine/params-runtime.js';
21
+ const WALL_BUFFER = 80;
22
+
23
+ const SHIELD_BUFFER = 3;
24
+
25
+ /**
26
+ * Predictive homing missile AI - leads the target.
27
+ */
28
+ const StormcallerMissileAI: MissileAIFunction = ({
29
+ worldState,
30
+ missileState,
31
+ }) =>
32
+ {
33
+ const enemy = worldState.enemies[0];
34
+ if (!enemy) return {};
35
+
36
+ const angle = interceptAngle(
37
+ missileState.position,
38
+ enemy.position,
39
+ enemy.velocity,
40
+ missileState.speed,
41
+ );
42
+ if (angle !== null)
43
+ {
44
+ return {turnToward: moveInDirection(missileState.position, angle, 100)};
45
+ }
46
+ return {turnToward: enemy.position};
47
+ };
48
+
49
+ /**
50
+ * Check if position is near a wall.
51
+ */
52
+ function isNearWall(position: {x: number; y: number}): boolean
53
+ {
54
+ return (
55
+ position.x < WALL_BUFFER ||
56
+ position.x > ARENA_SIZE - WALL_BUFFER ||
57
+ position.y < WALL_BUFFER ||
58
+ position.y > ARENA_SIZE - WALL_BUFFER
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Smart combat movement with threat-aware dodging.
64
+ */
65
+ function combatMove(
66
+ position: {x: number; y: number},
67
+ enemy: {position: {x: number; y: number}},
68
+ currentDistance: number,
69
+ targetDistance: number,
70
+ dodgeDirection: {x: number; y: number} | null,
71
+ tick: number,
72
+ strafeCyclePeriod: number,
73
+ strafeIntensity: number,
74
+ approachSpeed: number,
75
+ distanceDeadZone: number,
76
+ ): {x: number; y: number}
77
+ {
78
+ const deltaX = enemy.position.x - position.x;
79
+ const deltaY = enemy.position.y - position.y;
80
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
81
+
82
+ const strafeClockwise = {
83
+ x: -deltaY / normalizedDistance,
84
+ y: deltaX / normalizedDistance,
85
+ };
86
+ const strafeCounterClockwise = {
87
+ x: deltaY / normalizedDistance,
88
+ y: -deltaX / normalizedDistance,
89
+ };
90
+
91
+ let strafeDirection: {x: number; y: number};
92
+
93
+ if (dodgeDirection)
94
+ {
95
+ strafeDirection = dodgeDirection;
96
+ }
97
+ else if (isNearWall(position))
98
+ {
99
+ const futureClockwise = {
100
+ x: position.x + strafeClockwise.x * 100,
101
+ y: position.y + strafeClockwise.y * 100,
102
+ };
103
+ const futureCounterClockwise = {
104
+ x: position.x + strafeCounterClockwise.x * 100,
105
+ y: position.y + strafeCounterClockwise.y * 100,
106
+ };
107
+ const scoreClockwise = Math.min(
108
+ futureClockwise.x,
109
+ ARENA_SIZE - futureClockwise.x,
110
+ futureClockwise.y,
111
+ ARENA_SIZE - futureClockwise.y,
112
+ );
113
+ const scoreCounterClockwise = Math.min(
114
+ futureCounterClockwise.x,
115
+ ARENA_SIZE - futureCounterClockwise.x,
116
+ futureCounterClockwise.y,
117
+ ARENA_SIZE - futureCounterClockwise.y,
118
+ );
119
+ strafeDirection =
120
+ scoreClockwise > scoreCounterClockwise
121
+ ? strafeClockwise
122
+ : strafeCounterClockwise;
123
+ }
124
+ else
125
+ {
126
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
127
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
128
+ }
129
+
130
+ if (dodgeDirection)
131
+ {
132
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
133
+ }
134
+
135
+ let moveX = strafeDirection.x * strafeIntensity;
136
+ let moveY = strafeDirection.y * strafeIntensity;
137
+
138
+ if (currentDistance > targetDistance + distanceDeadZone)
139
+ {
140
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
141
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
142
+ }
143
+ else if (currentDistance < targetDistance - distanceDeadZone)
144
+ {
145
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
146
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
147
+ if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
148
+ if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
149
+ if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
150
+ if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
151
+ moveX += retreatX;
152
+ moveY += retreatY;
153
+ }
154
+
155
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
156
+ if (magnitude > 100)
157
+ {
158
+ moveX = (moveX / magnitude) * 100;
159
+ moveY = (moveY / magnitude) * 100;
160
+ }
161
+
162
+ return {x: moveX, y: moveY};
163
+ }
164
+
165
+ /**
166
+ * Bot: Stormcaller
167
+ *
168
+ * BEHAVIOR: Enhanced Stormchaser with adaptive missile fitting (fitMissileToBudget)
169
+ * AND predictive homing missiles. Combines aggressive fighting philosophy with
170
+ * optimized damage output. Uses budget-based missile fitting to maximize damage
171
+ * within safe windows. Falls back to quick missiles under pressure. Same smart
172
+ * trade/shield decisions as Stormchaser but with better resource usage.
173
+ *
174
+ * PROGRESSION LINE: Stormchaser → Stormcaller → Stormforger
175
+ * - Stormchaser (tier 1): Adaptive missiles, smart trading, aggressive defense
176
+ * - Stormcaller (tier 2): + fitMissileToBudget, predictive homing, optimized damage
177
+ * - Stormforger (tier 3): Future — supreme berserker, perfect aggression
178
+ *
179
+ * TIER: 2 (enhanced Stormchaser)
180
+ */
181
+ export const Stormcaller: WizardFunction = ({state}) =>
182
+ {
183
+ // Tunable parameters (run optimizer script to find optimal values)
184
+ const targetDistance = useParam('targetDistance', 129, {
185
+ range: 200,
186
+ min: 0,
187
+ });
188
+ const minTurnRate = useParam('minTurnRate', 0.5, {range: 1.5, steps: 5});
189
+ const shieldHealthThreshold = useParam('shieldHealthThreshold', 20, {
190
+ range: 23,
191
+ min: 1,
192
+ steps: 7,
193
+ });
194
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 281, {
195
+ range: 75,
196
+ min: 80,
197
+ max: 300,
198
+ });
199
+ const strafeIntensity = useParam('strafeIntensity', 69, {
200
+ range: 40,
201
+ min: 20,
202
+ max: 100,
203
+ steps: 7,
204
+ });
205
+ const approachSpeed = useParam('approachSpeed', 69, {
206
+ range: 30,
207
+ min: 10,
208
+ max: 70,
209
+ steps: 7,
210
+ });
211
+ const distanceDeadZone = useParam('distanceDeadZone', 18, {
212
+ range: 25,
213
+ min: 10,
214
+ max: 60,
215
+ steps: 7,
216
+ });
217
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
218
+ range: 75,
219
+ min: 50,
220
+ max: 300,
221
+ });
222
+ const blinkWindowMax = useParam('blinkWindowMax', 70, {
223
+ range: 40,
224
+ min: 20,
225
+ max: 120,
226
+ steps: 7,
227
+ });
228
+ const attackRange = useParam('attackRange', 430, {
229
+ range: 200,
230
+ min: 300,
231
+ max: 800,
232
+ });
233
+ const flightTimeDivisor = useParam('flightTimeDivisor', 10.8, {
234
+ range: 4,
235
+ min: 2,
236
+ max: 12,
237
+ steps: 5,
238
+ });
239
+
240
+ const enemy = state.enemies[0];
241
+ if (!enemy)
242
+ {
243
+ return {move: {x: 0, y: 0}};
244
+ }
245
+
246
+ const distance = distanceTo(state.position, enemy.position);
247
+
248
+ // Analyze threats using simulation
249
+ const threats = getThreats(state);
250
+ const urgentThreat = getMostUrgentThreat(threats);
251
+
252
+ // === DEFENSE: Handle channeling state ===
253
+ if (state.state === 'channeling')
254
+ {
255
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
256
+ {
257
+ return {move: {x: 0, y: 0}, cancel: true};
258
+ }
259
+ return {move: {x: 0, y: 0}};
260
+ }
261
+
262
+ // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
263
+ // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
264
+ if (
265
+ state.state === 'casting' &&
266
+ state.castingSpell === 'missile' &&
267
+ urgentThreat &&
268
+ urgentThreat.projectile.damage >= state.health
269
+ )
270
+ {
271
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
272
+ if (
273
+ urgentThreat.ticksToImpact <=
274
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
275
+ )
276
+ {
277
+ const dodgeDirection = getDodgeDirectionForMovement(
278
+ threats,
279
+ state.position,
280
+ );
281
+ const move = combatMove(
282
+ state.position,
283
+ enemy,
284
+ distance,
285
+ targetDistance,
286
+ dodgeDirection,
287
+ state.tick,
288
+ strafeCyclePeriod,
289
+ strafeIntensity,
290
+ approachSpeed,
291
+ distanceDeadZone,
292
+ );
293
+ return {move, cancel: true};
294
+ }
295
+ }
296
+
297
+ // === BLINK: Dodge missile + reposition (first priority) ===
298
+ // Blink dodges the incoming missile — no GCD after, so can immediately fire back.
299
+ // Only shield when blink is on cooldown.
300
+ if (
301
+ state.state === 'idle' &&
302
+ urgentThreat &&
303
+ state.blinkCooldown === 0 &&
304
+ urgentThreat.ticksToImpact >= 10 &&
305
+ urgentThreat.ticksToImpact <= blinkWindowMax
306
+ )
307
+ {
308
+ return {
309
+ move: {x: 0, y: 0},
310
+ startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
311
+ };
312
+ }
313
+
314
+ // === DEFENSE: Shield threats (when blink is on cooldown) ===
315
+ if (state.state === 'idle' && urgentThreat)
316
+ {
317
+ const forceShield = shouldForceShield(urgentThreat);
318
+ const undodgeable = !urgentThreat.bestDodgeDirection;
319
+ const healthLow = state.health < shieldHealthThreshold;
320
+ const healthCritical =
321
+ state.health <= urgentThreat.projectile.damage * 2 + 1;
322
+ const doShield =
323
+ forceShield || (undodgeable && healthLow) || healthCritical;
324
+
325
+ if (doShield && urgentThreat.canBlockInTime)
326
+ {
327
+ if (urgentThreat.ticksToStartShield <= 3)
328
+ {
329
+ return {
330
+ move: {x: 0, y: 0},
331
+ startCast: {spell: 'shield'},
332
+ };
333
+ }
334
+ }
335
+ }
336
+
337
+ // === MOVEMENT: Smart combat movement ===
338
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
339
+ const move = combatMove(
340
+ state.position,
341
+ enemy,
342
+ distance,
343
+ targetDistance,
344
+ dodgeDirection,
345
+ state.tick,
346
+ strafeCyclePeriod,
347
+ strafeIntensity,
348
+ approachSpeed,
349
+ distanceDeadZone,
350
+ );
351
+
352
+ // === OFFENSE: Adaptive missile fitting with predictive homing ===
353
+ if (state.state === 'idle' && distance < attackRange)
354
+ {
355
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
356
+
357
+ // Account for enemy's active cast — their missile will arrive after cast completes + flight time
358
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
359
+ {
360
+ const remainingEnemyCast =
361
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
362
+ const estimatedFlightTime = distance / flightTimeDivisor;
363
+ nextThreatTime = Math.min(
364
+ nextThreatTime,
365
+ remainingEnemyCast + estimatedFlightTime,
366
+ );
367
+ }
368
+
369
+ const safeBudget =
370
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
371
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
372
+
373
+ // Predictive homing compensates for movement during cast
374
+ const enemyHP = Math.ceil(enemy.health);
375
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
376
+ const config = fitMissileToBudget(budgetTicks, distance, {
377
+ minTurnRate,
378
+ maxDamage: enemyHP,
379
+ lastMissileConfig: state.lastMissileConfig,
380
+ });
381
+
382
+ if (config && config.damage >= minDmg)
383
+ {
384
+ return {
385
+ move,
386
+ startCast: {
387
+ spell: 'missile',
388
+ config,
389
+ missileAI:
390
+ config.turnRate > 0
391
+ ? StormcallerMissileAI
392
+ : (): MissileActions => ({}),
393
+ direction: angleTo(state.position, enemy.position),
394
+ },
395
+ };
396
+ }
397
+
398
+ // Budget too small for useful missile — wait for threat to pass, then fire
399
+ }
400
+
401
+ // === FALLBACK DEFENSE: Shield when no attack window exists ===
402
+ if (
403
+ state.state === 'idle' &&
404
+ urgentThreat &&
405
+ !urgentThreat.bestDodgeDirection &&
406
+ urgentThreat.canBlockInTime &&
407
+ urgentThreat.ticksToStartShield <= 3
408
+ )
409
+ {
410
+ return {
411
+ move: {x: 0, y: 0},
412
+ startCast: {spell: 'shield'},
413
+ };
414
+ }
415
+
416
+ return {move};
417
+ };