@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,457 +1,457 @@
1
- import {WizardFunction, MissileAIFunction} from '../../types.js';
2
- import {angleTo} from '../../utils/angles.js';
3
- import {distanceTo} from '../../utils/distance.js';
4
- import {interceptAngle, moveInDirection} from '../../utils/movement.js';
5
- import {getMissileCastTime} from '../../utils/combat.js';
6
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
7
- import {
8
- getThreats,
9
- getMostUrgentThreat,
10
- getDodgeDirectionForMovement,
11
- isSafeToAttack,
12
- getBlinkToCenter,
13
- shouldForceShield,
14
- } from '../shared.js';
15
- import {useParam} from '../../engine/params-runtime.js';
16
- const WALL_BUFFER = 80;
17
-
18
- /**
19
- * Standard homing missile: higher damage for faster kills. s=5 × dur=150 = 750u range.
20
- */
21
- const STANDARD_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
22
-
23
- /**
24
- * Quick attack config for use under pressure (shorter cast time).
25
- * Same low turn/duration for warmup compatibility.
26
- */
27
- const QUICK_CONFIG = {damage: 10, speed: 5, turnRate: 1, duration: 80};
28
-
29
- /**
30
- * Predictive homing missile AI - leads the target.
31
- */
32
- const StormchaserMissileAI: MissileAIFunction = ({
33
- worldState,
34
- missileState,
35
- }) =>
36
- {
37
- const enemy = worldState.enemies[0];
38
- if (!enemy) return {};
39
-
40
- const angle = interceptAngle(
41
- missileState.position,
42
- enemy.position,
43
- enemy.velocity,
44
- missileState.speed,
45
- );
46
- if (angle !== null)
47
- {
48
- return {turnToward: moveInDirection(missileState.position, angle, 100)};
49
- }
50
- return {turnToward: enemy.position};
51
- };
52
-
53
- /**
54
- * Check if position is near a wall.
55
- */
56
- function isNearWall(position: {x: number; y: number}): boolean
57
- {
58
- return (
59
- position.x < WALL_BUFFER ||
60
- position.x > ARENA_SIZE - WALL_BUFFER ||
61
- position.y < WALL_BUFFER ||
62
- position.y > ARENA_SIZE - WALL_BUFFER
63
- );
64
- }
65
-
66
- /**
67
- * Smart combat movement with threat-aware dodging.
68
- */
69
- function combatMove(
70
- position: {x: number; y: number},
71
- enemy: {position: {x: number; y: number}},
72
- currentDistance: number,
73
- targetDistance: number,
74
- dodgeDirection: {x: number; y: number} | null,
75
- tick: number,
76
- strafeCyclePeriod: number,
77
- strafeIntensity: number,
78
- approachSpeed: number,
79
- distanceDeadZone: number,
80
- ): {x: number; y: number}
81
- {
82
- const deltaX = enemy.position.x - position.x;
83
- const deltaY = enemy.position.y - position.y;
84
- const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
85
-
86
- // Perpendicular directions for strafing
87
- const strafeClockwise = {
88
- x: -deltaY / normalizedDistance,
89
- y: deltaX / normalizedDistance,
90
- };
91
- const strafeCounterClockwise = {
92
- x: deltaY / normalizedDistance,
93
- y: -deltaX / normalizedDistance,
94
- };
95
-
96
- let strafeDirection: {x: number; y: number};
97
-
98
- // Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
99
- if (dodgeDirection)
100
- {
101
- strafeDirection = dodgeDirection;
102
- }
103
- // Priority 2: Avoid walls
104
- else if (isNearWall(position))
105
- {
106
- const futureClockwise = {
107
- x: position.x + strafeClockwise.x * 100,
108
- y: position.y + strafeClockwise.y * 100,
109
- };
110
- const futureCounterClockwise = {
111
- x: position.x + strafeCounterClockwise.x * 100,
112
- y: position.y + strafeCounterClockwise.y * 100,
113
- };
114
- const scoreClockwise = Math.min(
115
- futureClockwise.x,
116
- ARENA_SIZE - futureClockwise.x,
117
- futureClockwise.y,
118
- ARENA_SIZE - futureClockwise.y,
119
- );
120
- const scoreCounterClockwise = Math.min(
121
- futureCounterClockwise.x,
122
- ARENA_SIZE - futureCounterClockwise.x,
123
- futureCounterClockwise.y,
124
- ARENA_SIZE - futureCounterClockwise.y,
125
- );
126
- strafeDirection =
127
- scoreClockwise > scoreCounterClockwise
128
- ? strafeClockwise
129
- : strafeCounterClockwise;
130
- }
131
- // Priority 3: Commit to strafe direction (unpredictability)
132
- else
133
- {
134
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
135
- strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
136
- }
137
-
138
- // When actively dodging, commit fully — no approach/retreat dilution
139
- if (dodgeDirection)
140
- {
141
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
142
- }
143
-
144
- let moveX = strafeDirection.x * strafeIntensity;
145
- let moveY = strafeDirection.y * strafeIntensity;
146
-
147
- // Distance management (tighter band than other bots, only when not dodging)
148
- if (currentDistance > targetDistance + distanceDeadZone)
149
- {
150
- moveX += (deltaX / normalizedDistance) * approachSpeed;
151
- moveY += (deltaY / normalizedDistance) * approachSpeed;
152
- }
153
- else if (currentDistance < targetDistance - distanceDeadZone)
154
- {
155
- let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
156
- let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
157
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
158
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
159
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
160
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
161
- moveX += retreatX;
162
- moveY += retreatY;
163
- }
164
-
165
- // Normalize to max speed
166
- const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
167
- if (magnitude > 100)
168
- {
169
- moveX = (moveX / magnitude) * 100;
170
- moveY = (moveY / magnitude) * 100;
171
- }
172
-
173
- return {x: moveX, y: moveY};
174
- }
175
-
176
- /**
177
- * Bot: Stormchaser
178
- *
179
- * BEHAVIOR: Fights aggressively while managing defense intelligently. Uses
180
- * two fixed missile configs (standard homing + quick attack) with predictive
181
- * homing missile AI (interceptAngle on the missile itself). Blink-dodges
182
- * incoming threats, shields when blink is on cooldown. Tight distance
183
- * management (350 units, ±30 band).
184
- *
185
- * PROGRESSION LINE: Stormchaser → Stormcaller → Stormforger
186
- * - Stormchaser (tier 1): Fixed missiles, predictive homing AI, blink-dodge
187
- * - Stormcaller (tier 2): + fitMissileToBudget, predictive homing, optimized damage
188
- * - Stormforger (tier 3): Future — supreme berserker, perfect aggression
189
- *
190
- * TIER: 1 (base)
191
- */
192
- export const Stormchaser: WizardFunction = ({state}) =>
193
- {
194
- const targetDistance = useParam('targetDistance', 191, {
195
- range: 150,
196
- min: 0,
197
- });
198
- const healthLowThreshold = useParam('healthLowThreshold', 45, {
199
- range: 20,
200
- min: 1,
201
- steps: 5,
202
- });
203
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 130, {
204
- range: 75,
205
- min: 80,
206
- max: 300,
207
- });
208
- const strafeIntensity = useParam('strafeIntensity', 42, {
209
- range: 40,
210
- min: 20,
211
- max: 100,
212
- steps: 7,
213
- });
214
- const approachSpeed = useParam('approachSpeed', 68, {
215
- range: 30,
216
- min: 10,
217
- max: 70,
218
- steps: 7,
219
- });
220
- const distanceDeadZone = useParam('distanceDeadZone', 26, {
221
- range: 25,
222
- min: 10,
223
- max: 60,
224
- steps: 7,
225
- });
226
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
227
- range: 75,
228
- min: 50,
229
- max: 300,
230
- });
231
- const blinkWindowMax = useParam('blinkWindowMax', 25, {
232
- range: 30,
233
- min: 15,
234
- max: 80,
235
- steps: 7,
236
- });
237
- const cancelMinRemainingCast = useParam('cancelMinRemainingCast', 5, {
238
- range: 50,
239
- min: 5,
240
- max: 150,
241
- steps: 7,
242
- });
243
- const attackRange = useParam('attackRange', 676, {
244
- range: 200,
245
- min: 300,
246
- max: 800,
247
- });
248
- const healthDamageRatio = useParam('healthDamageRatio', 5, {
249
- range: 2,
250
- min: 0.5,
251
- max: 5,
252
- steps: 7,
253
- });
254
-
255
- const enemy = state.enemies[0];
256
- if (!enemy)
257
- {
258
- return {move: {x: 0, y: 0}};
259
- }
260
-
261
- const distance = distanceTo(state.position, enemy.position);
262
-
263
- // Analyze threats using simulation
264
- const threats = getThreats(state);
265
- const urgentThreat = getMostUrgentThreat(threats);
266
-
267
- // === DEFENSE: Handle channeling state ===
268
- if (state.state === 'channeling')
269
- {
270
- // Cancel shield if no imminent danger
271
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
272
- {
273
- return {move: {x: 0, y: 0}, cancel: true};
274
- }
275
- return {move: {x: 0, y: 0}};
276
- }
277
-
278
- // === DEFENSE: Cancel missile cast for threats arriving during vulnerability window ===
279
- if (
280
- state.state === 'casting' &&
281
- state.castingSpell === 'missile' &&
282
- urgentThreat
283
- )
284
- {
285
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
286
- const incomingDamage = urgentThreat.projectile.damage;
287
- const arrivesInGCD =
288
- urgentThreat.ticksToImpact > remainingCast &&
289
- urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION;
290
-
291
- // Cancel for undodgeable threats when trade isn't worth it
292
- if (!urgentThreat.bestDodgeDirection)
293
- {
294
- const canSurviveHit =
295
- state.health > incomingDamage * healthDamageRatio + 1;
296
- const tradeWorthIt =
297
- canSurviveHit && incomingDamage <= QUICK_CONFIG.damage;
298
- if (
299
- !tradeWorthIt &&
300
- remainingCast > cancelMinRemainingCast &&
301
- urgentThreat.ticksToImpact <=
302
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
303
- )
304
- {
305
- const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
306
- const cancelMove = combatMove(
307
- state.position,
308
- enemy,
309
- distance,
310
- targetDistance,
311
- dodgeDir,
312
- state.tick,
313
- strafeCyclePeriod,
314
- strafeIntensity,
315
- approachSpeed,
316
- distanceDeadZone,
317
- );
318
- return {move: cancelMove, cancel: true};
319
- }
320
- }
321
-
322
- // Cancel for ANY threat arriving during GCD when health is critical
323
- // At low health, surviving is more important than finishing the attack.
324
- if (
325
- arrivesInGCD &&
326
- state.health <= incomingDamage + 1 &&
327
- remainingCast > 10
328
- )
329
- {
330
- const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
331
- const cancelMove = combatMove(
332
- state.position,
333
- enemy,
334
- distance,
335
- targetDistance,
336
- dodgeDir,
337
- state.tick,
338
- strafeCyclePeriod,
339
- strafeIntensity,
340
- approachSpeed,
341
- distanceDeadZone,
342
- );
343
- return {move: cancelMove, cancel: true};
344
- }
345
- }
346
-
347
- // === BLINK: Dodge missile + reposition (first priority) ===
348
- // Blink dodges the incoming missile — no GCD after, so can immediately fire back.
349
- // Only shield when blink is on cooldown.
350
- if (
351
- state.state === 'idle' &&
352
- urgentThreat &&
353
- state.blinkCooldown === 0 &&
354
- urgentThreat.ticksToImpact >= 10 &&
355
- urgentThreat.ticksToImpact <= blinkWindowMax
356
- )
357
- {
358
- return {
359
- move: {x: 0, y: 0},
360
- startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
361
- };
362
- }
363
-
364
- // === DEFENSE: Shield threats (when blink is on cooldown) ===
365
- if (state.state === 'idle' && urgentThreat)
366
- {
367
- const forceShield = shouldForceShield(urgentThreat);
368
- const undodgeable = !urgentThreat.bestDodgeDirection;
369
- const healthLow = state.health < healthLowThreshold;
370
- const healthCritical =
371
- state.health <= urgentThreat.projectile.damage * healthDamageRatio + 1;
372
- const shouldShield =
373
- forceShield || (undodgeable && healthLow) || healthCritical;
374
-
375
- if (shouldShield && urgentThreat.canBlockInTime)
376
- {
377
- if (urgentThreat.ticksToStartShield <= 3)
378
- {
379
- return {
380
- move: {x: 0, y: 0},
381
- startCast: {spell: 'shield'},
382
- };
383
- }
384
- }
385
- }
386
-
387
- // === MOVEMENT: Smart combat movement ===
388
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
389
- const move = combatMove(
390
- state.position,
391
- enemy,
392
- distance,
393
- targetDistance,
394
- dodgeDirection,
395
- state.tick,
396
- strafeCyclePeriod,
397
- strafeIntensity,
398
- approachSpeed,
399
- distanceDeadZone,
400
- );
401
-
402
- // === OFFENSE: Attack aggressively while moving ===
403
- if (state.state === 'idle' && distance < attackRange)
404
- {
405
- // Primary: standard homing missile
406
- const standardCastTime = getMissileCastTime(STANDARD_CONFIG);
407
- if (isSafeToAttack(threats, standardCastTime))
408
- {
409
- return {
410
- move,
411
- startCast: {
412
- spell: 'missile',
413
- config: STANDARD_CONFIG,
414
- missileAI: StormchaserMissileAI,
415
- direction: angleTo(state.position, enemy.position),
416
- },
417
- };
418
- }
419
-
420
- // Fallback: quick missile under pressure (shorter cast = smaller vulnerability window)
421
- const quickCastTime = getMissileCastTime(QUICK_CONFIG);
422
- if (isSafeToAttack(threats, quickCastTime))
423
- {
424
- return {
425
- move,
426
- startCast: {
427
- spell: 'missile',
428
- config: QUICK_CONFIG,
429
- missileAI: StormchaserMissileAI,
430
- direction: angleTo(state.position, enemy.position),
431
- },
432
- };
433
- }
434
-
435
- // Not safe to attack — wait for threat to pass, then fire
436
- }
437
-
438
- // === FALLBACK DEFENSE: Shield when no attack window exists ===
439
- // If all offense checks failed and there's an undodgeable threat, shield it
440
- // rather than sitting idle and taking the hit.
441
- if (
442
- state.state === 'idle' &&
443
- urgentThreat &&
444
- !urgentThreat.bestDodgeDirection &&
445
- urgentThreat.canBlockInTime &&
446
- urgentThreat.ticksToStartShield <= 3
447
- )
448
- {
449
- return {
450
- move: {x: 0, y: 0},
451
- startCast: {spell: 'shield'},
452
- };
453
- }
454
-
455
- // Default: just move (dodging/positioning)
456
- return {move};
457
- };
1
+ import {WizardFunction, MissileAIFunction} from '../../types.js';
2
+ import {angleTo} from '../../utils/angles.js';
3
+ import {distanceTo} from '../../utils/distance.js';
4
+ import {interceptAngle, moveInDirection} from '../../utils/movement.js';
5
+ import {getMissileCastTime} from '../../utils/combat.js';
6
+ import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
7
+ import {
8
+ getThreats,
9
+ getMostUrgentThreat,
10
+ getDodgeDirectionForMovement,
11
+ isSafeToAttack,
12
+ getBlinkToCenter,
13
+ shouldForceShield,
14
+ } from '../shared.js';
15
+ import {useParam} from '../../engine/params-runtime.js';
16
+ const WALL_BUFFER = 80;
17
+
18
+ /**
19
+ * Standard homing missile: higher damage for faster kills. s=5 × dur=150 = 750u range.
20
+ */
21
+ const STANDARD_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
22
+
23
+ /**
24
+ * Quick attack config for use under pressure (shorter cast time).
25
+ * Same low turn/duration for warmup compatibility.
26
+ */
27
+ const QUICK_CONFIG = {damage: 10, speed: 5, turnRate: 1, duration: 80};
28
+
29
+ /**
30
+ * Predictive homing missile AI - leads the target.
31
+ */
32
+ const StormchaserMissileAI: MissileAIFunction = ({
33
+ worldState,
34
+ missileState,
35
+ }) =>
36
+ {
37
+ const enemy = worldState.enemies[0];
38
+ if (!enemy) return {};
39
+
40
+ const angle = interceptAngle(
41
+ missileState.position,
42
+ enemy.position,
43
+ enemy.velocity,
44
+ missileState.speed,
45
+ );
46
+ if (angle !== null)
47
+ {
48
+ return {turnToward: moveInDirection(missileState.position, angle, 100)};
49
+ }
50
+ return {turnToward: enemy.position};
51
+ };
52
+
53
+ /**
54
+ * Check if position is near a wall.
55
+ */
56
+ function isNearWall(position: {x: number; y: number}): boolean
57
+ {
58
+ return (
59
+ position.x < WALL_BUFFER ||
60
+ position.x > ARENA_SIZE - WALL_BUFFER ||
61
+ position.y < WALL_BUFFER ||
62
+ position.y > ARENA_SIZE - WALL_BUFFER
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Smart combat movement with threat-aware dodging.
68
+ */
69
+ function combatMove(
70
+ position: {x: number; y: number},
71
+ enemy: {position: {x: number; y: number}},
72
+ currentDistance: number,
73
+ targetDistance: number,
74
+ dodgeDirection: {x: number; y: number} | null,
75
+ tick: number,
76
+ strafeCyclePeriod: number,
77
+ strafeIntensity: number,
78
+ approachSpeed: number,
79
+ distanceDeadZone: number,
80
+ ): {x: number; y: number}
81
+ {
82
+ const deltaX = enemy.position.x - position.x;
83
+ const deltaY = enemy.position.y - position.y;
84
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
85
+
86
+ // Perpendicular directions for strafing
87
+ const strafeClockwise = {
88
+ x: -deltaY / normalizedDistance,
89
+ y: deltaX / normalizedDistance,
90
+ };
91
+ const strafeCounterClockwise = {
92
+ x: deltaY / normalizedDistance,
93
+ y: -deltaX / normalizedDistance,
94
+ };
95
+
96
+ let strafeDirection: {x: number; y: number};
97
+
98
+ // Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
99
+ if (dodgeDirection)
100
+ {
101
+ strafeDirection = dodgeDirection;
102
+ }
103
+ // Priority 2: Avoid walls
104
+ else if (isNearWall(position))
105
+ {
106
+ const futureClockwise = {
107
+ x: position.x + strafeClockwise.x * 100,
108
+ y: position.y + strafeClockwise.y * 100,
109
+ };
110
+ const futureCounterClockwise = {
111
+ x: position.x + strafeCounterClockwise.x * 100,
112
+ y: position.y + strafeCounterClockwise.y * 100,
113
+ };
114
+ const scoreClockwise = Math.min(
115
+ futureClockwise.x,
116
+ ARENA_SIZE - futureClockwise.x,
117
+ futureClockwise.y,
118
+ ARENA_SIZE - futureClockwise.y,
119
+ );
120
+ const scoreCounterClockwise = Math.min(
121
+ futureCounterClockwise.x,
122
+ ARENA_SIZE - futureCounterClockwise.x,
123
+ futureCounterClockwise.y,
124
+ ARENA_SIZE - futureCounterClockwise.y,
125
+ );
126
+ strafeDirection =
127
+ scoreClockwise > scoreCounterClockwise
128
+ ? strafeClockwise
129
+ : strafeCounterClockwise;
130
+ }
131
+ // Priority 3: Commit to strafe direction (unpredictability)
132
+ else
133
+ {
134
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
135
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
136
+ }
137
+
138
+ // When actively dodging, commit fully — no approach/retreat dilution
139
+ if (dodgeDirection)
140
+ {
141
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
142
+ }
143
+
144
+ let moveX = strafeDirection.x * strafeIntensity;
145
+ let moveY = strafeDirection.y * strafeIntensity;
146
+
147
+ // Distance management (tighter band than other bots, only when not dodging)
148
+ if (currentDistance > targetDistance + distanceDeadZone)
149
+ {
150
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
151
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
152
+ }
153
+ else if (currentDistance < targetDistance - distanceDeadZone)
154
+ {
155
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
156
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
157
+ if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
158
+ if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
159
+ if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
160
+ if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
161
+ moveX += retreatX;
162
+ moveY += retreatY;
163
+ }
164
+
165
+ // Normalize to max speed
166
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
167
+ if (magnitude > 100)
168
+ {
169
+ moveX = (moveX / magnitude) * 100;
170
+ moveY = (moveY / magnitude) * 100;
171
+ }
172
+
173
+ return {x: moveX, y: moveY};
174
+ }
175
+
176
+ /**
177
+ * Bot: Stormchaser
178
+ *
179
+ * BEHAVIOR: Fights aggressively while managing defense intelligently. Uses
180
+ * two fixed missile configs (standard homing + quick attack) with predictive
181
+ * homing missile AI (interceptAngle on the missile itself). Blink-dodges
182
+ * incoming threats, shields when blink is on cooldown. Tight distance
183
+ * management (350 units, ±30 band).
184
+ *
185
+ * PROGRESSION LINE: Stormchaser → Stormcaller → Stormforger
186
+ * - Stormchaser (tier 1): Fixed missiles, predictive homing AI, blink-dodge
187
+ * - Stormcaller (tier 2): + fitMissileToBudget, predictive homing, optimized damage
188
+ * - Stormforger (tier 3): Future — supreme berserker, perfect aggression
189
+ *
190
+ * TIER: 1 (base)
191
+ */
192
+ export const Stormchaser: WizardFunction = ({state}) =>
193
+ {
194
+ const targetDistance = useParam('targetDistance', 191, {
195
+ range: 150,
196
+ min: 0,
197
+ });
198
+ const healthLowThreshold = useParam('healthLowThreshold', 45, {
199
+ range: 20,
200
+ min: 1,
201
+ steps: 5,
202
+ });
203
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 130, {
204
+ range: 75,
205
+ min: 80,
206
+ max: 300,
207
+ });
208
+ const strafeIntensity = useParam('strafeIntensity', 42, {
209
+ range: 40,
210
+ min: 20,
211
+ max: 100,
212
+ steps: 7,
213
+ });
214
+ const approachSpeed = useParam('approachSpeed', 68, {
215
+ range: 30,
216
+ min: 10,
217
+ max: 70,
218
+ steps: 7,
219
+ });
220
+ const distanceDeadZone = useParam('distanceDeadZone', 26, {
221
+ range: 25,
222
+ min: 10,
223
+ max: 60,
224
+ steps: 7,
225
+ });
226
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
227
+ range: 75,
228
+ min: 50,
229
+ max: 300,
230
+ });
231
+ const blinkWindowMax = useParam('blinkWindowMax', 25, {
232
+ range: 30,
233
+ min: 15,
234
+ max: 80,
235
+ steps: 7,
236
+ });
237
+ const cancelMinRemainingCast = useParam('cancelMinRemainingCast', 5, {
238
+ range: 50,
239
+ min: 5,
240
+ max: 150,
241
+ steps: 7,
242
+ });
243
+ const attackRange = useParam('attackRange', 676, {
244
+ range: 200,
245
+ min: 300,
246
+ max: 800,
247
+ });
248
+ const healthDamageRatio = useParam('healthDamageRatio', 5, {
249
+ range: 2,
250
+ min: 0.5,
251
+ max: 5,
252
+ steps: 7,
253
+ });
254
+
255
+ const enemy = state.enemies[0];
256
+ if (!enemy)
257
+ {
258
+ return {move: {x: 0, y: 0}};
259
+ }
260
+
261
+ const distance = distanceTo(state.position, enemy.position);
262
+
263
+ // Analyze threats using simulation
264
+ const threats = getThreats(state);
265
+ const urgentThreat = getMostUrgentThreat(threats);
266
+
267
+ // === DEFENSE: Handle channeling state ===
268
+ if (state.state === 'channeling')
269
+ {
270
+ // Cancel shield if no imminent danger
271
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
272
+ {
273
+ return {move: {x: 0, y: 0}, cancel: true};
274
+ }
275
+ return {move: {x: 0, y: 0}};
276
+ }
277
+
278
+ // === DEFENSE: Cancel missile cast for threats arriving during vulnerability window ===
279
+ if (
280
+ state.state === 'casting' &&
281
+ state.castingSpell === 'missile' &&
282
+ urgentThreat
283
+ )
284
+ {
285
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
286
+ const incomingDamage = urgentThreat.projectile.damage;
287
+ const arrivesInGCD =
288
+ urgentThreat.ticksToImpact > remainingCast &&
289
+ urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION;
290
+
291
+ // Cancel for undodgeable threats when trade isn't worth it
292
+ if (!urgentThreat.bestDodgeDirection)
293
+ {
294
+ const canSurviveHit =
295
+ state.health > incomingDamage * healthDamageRatio + 1;
296
+ const tradeWorthIt =
297
+ canSurviveHit && incomingDamage <= QUICK_CONFIG.damage;
298
+ if (
299
+ !tradeWorthIt &&
300
+ remainingCast > cancelMinRemainingCast &&
301
+ urgentThreat.ticksToImpact <=
302
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
303
+ )
304
+ {
305
+ const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
306
+ const cancelMove = combatMove(
307
+ state.position,
308
+ enemy,
309
+ distance,
310
+ targetDistance,
311
+ dodgeDir,
312
+ state.tick,
313
+ strafeCyclePeriod,
314
+ strafeIntensity,
315
+ approachSpeed,
316
+ distanceDeadZone,
317
+ );
318
+ return {move: cancelMove, cancel: true};
319
+ }
320
+ }
321
+
322
+ // Cancel for ANY threat arriving during GCD when health is critical
323
+ // At low health, surviving is more important than finishing the attack.
324
+ if (
325
+ arrivesInGCD &&
326
+ state.health <= incomingDamage + 1 &&
327
+ remainingCast > 10
328
+ )
329
+ {
330
+ const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
331
+ const cancelMove = combatMove(
332
+ state.position,
333
+ enemy,
334
+ distance,
335
+ targetDistance,
336
+ dodgeDir,
337
+ state.tick,
338
+ strafeCyclePeriod,
339
+ strafeIntensity,
340
+ approachSpeed,
341
+ distanceDeadZone,
342
+ );
343
+ return {move: cancelMove, cancel: true};
344
+ }
345
+ }
346
+
347
+ // === BLINK: Dodge missile + reposition (first priority) ===
348
+ // Blink dodges the incoming missile — no GCD after, so can immediately fire back.
349
+ // Only shield when blink is on cooldown.
350
+ if (
351
+ state.state === 'idle' &&
352
+ urgentThreat &&
353
+ state.blinkCooldown === 0 &&
354
+ urgentThreat.ticksToImpact >= 10 &&
355
+ urgentThreat.ticksToImpact <= blinkWindowMax
356
+ )
357
+ {
358
+ return {
359
+ move: {x: 0, y: 0},
360
+ startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
361
+ };
362
+ }
363
+
364
+ // === DEFENSE: Shield threats (when blink is on cooldown) ===
365
+ if (state.state === 'idle' && urgentThreat)
366
+ {
367
+ const forceShield = shouldForceShield(urgentThreat);
368
+ const undodgeable = !urgentThreat.bestDodgeDirection;
369
+ const healthLow = state.health < healthLowThreshold;
370
+ const healthCritical =
371
+ state.health <= urgentThreat.projectile.damage * healthDamageRatio + 1;
372
+ const shouldShield =
373
+ forceShield || (undodgeable && healthLow) || healthCritical;
374
+
375
+ if (shouldShield && urgentThreat.canBlockInTime)
376
+ {
377
+ if (urgentThreat.ticksToStartShield <= 3)
378
+ {
379
+ return {
380
+ move: {x: 0, y: 0},
381
+ startCast: {spell: 'shield'},
382
+ };
383
+ }
384
+ }
385
+ }
386
+
387
+ // === MOVEMENT: Smart combat movement ===
388
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
389
+ const move = combatMove(
390
+ state.position,
391
+ enemy,
392
+ distance,
393
+ targetDistance,
394
+ dodgeDirection,
395
+ state.tick,
396
+ strafeCyclePeriod,
397
+ strafeIntensity,
398
+ approachSpeed,
399
+ distanceDeadZone,
400
+ );
401
+
402
+ // === OFFENSE: Attack aggressively while moving ===
403
+ if (state.state === 'idle' && distance < attackRange)
404
+ {
405
+ // Primary: standard homing missile
406
+ const standardCastTime = getMissileCastTime(STANDARD_CONFIG);
407
+ if (isSafeToAttack(threats, standardCastTime))
408
+ {
409
+ return {
410
+ move,
411
+ startCast: {
412
+ spell: 'missile',
413
+ config: STANDARD_CONFIG,
414
+ missileAI: StormchaserMissileAI,
415
+ direction: angleTo(state.position, enemy.position),
416
+ },
417
+ };
418
+ }
419
+
420
+ // Fallback: quick missile under pressure (shorter cast = smaller vulnerability window)
421
+ const quickCastTime = getMissileCastTime(QUICK_CONFIG);
422
+ if (isSafeToAttack(threats, quickCastTime))
423
+ {
424
+ return {
425
+ move,
426
+ startCast: {
427
+ spell: 'missile',
428
+ config: QUICK_CONFIG,
429
+ missileAI: StormchaserMissileAI,
430
+ direction: angleTo(state.position, enemy.position),
431
+ },
432
+ };
433
+ }
434
+
435
+ // Not safe to attack — wait for threat to pass, then fire
436
+ }
437
+
438
+ // === FALLBACK DEFENSE: Shield when no attack window exists ===
439
+ // If all offense checks failed and there's an undodgeable threat, shield it
440
+ // rather than sitting idle and taking the hit.
441
+ if (
442
+ state.state === 'idle' &&
443
+ urgentThreat &&
444
+ !urgentThreat.bestDodgeDirection &&
445
+ urgentThreat.canBlockInTime &&
446
+ urgentThreat.ticksToStartShield <= 3
447
+ )
448
+ {
449
+ return {
450
+ move: {x: 0, y: 0},
451
+ startCast: {spell: 'shield'},
452
+ };
453
+ }
454
+
455
+ // Default: just move (dodging/positioning)
456
+ return {move};
457
+ };