@vibemancer/core 0.1.0 → 0.1.2

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-OL7ETV6V.js} +3 -3
  3. package/dist/chunk-OL7ETV6V.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,433 +1,433 @@
1
- import {WizardFunction, MissileAIFunction} from '../../types.js';
2
- import {angleTo} from '../../utils/angles.js';
3
- import {distanceTo} from '../../utils/distance.js';
4
- import {getMissileCastTime} from '../../utils/combat.js';
5
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
6
- import {
7
- getThreats,
8
- getMostUrgentThreat,
9
- getDodgeDirectionForMovement,
10
- isSafeToAttack,
11
- getBlinkToDecreaseDistance,
12
- shouldForceShield,
13
- } from '../shared.js';
14
- import {useParam} from '../../engine/params-runtime.js';
15
- const WALL_BUFFER = 60;
16
-
17
- const QUICK_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 70};
18
- const HEAVY_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
19
-
20
- // Missile effective range (speed * duration). Use HEAVY when QUICK can't reach.
21
- const QUICK_RANGE = QUICK_CONFIG.speed * QUICK_CONFIG.duration; // 350
22
-
23
- /**
24
- * Standard homing missile AI.
25
- */
26
- const StandardHoming: MissileAIFunction = ({worldState}) =>
27
- {
28
- const enemy = worldState.enemies[0];
29
- return {
30
- turnToward: enemy?.position,
31
- };
32
- };
33
-
34
- /**
35
- * Check if near wall.
36
- */
37
- function isNearWall(position: {x: number; y: number}): boolean
38
- {
39
- return (
40
- position.x < WALL_BUFFER ||
41
- position.x > ARENA_SIZE - WALL_BUFFER ||
42
- position.y < WALL_BUFFER ||
43
- position.y > ARENA_SIZE - WALL_BUFFER
44
- );
45
- }
46
-
47
- /**
48
- * Smart combat movement with threat-aware dodging.
49
- */
50
- function combatMove(
51
- position: {x: number; y: number},
52
- enemy: {position: {x: number; y: number}},
53
- currentDistance: number,
54
- targetDistance: number,
55
- dodgeDirection: {x: number; y: number} | null,
56
- tick: number,
57
- strafeCyclePeriod: number,
58
- strafeIntensity: number,
59
- approachSpeed: number,
60
- distanceDeadZone: number,
61
- ): {x: number; y: number}
62
- {
63
- const deltaX = enemy.position.x - position.x;
64
- const deltaY = enemy.position.y - position.y;
65
- const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
66
-
67
- // Perpendicular directions
68
- const strafeClockwise = {
69
- x: -deltaY / normalizedDistance,
70
- y: deltaX / normalizedDistance,
71
- };
72
- const strafeCounterClockwise = {
73
- x: deltaY / normalizedDistance,
74
- y: -deltaX / normalizedDistance,
75
- };
76
-
77
- let strafeDirection: {x: number; y: number};
78
-
79
- // Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
80
- if (dodgeDirection)
81
- {
82
- strafeDirection = dodgeDirection;
83
- }
84
- // Priority 2: Avoid walls
85
- else if (isNearWall(position))
86
- {
87
- const futureClockwise = {
88
- x: position.x + strafeClockwise.x * 100,
89
- y: position.y + strafeClockwise.y * 100,
90
- };
91
- const futureCounterClockwise = {
92
- x: position.x + strafeCounterClockwise.x * 100,
93
- y: position.y + strafeCounterClockwise.y * 100,
94
- };
95
- const scoreClockwise = Math.min(
96
- futureClockwise.x,
97
- ARENA_SIZE - futureClockwise.x,
98
- futureClockwise.y,
99
- ARENA_SIZE - futureClockwise.y,
100
- );
101
- const scoreCounterClockwise = Math.min(
102
- futureCounterClockwise.x,
103
- ARENA_SIZE - futureCounterClockwise.x,
104
- futureCounterClockwise.y,
105
- ARENA_SIZE - futureCounterClockwise.y,
106
- );
107
- strafeDirection =
108
- scoreClockwise > scoreCounterClockwise
109
- ? strafeClockwise
110
- : strafeCounterClockwise;
111
- }
112
- // Priority 3: Commit to direction
113
- else
114
- {
115
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
116
- strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
117
- }
118
-
119
- // When actively dodging, commit fully — no approach/retreat dilution
120
- if (dodgeDirection)
121
- {
122
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
123
- }
124
-
125
- let moveX = strafeDirection.x * strafeIntensity;
126
- let moveY = strafeDirection.y * strafeIntensity;
127
-
128
- // Distance management (only when not dodging)
129
- if (currentDistance > targetDistance + distanceDeadZone)
130
- {
131
- moveX += (deltaX / normalizedDistance) * approachSpeed;
132
- moveY += (deltaY / normalizedDistance) * approachSpeed;
133
- }
134
- else if (currentDistance < targetDistance - distanceDeadZone)
135
- {
136
- let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
137
- let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
138
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
139
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
140
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
141
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
142
- moveX += retreatX;
143
- moveY += retreatY;
144
- }
145
-
146
- // Normalize
147
- const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
148
- if (magnitude > 100)
149
- {
150
- moveX = (moveX / magnitude) * 100;
151
- moveY = (moveY / magnitude) * 100;
152
- }
153
-
154
- return {x: moveX, y: moveY};
155
- }
156
-
157
- /**
158
- * Bot: Battlemage
159
- *
160
- * BEHAVIOR: Balanced mid-range duelist. Shields undodgeable threats, interrupts
161
- * enemy casts with quick missiles, saves blink for emergencies OR gap-closing.
162
- * Switches between quick (10 dmg, fast) and heavy (15 dmg, slow) missile configs
163
- * based on safety window and range. Will aggressively trade hits when health allows.
164
- * The unique trait is cast-interruption: fires quick missiles specifically when the
165
- * enemy is casting, punishing long cast times.
166
- *
167
- * PROGRESSION LINE: Battlemage → Warmage → Archmage
168
- * - Battlemage (tier 1): Quick/heavy fixed configs, cast interruption, basic defense
169
- * - Warmage (tier 2): Adaptive missile fitting (fitMissileToBudget), smarter attacks
170
- * - Archmage (tier 3): Future — supreme duelist, perfect tactical mastery
171
- *
172
- * TIER: 1 (base)
173
- */
174
- export const Battlemage: WizardFunction = ({state}) =>
175
- {
176
- const targetDistance = useParam('targetDistance', 274, {
177
- range: 150,
178
- min: 150,
179
- });
180
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 219, {
181
- range: 75,
182
- min: 80,
183
- max: 300,
184
- });
185
- const strafeIntensity = useParam('strafeIntensity', 36, {
186
- range: 40,
187
- min: 20,
188
- max: 100,
189
- steps: 7,
190
- });
191
- const approachSpeed = useParam('approachSpeed', 80, {
192
- range: 30,
193
- min: 10,
194
- max: 80,
195
- steps: 7,
196
- });
197
- const distanceDeadZone = useParam('distanceDeadZone', 31, {
198
- range: 30,
199
- min: 10,
200
- max: 80,
201
- steps: 7,
202
- });
203
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
204
- range: 75,
205
- min: 50,
206
- max: 300,
207
- });
208
- const blinkWindowMax = useParam('blinkWindowMax', 35, {
209
- range: 30,
210
- min: 15,
211
- max: 80,
212
- steps: 7,
213
- });
214
- const blinkDistanceOffset = useParam('blinkDistanceOffset', 100, {
215
- range: 40,
216
- min: 0,
217
- max: 100,
218
- steps: 7,
219
- });
220
- const cancelMinRemainingCast = useParam('cancelMinRemainingCast', 19, {
221
- range: 30,
222
- min: 5,
223
- max: 100,
224
- steps: 7,
225
- });
226
- const interruptRange = useParam('interruptRange', 324, {
227
- range: 150,
228
- min: 150,
229
- max: 600,
230
- steps: 7,
231
- });
232
- const attackRange = useParam('attackRange', 556, {
233
- range: 150,
234
- min: 200,
235
- max: 700,
236
- });
237
-
238
- const enemy = state.enemies[0];
239
- if (!enemy)
240
- {
241
- return {move: {x: 0, y: 0}};
242
- }
243
-
244
- const distance = distanceTo(state.position, enemy.position);
245
-
246
- // Analyze threats using simulation
247
- const threats = getThreats(state);
248
- const urgentThreat = getMostUrgentThreat(threats);
249
-
250
- // === DEFENSE: Handle channeling ===
251
- if (state.state === 'channeling')
252
- {
253
- // Cancel shield if no imminent threats
254
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
255
- {
256
- return {move: {x: 0, y: 0}, cancel: true};
257
- }
258
- return {move: {x: 0, y: 0}};
259
- }
260
-
261
- // === DEFENSE: Cancel long missile casts if undodgeable threat approaches ===
262
- // Only cancel when health is too low to trade (aggressive casts accepted the risk).
263
- if (
264
- state.state === 'casting' &&
265
- state.castingSpell === 'missile' &&
266
- urgentThreat &&
267
- !urgentThreat.bestDodgeDirection
268
- )
269
- {
270
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
271
- if (
272
- remainingCast > cancelMinRemainingCast &&
273
- urgentThreat.ticksToImpact <=
274
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1 &&
275
- state.health <= urgentThreat.projectile.damage * 2 + 1
276
- )
277
- {
278
- const dodgeDirection = getDodgeDirectionForMovement(
279
- threats,
280
- state.position,
281
- );
282
- const move = combatMove(
283
- state.position,
284
- enemy,
285
- distance,
286
- targetDistance,
287
- dodgeDirection,
288
- state.tick,
289
- strafeCyclePeriod,
290
- strafeIntensity,
291
- approachSpeed,
292
- distanceDeadZone,
293
- );
294
- return {move, cancel: true};
295
- }
296
- }
297
-
298
- // === BLINK: Dodge missile + close gap simultaneously (first priority) ===
299
- // Blink both dodges the incoming missile AND closes distance — better than shielding.
300
- // Only shield when blink is on cooldown.
301
- if (
302
- state.state === 'idle' &&
303
- urgentThreat &&
304
- state.blinkCooldown === 0 &&
305
- distance > targetDistance - blinkDistanceOffset &&
306
- urgentThreat.ticksToImpact >= 10 &&
307
- urgentThreat.ticksToImpact <= blinkWindowMax
308
- )
309
- {
310
- const blinkTarget =
311
- getBlinkToDecreaseDistance(
312
- state.position,
313
- enemy.position,
314
- state.projectiles,
315
- ) ?? enemy.position;
316
- return {
317
- move: {x: 0, y: 0},
318
- startCast: {spell: 'blink', target: blinkTarget},
319
- };
320
- }
321
-
322
- // === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
323
- if (
324
- state.state === 'idle' &&
325
- urgentThreat &&
326
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
327
- )
328
- {
329
- if (
330
- urgentThreat.canBlockInTime &&
331
- urgentThreat.ticksToStartShield <= 3 &&
332
- (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
333
- )
334
- {
335
- return {
336
- move: {x: 0, y: 0},
337
- startCast: {spell: 'shield'},
338
- };
339
- }
340
- }
341
-
342
- // Movement: smart strafe + maintain optimal distance
343
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
344
- const move = combatMove(
345
- state.position,
346
- enemy,
347
- distance,
348
- targetDistance,
349
- dodgeDirection,
350
- state.tick,
351
- strafeCyclePeriod,
352
- strafeIntensity,
353
- approachSpeed,
354
- distanceDeadZone,
355
- );
356
-
357
- // === OFFENSE ===
358
- if (state.state === 'idle')
359
- {
360
- // Interrupt if enemy is casting (quick attack)
361
- if (enemy.state === 'casting' && distance < interruptRange)
362
- {
363
- const castTime = getMissileCastTime(QUICK_CONFIG);
364
- if (isSafeToAttack(threats, castTime))
365
- {
366
- return {
367
- move,
368
- startCast: {
369
- spell: 'missile',
370
- config: QUICK_CONFIG,
371
- missileAI: StandardHoming,
372
- direction: angleTo(state.position, enemy.position),
373
- },
374
- };
375
- }
376
- }
377
-
378
- // Standard attacks at combat range
379
- if (distance < attackRange)
380
- {
381
- const heavyCastTime = getMissileCastTime(HEAVY_CONFIG);
382
- if (isSafeToAttack(threats, heavyCastTime))
383
- {
384
- return {
385
- move,
386
- startCast: {
387
- spell: 'missile',
388
- config: HEAVY_CONFIG,
389
- missileAI: StandardHoming,
390
- direction: angleTo(state.position, enemy.position),
391
- },
392
- };
393
- }
394
-
395
- // Fallback: quick homing missile when heavy isn't safe (only if in range)
396
- const quickCastTime = getMissileCastTime(QUICK_CONFIG);
397
- if (
398
- distance < QUICK_RANGE * 0.9 &&
399
- isSafeToAttack(threats, quickCastTime)
400
- )
401
- {
402
- return {
403
- move,
404
- startCast: {
405
- spell: 'missile',
406
- config: QUICK_CONFIG,
407
- missileAI: StandardHoming,
408
- direction: angleTo(state.position, enemy.position),
409
- },
410
- };
411
- }
412
-
413
- // Not safe to attack — wait for threat to pass, then fire
414
- }
415
- }
416
-
417
- // === FALLBACK DEFENSE: Shield when no attack window exists ===
418
- if (
419
- state.state === 'idle' &&
420
- urgentThreat &&
421
- !urgentThreat.bestDodgeDirection &&
422
- urgentThreat.canBlockInTime &&
423
- urgentThreat.ticksToStartShield <= 3
424
- )
425
- {
426
- return {
427
- move: {x: 0, y: 0},
428
- startCast: {spell: 'shield'},
429
- };
430
- }
431
-
432
- return {move};
433
- };
1
+ import {WizardFunction, MissileAIFunction} from '../../types.js';
2
+ import {angleTo} from '../../utils/angles.js';
3
+ import {distanceTo} from '../../utils/distance.js';
4
+ import {getMissileCastTime} from '../../utils/combat.js';
5
+ import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
6
+ import {
7
+ getThreats,
8
+ getMostUrgentThreat,
9
+ getDodgeDirectionForMovement,
10
+ isSafeToAttack,
11
+ getBlinkToDecreaseDistance,
12
+ shouldForceShield,
13
+ } from '../shared.js';
14
+ import {useParam} from '../../engine/params-runtime.js';
15
+ const WALL_BUFFER = 60;
16
+
17
+ const QUICK_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 70};
18
+ const HEAVY_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
19
+
20
+ // Missile effective range (speed * duration). Use HEAVY when QUICK can't reach.
21
+ const QUICK_RANGE = QUICK_CONFIG.speed * QUICK_CONFIG.duration; // 350
22
+
23
+ /**
24
+ * Standard homing missile AI.
25
+ */
26
+ const StandardHoming: MissileAIFunction = ({worldState}) =>
27
+ {
28
+ const enemy = worldState.enemies[0];
29
+ return {
30
+ turnToward: enemy?.position,
31
+ };
32
+ };
33
+
34
+ /**
35
+ * Check if near wall.
36
+ */
37
+ function isNearWall(position: {x: number; y: number}): boolean
38
+ {
39
+ return (
40
+ position.x < WALL_BUFFER ||
41
+ position.x > ARENA_SIZE - WALL_BUFFER ||
42
+ position.y < WALL_BUFFER ||
43
+ position.y > ARENA_SIZE - WALL_BUFFER
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Smart combat movement with threat-aware dodging.
49
+ */
50
+ function combatMove(
51
+ position: {x: number; y: number},
52
+ enemy: {position: {x: number; y: number}},
53
+ currentDistance: number,
54
+ targetDistance: number,
55
+ dodgeDirection: {x: number; y: number} | null,
56
+ tick: number,
57
+ strafeCyclePeriod: number,
58
+ strafeIntensity: number,
59
+ approachSpeed: number,
60
+ distanceDeadZone: number,
61
+ ): {x: number; y: number}
62
+ {
63
+ const deltaX = enemy.position.x - position.x;
64
+ const deltaY = enemy.position.y - position.y;
65
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
66
+
67
+ // Perpendicular directions
68
+ const strafeClockwise = {
69
+ x: -deltaY / normalizedDistance,
70
+ y: deltaX / normalizedDistance,
71
+ };
72
+ const strafeCounterClockwise = {
73
+ x: deltaY / normalizedDistance,
74
+ y: -deltaX / normalizedDistance,
75
+ };
76
+
77
+ let strafeDirection: {x: number; y: number};
78
+
79
+ // Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
80
+ if (dodgeDirection)
81
+ {
82
+ strafeDirection = dodgeDirection;
83
+ }
84
+ // Priority 2: Avoid walls
85
+ else if (isNearWall(position))
86
+ {
87
+ const futureClockwise = {
88
+ x: position.x + strafeClockwise.x * 100,
89
+ y: position.y + strafeClockwise.y * 100,
90
+ };
91
+ const futureCounterClockwise = {
92
+ x: position.x + strafeCounterClockwise.x * 100,
93
+ y: position.y + strafeCounterClockwise.y * 100,
94
+ };
95
+ const scoreClockwise = Math.min(
96
+ futureClockwise.x,
97
+ ARENA_SIZE - futureClockwise.x,
98
+ futureClockwise.y,
99
+ ARENA_SIZE - futureClockwise.y,
100
+ );
101
+ const scoreCounterClockwise = Math.min(
102
+ futureCounterClockwise.x,
103
+ ARENA_SIZE - futureCounterClockwise.x,
104
+ futureCounterClockwise.y,
105
+ ARENA_SIZE - futureCounterClockwise.y,
106
+ );
107
+ strafeDirection =
108
+ scoreClockwise > scoreCounterClockwise
109
+ ? strafeClockwise
110
+ : strafeCounterClockwise;
111
+ }
112
+ // Priority 3: Commit to direction
113
+ else
114
+ {
115
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
116
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
117
+ }
118
+
119
+ // When actively dodging, commit fully — no approach/retreat dilution
120
+ if (dodgeDirection)
121
+ {
122
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
123
+ }
124
+
125
+ let moveX = strafeDirection.x * strafeIntensity;
126
+ let moveY = strafeDirection.y * strafeIntensity;
127
+
128
+ // Distance management (only when not dodging)
129
+ if (currentDistance > targetDistance + distanceDeadZone)
130
+ {
131
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
132
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
133
+ }
134
+ else if (currentDistance < targetDistance - distanceDeadZone)
135
+ {
136
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
137
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
138
+ if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
139
+ if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
140
+ if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
141
+ if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
142
+ moveX += retreatX;
143
+ moveY += retreatY;
144
+ }
145
+
146
+ // Normalize
147
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
148
+ if (magnitude > 100)
149
+ {
150
+ moveX = (moveX / magnitude) * 100;
151
+ moveY = (moveY / magnitude) * 100;
152
+ }
153
+
154
+ return {x: moveX, y: moveY};
155
+ }
156
+
157
+ /**
158
+ * Bot: Battlemage
159
+ *
160
+ * BEHAVIOR: Balanced mid-range duelist. Shields undodgeable threats, interrupts
161
+ * enemy casts with quick missiles, saves blink for emergencies OR gap-closing.
162
+ * Switches between quick (10 dmg, fast) and heavy (15 dmg, slow) missile configs
163
+ * based on safety window and range. Will aggressively trade hits when health allows.
164
+ * The unique trait is cast-interruption: fires quick missiles specifically when the
165
+ * enemy is casting, punishing long cast times.
166
+ *
167
+ * PROGRESSION LINE: Battlemage → Warmage → Archmage
168
+ * - Battlemage (tier 1): Quick/heavy fixed configs, cast interruption, basic defense
169
+ * - Warmage (tier 2): Adaptive missile fitting (fitMissileToBudget), smarter attacks
170
+ * - Archmage (tier 3): Future — supreme duelist, perfect tactical mastery
171
+ *
172
+ * TIER: 1 (base)
173
+ */
174
+ export const Battlemage: WizardFunction = ({state}) =>
175
+ {
176
+ const targetDistance = useParam('targetDistance', 274, {
177
+ range: 150,
178
+ min: 150,
179
+ });
180
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 219, {
181
+ range: 75,
182
+ min: 80,
183
+ max: 300,
184
+ });
185
+ const strafeIntensity = useParam('strafeIntensity', 36, {
186
+ range: 40,
187
+ min: 20,
188
+ max: 100,
189
+ steps: 7,
190
+ });
191
+ const approachSpeed = useParam('approachSpeed', 80, {
192
+ range: 30,
193
+ min: 10,
194
+ max: 80,
195
+ steps: 7,
196
+ });
197
+ const distanceDeadZone = useParam('distanceDeadZone', 31, {
198
+ range: 30,
199
+ min: 10,
200
+ max: 80,
201
+ steps: 7,
202
+ });
203
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
204
+ range: 75,
205
+ min: 50,
206
+ max: 300,
207
+ });
208
+ const blinkWindowMax = useParam('blinkWindowMax', 35, {
209
+ range: 30,
210
+ min: 15,
211
+ max: 80,
212
+ steps: 7,
213
+ });
214
+ const blinkDistanceOffset = useParam('blinkDistanceOffset', 100, {
215
+ range: 40,
216
+ min: 0,
217
+ max: 100,
218
+ steps: 7,
219
+ });
220
+ const cancelMinRemainingCast = useParam('cancelMinRemainingCast', 19, {
221
+ range: 30,
222
+ min: 5,
223
+ max: 100,
224
+ steps: 7,
225
+ });
226
+ const interruptRange = useParam('interruptRange', 324, {
227
+ range: 150,
228
+ min: 150,
229
+ max: 600,
230
+ steps: 7,
231
+ });
232
+ const attackRange = useParam('attackRange', 556, {
233
+ range: 150,
234
+ min: 200,
235
+ max: 700,
236
+ });
237
+
238
+ const enemy = state.enemies[0];
239
+ if (!enemy)
240
+ {
241
+ return {move: {x: 0, y: 0}};
242
+ }
243
+
244
+ const distance = distanceTo(state.position, enemy.position);
245
+
246
+ // Analyze threats using simulation
247
+ const threats = getThreats(state);
248
+ const urgentThreat = getMostUrgentThreat(threats);
249
+
250
+ // === DEFENSE: Handle channeling ===
251
+ if (state.state === 'channeling')
252
+ {
253
+ // Cancel shield if no imminent threats
254
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
255
+ {
256
+ return {move: {x: 0, y: 0}, cancel: true};
257
+ }
258
+ return {move: {x: 0, y: 0}};
259
+ }
260
+
261
+ // === DEFENSE: Cancel long missile casts if undodgeable threat approaches ===
262
+ // Only cancel when health is too low to trade (aggressive casts accepted the risk).
263
+ if (
264
+ state.state === 'casting' &&
265
+ state.castingSpell === 'missile' &&
266
+ urgentThreat &&
267
+ !urgentThreat.bestDodgeDirection
268
+ )
269
+ {
270
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
271
+ if (
272
+ remainingCast > cancelMinRemainingCast &&
273
+ urgentThreat.ticksToImpact <=
274
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1 &&
275
+ state.health <= urgentThreat.projectile.damage * 2 + 1
276
+ )
277
+ {
278
+ const dodgeDirection = getDodgeDirectionForMovement(
279
+ threats,
280
+ state.position,
281
+ );
282
+ const move = combatMove(
283
+ state.position,
284
+ enemy,
285
+ distance,
286
+ targetDistance,
287
+ dodgeDirection,
288
+ state.tick,
289
+ strafeCyclePeriod,
290
+ strafeIntensity,
291
+ approachSpeed,
292
+ distanceDeadZone,
293
+ );
294
+ return {move, cancel: true};
295
+ }
296
+ }
297
+
298
+ // === BLINK: Dodge missile + close gap simultaneously (first priority) ===
299
+ // Blink both dodges the incoming missile AND closes distance — better than shielding.
300
+ // Only shield when blink is on cooldown.
301
+ if (
302
+ state.state === 'idle' &&
303
+ urgentThreat &&
304
+ state.blinkCooldown === 0 &&
305
+ distance > targetDistance - blinkDistanceOffset &&
306
+ urgentThreat.ticksToImpact >= 10 &&
307
+ urgentThreat.ticksToImpact <= blinkWindowMax
308
+ )
309
+ {
310
+ const blinkTarget =
311
+ getBlinkToDecreaseDistance(
312
+ state.position,
313
+ enemy.position,
314
+ state.projectiles,
315
+ ) ?? enemy.position;
316
+ return {
317
+ move: {x: 0, y: 0},
318
+ startCast: {spell: 'blink', target: blinkTarget},
319
+ };
320
+ }
321
+
322
+ // === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
323
+ if (
324
+ state.state === 'idle' &&
325
+ urgentThreat &&
326
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
327
+ )
328
+ {
329
+ if (
330
+ urgentThreat.canBlockInTime &&
331
+ urgentThreat.ticksToStartShield <= 3 &&
332
+ (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
333
+ )
334
+ {
335
+ return {
336
+ move: {x: 0, y: 0},
337
+ startCast: {spell: 'shield'},
338
+ };
339
+ }
340
+ }
341
+
342
+ // Movement: smart strafe + maintain optimal distance
343
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
344
+ const move = combatMove(
345
+ state.position,
346
+ enemy,
347
+ distance,
348
+ targetDistance,
349
+ dodgeDirection,
350
+ state.tick,
351
+ strafeCyclePeriod,
352
+ strafeIntensity,
353
+ approachSpeed,
354
+ distanceDeadZone,
355
+ );
356
+
357
+ // === OFFENSE ===
358
+ if (state.state === 'idle')
359
+ {
360
+ // Interrupt if enemy is casting (quick attack)
361
+ if (enemy.state === 'casting' && distance < interruptRange)
362
+ {
363
+ const castTime = getMissileCastTime(QUICK_CONFIG);
364
+ if (isSafeToAttack(threats, castTime))
365
+ {
366
+ return {
367
+ move,
368
+ startCast: {
369
+ spell: 'missile',
370
+ config: QUICK_CONFIG,
371
+ missileAI: StandardHoming,
372
+ direction: angleTo(state.position, enemy.position),
373
+ },
374
+ };
375
+ }
376
+ }
377
+
378
+ // Standard attacks at combat range
379
+ if (distance < attackRange)
380
+ {
381
+ const heavyCastTime = getMissileCastTime(HEAVY_CONFIG);
382
+ if (isSafeToAttack(threats, heavyCastTime))
383
+ {
384
+ return {
385
+ move,
386
+ startCast: {
387
+ spell: 'missile',
388
+ config: HEAVY_CONFIG,
389
+ missileAI: StandardHoming,
390
+ direction: angleTo(state.position, enemy.position),
391
+ },
392
+ };
393
+ }
394
+
395
+ // Fallback: quick homing missile when heavy isn't safe (only if in range)
396
+ const quickCastTime = getMissileCastTime(QUICK_CONFIG);
397
+ if (
398
+ distance < QUICK_RANGE * 0.9 &&
399
+ isSafeToAttack(threats, quickCastTime)
400
+ )
401
+ {
402
+ return {
403
+ move,
404
+ startCast: {
405
+ spell: 'missile',
406
+ config: QUICK_CONFIG,
407
+ missileAI: StandardHoming,
408
+ direction: angleTo(state.position, enemy.position),
409
+ },
410
+ };
411
+ }
412
+
413
+ // Not safe to attack — wait for threat to pass, then fire
414
+ }
415
+ }
416
+
417
+ // === FALLBACK DEFENSE: Shield when no attack window exists ===
418
+ if (
419
+ state.state === 'idle' &&
420
+ urgentThreat &&
421
+ !urgentThreat.bestDodgeDirection &&
422
+ urgentThreat.canBlockInTime &&
423
+ urgentThreat.ticksToStartShield <= 3
424
+ )
425
+ {
426
+ return {
427
+ move: {x: 0, y: 0},
428
+ startCast: {spell: 'shield'},
429
+ };
430
+ }
431
+
432
+ return {move};
433
+ };