@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,438 +1,438 @@
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 {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
9
- import {
10
- getThreats,
11
- getMostUrgentThreat,
12
- getDodgeDirectionForMovement,
13
- fitMissileToBudget,
14
- getBlinkToDecreaseDistance,
15
- getBlinkToIncreaseDistance,
16
- shouldForceShield,
17
- MIN_BLINK_ESCAPE_DISTANCE,
18
- MAX_CAST_BUDGET,
19
- MIN_USEFUL_DAMAGE,
20
- } from '../shared.js';
21
- import {useParam} from '../../engine/params-runtime.js';
22
- const WALL_BUFFER = 60;
23
-
24
- const SHIELD_BUFFER = 3;
25
-
26
- /**
27
- * Standard homing missile AI.
28
- */
29
- const StandardHoming: MissileAIFunction = ({worldState}) =>
30
- {
31
- const enemy = worldState.enemies[0];
32
- return {
33
- turnToward: enemy?.position,
34
- };
35
- };
36
-
37
- /**
38
- * Check if near wall.
39
- */
40
- function isNearWall(position: {x: number; y: number}): boolean
41
- {
42
- return (
43
- position.x < WALL_BUFFER ||
44
- position.x > ARENA_SIZE - WALL_BUFFER ||
45
- position.y < WALL_BUFFER ||
46
- position.y > ARENA_SIZE - WALL_BUFFER
47
- );
48
- }
49
-
50
- /**
51
- * Smart combat movement with threat-aware dodging.
52
- */
53
- function combatMove(
54
- position: {x: number; y: number},
55
- enemy: {position: {x: number; y: number}},
56
- currentDistance: number,
57
- targetDistance: number,
58
- dodgeDirection: {x: number; y: number} | null,
59
- tick: number,
60
- strafeCyclePeriod: number,
61
- strafeIntensity: number,
62
- approachSpeed: number,
63
- distanceDeadZone: number,
64
- ): {x: number; y: number}
65
- {
66
- const deltaX = enemy.position.x - position.x;
67
- const deltaY = enemy.position.y - position.y;
68
- const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
69
-
70
- // Perpendicular directions
71
- const strafeClockwise = {
72
- x: -deltaY / normalizedDistance,
73
- y: deltaX / normalizedDistance,
74
- };
75
- const strafeCounterClockwise = {
76
- x: deltaY / normalizedDistance,
77
- y: -deltaX / normalizedDistance,
78
- };
79
-
80
- let strafeDirection: {x: number; y: number};
81
-
82
- if (dodgeDirection)
83
- {
84
- strafeDirection = dodgeDirection;
85
- }
86
- else if (isNearWall(position))
87
- {
88
- const futureClockwise = {
89
- x: position.x + strafeClockwise.x * 100,
90
- y: position.y + strafeClockwise.y * 100,
91
- };
92
- const futureCounterClockwise = {
93
- x: position.x + strafeCounterClockwise.x * 100,
94
- y: position.y + strafeCounterClockwise.y * 100,
95
- };
96
- const scoreClockwise = Math.min(
97
- futureClockwise.x,
98
- ARENA_SIZE - futureClockwise.x,
99
- futureClockwise.y,
100
- ARENA_SIZE - futureClockwise.y,
101
- );
102
- const scoreCounterClockwise = Math.min(
103
- futureCounterClockwise.x,
104
- ARENA_SIZE - futureCounterClockwise.x,
105
- futureCounterClockwise.y,
106
- ARENA_SIZE - futureCounterClockwise.y,
107
- );
108
- strafeDirection =
109
- scoreClockwise > scoreCounterClockwise
110
- ? strafeClockwise
111
- : strafeCounterClockwise;
112
- }
113
- else
114
- {
115
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
116
- strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
117
- }
118
-
119
- if (dodgeDirection)
120
- {
121
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
122
- }
123
-
124
- let moveX = strafeDirection.x * strafeIntensity;
125
- let moveY = strafeDirection.y * strafeIntensity;
126
-
127
- if (currentDistance > targetDistance + distanceDeadZone)
128
- {
129
- moveX += (deltaX / normalizedDistance) * approachSpeed;
130
- moveY += (deltaY / normalizedDistance) * approachSpeed;
131
- }
132
- else if (currentDistance < targetDistance - distanceDeadZone)
133
- {
134
- let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
135
- let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
136
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
137
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
138
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
139
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
140
- moveX += retreatX;
141
- moveY += retreatY;
142
- }
143
-
144
- const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
145
- if (magnitude > 100)
146
- {
147
- moveX = (moveX / magnitude) * 100;
148
- moveY = (moveY / magnitude) * 100;
149
- }
150
-
151
- return {x: moveX, y: moveY};
152
- }
153
-
154
- /**
155
- * Bot: Warmage
156
- *
157
- * BEHAVIOR: Enhanced Battlemage with adaptive missile fitting. Uses fitMissileToBudget
158
- * to maximize damage within safe attack windows instead of fixed quick/heavy configs.
159
- * Same close-range playstyle: shields undodgeable threats, blinks to close distance
160
- * or escape, aggressive hit-trading when health allows. The adaptive fitting means
161
- * every attack is optimized for the current situation — no wasted cast time.
162
- *
163
- * PROGRESSION LINE: Battlemage → Warmage → Archmage
164
- * - Battlemage (tier 1): Quick/heavy fixed configs, cast interruption, basic defense
165
- * - Warmage (tier 2): Adaptive missile fitting, optimized damage windows
166
- * - Archmage (tier 3): Future — supreme duelist, perfect tactical mastery
167
- *
168
- * TIER: 2 (enhanced Battlemage)
169
- */
170
- export const Warmage: WizardFunction = ({state}) =>
171
- {
172
- const targetDistance = useParam('targetDistance', 188, {
173
- range: 150,
174
- min: 0,
175
- });
176
- const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, steps: 5});
177
- const blinkEscapeDistance = useParam('blinkEscapeDistance', 42, {
178
- range: 190,
179
- min: 0,
180
- steps: 7,
181
- });
182
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 299, {
183
- range: 75,
184
- min: 80,
185
- max: 300,
186
- });
187
- const strafeIntensity = useParam('strafeIntensity', 33, {
188
- range: 40,
189
- min: 20,
190
- max: 100,
191
- steps: 7,
192
- });
193
- const approachSpeed = useParam('approachSpeed', 80, {
194
- range: 30,
195
- min: 10,
196
- max: 80,
197
- steps: 7,
198
- });
199
- const distanceDeadZone = useParam('distanceDeadZone', 33, {
200
- range: 30,
201
- min: 10,
202
- max: 80,
203
- steps: 7,
204
- });
205
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
206
- range: 75,
207
- min: 50,
208
- max: 300,
209
- });
210
- const blinkWindowMax = useParam('blinkWindowMax', 37, {
211
- range: 30,
212
- min: 15,
213
- max: 80,
214
- steps: 7,
215
- });
216
- const blinkDistanceOffset = useParam('blinkDistanceOffset', 100, {
217
- range: 40,
218
- min: 0,
219
- max: 100,
220
- steps: 7,
221
- });
222
- const attackRange = useParam('attackRange', 300, {
223
- range: 150,
224
- min: 200,
225
- max: 700,
226
- });
227
- const flightTimeDivisor = useParam('flightTimeDivisor', 9.5, {
228
- range: 4,
229
- min: 2,
230
- max: 12,
231
- steps: 5,
232
- });
233
-
234
- const enemy = state.enemies[0];
235
- if (!enemy)
236
- {
237
- return {move: {x: 0, y: 0}};
238
- }
239
-
240
- const distance = distanceTo(state.position, enemy.position);
241
-
242
- // Analyze threats using simulation
243
- const threats = getThreats(state);
244
- const urgentThreat = getMostUrgentThreat(threats);
245
-
246
- // === DEFENSE: Handle channeling ===
247
- if (state.state === 'channeling')
248
- {
249
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
250
- {
251
- return {move: {x: 0, y: 0}, cancel: true};
252
- }
253
- return {move: {x: 0, y: 0}};
254
- }
255
-
256
- // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
257
- // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
258
- if (
259
- state.state === 'casting' &&
260
- state.castingSpell === 'missile' &&
261
- urgentThreat &&
262
- urgentThreat.projectile.damage >= state.health
263
- )
264
- {
265
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
266
- if (
267
- urgentThreat.ticksToImpact <=
268
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
269
- )
270
- {
271
- const dodgeDirection = getDodgeDirectionForMovement(
272
- threats,
273
- state.position,
274
- );
275
- const move = combatMove(
276
- state.position,
277
- enemy,
278
- distance,
279
- targetDistance,
280
- dodgeDirection,
281
- state.tick,
282
- strafeCyclePeriod,
283
- strafeIntensity,
284
- approachSpeed,
285
- distanceDeadZone,
286
- );
287
- return {move, cancel: true};
288
- }
289
- }
290
-
291
- // === BLINK: Dodge missile + close gap simultaneously (first priority) ===
292
- // Blink both dodges the incoming missile AND closes distance — better than shielding.
293
- // Only shield when blink is on cooldown.
294
- if (
295
- state.state === 'idle' &&
296
- urgentThreat &&
297
- state.blinkCooldown === 0 &&
298
- distance > targetDistance - blinkDistanceOffset &&
299
- urgentThreat.ticksToImpact >= 10 &&
300
- urgentThreat.ticksToImpact <= blinkWindowMax
301
- )
302
- {
303
- const blinkTarget =
304
- getBlinkToDecreaseDistance(
305
- state.position,
306
- enemy.position,
307
- state.projectiles,
308
- ) ?? enemy.position;
309
- return {
310
- move: {x: 0, y: 0},
311
- startCast: {spell: 'blink', target: blinkTarget},
312
- };
313
- }
314
-
315
- // === BLINK ESCAPE: Enemy too close, blink away to reestablish dueling range ===
316
- // Warmage is a duelist at 280 range — if enemy closes to melee, blink out.
317
- if (
318
- state.state === 'idle' &&
319
- distance < blinkEscapeDistance &&
320
- state.blinkCooldown === 0 &&
321
- (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
322
- )
323
- {
324
- const escapeTarget = getBlinkToIncreaseDistance(
325
- state.position,
326
- enemy.position,
327
- state.projectiles,
328
- MIN_BLINK_ESCAPE_DISTANCE,
329
- );
330
- if (escapeTarget)
331
- {
332
- return {
333
- move: {x: 0, y: 0},
334
- startCast: {spell: 'blink', target: escapeTarget},
335
- };
336
- }
337
- }
338
-
339
- // === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
340
- if (
341
- state.state === 'idle' &&
342
- urgentThreat &&
343
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
344
- )
345
- {
346
- if (
347
- urgentThreat.canBlockInTime &&
348
- urgentThreat.ticksToStartShield <= 3 &&
349
- (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
350
- )
351
- {
352
- return {
353
- move: {x: 0, y: 0},
354
- startCast: {spell: 'shield'},
355
- };
356
- }
357
- }
358
-
359
- // Movement
360
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
361
- const move = combatMove(
362
- state.position,
363
- enemy,
364
- distance,
365
- targetDistance,
366
- dodgeDirection,
367
- state.tick,
368
- strafeCyclePeriod,
369
- strafeIntensity,
370
- approachSpeed,
371
- distanceDeadZone,
372
- );
373
-
374
- // === OFFENSE: Adaptive missile fitting ===
375
- if (state.state === 'idle' && distance < attackRange)
376
- {
377
- let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
378
-
379
- // Account for enemy's active cast — their missile will arrive after cast completes + flight time
380
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
381
- {
382
- const remainingEnemyCast =
383
- (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
384
- const estimatedFlightTime = distance / flightTimeDivisor;
385
- nextThreatTime = Math.min(
386
- nextThreatTime,
387
- remainingEnemyCast + estimatedFlightTime,
388
- );
389
- }
390
-
391
- const safeBudget =
392
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
393
- const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
394
-
395
- // Warmage moves while casting, so always need homing to compensate
396
- const enemyHP = Math.ceil(enemy.health);
397
- const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
398
- const config = fitMissileToBudget(budgetTicks, distance, {
399
- minTurnRate,
400
- maxDamage: enemyHP,
401
- lastMissileConfig: state.lastMissileConfig,
402
- });
403
-
404
- if (config && config.damage >= minDmg)
405
- {
406
- return {
407
- move,
408
- startCast: {
409
- spell: 'missile',
410
- config,
411
- missileAI:
412
- config.turnRate > 0 ? StandardHoming : (): MissileActions => ({}),
413
- direction: angleTo(state.position, enemy.position),
414
- },
415
- };
416
- }
417
-
418
- // Budget too small for useful missile — wait for threat to pass, then fire
419
- }
420
-
421
- // === FALLBACK DEFENSE: Shield when no attack window exists ===
422
- // If all offense checks failed and there's an undodgeable threat, shield it.
423
- if (
424
- state.state === 'idle' &&
425
- urgentThreat &&
426
- !urgentThreat.bestDodgeDirection &&
427
- urgentThreat.canBlockInTime &&
428
- urgentThreat.ticksToStartShield <= 3
429
- )
430
- {
431
- return {
432
- move: {x: 0, y: 0},
433
- startCast: {spell: 'shield'},
434
- };
435
- }
436
-
437
- return {move};
438
- };
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 {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
9
+ import {
10
+ getThreats,
11
+ getMostUrgentThreat,
12
+ getDodgeDirectionForMovement,
13
+ fitMissileToBudget,
14
+ getBlinkToDecreaseDistance,
15
+ getBlinkToIncreaseDistance,
16
+ shouldForceShield,
17
+ MIN_BLINK_ESCAPE_DISTANCE,
18
+ MAX_CAST_BUDGET,
19
+ MIN_USEFUL_DAMAGE,
20
+ } from '../shared.js';
21
+ import {useParam} from '../../engine/params-runtime.js';
22
+ const WALL_BUFFER = 60;
23
+
24
+ const SHIELD_BUFFER = 3;
25
+
26
+ /**
27
+ * Standard homing missile AI.
28
+ */
29
+ const StandardHoming: MissileAIFunction = ({worldState}) =>
30
+ {
31
+ const enemy = worldState.enemies[0];
32
+ return {
33
+ turnToward: enemy?.position,
34
+ };
35
+ };
36
+
37
+ /**
38
+ * Check if near wall.
39
+ */
40
+ function isNearWall(position: {x: number; y: number}): boolean
41
+ {
42
+ return (
43
+ position.x < WALL_BUFFER ||
44
+ position.x > ARENA_SIZE - WALL_BUFFER ||
45
+ position.y < WALL_BUFFER ||
46
+ position.y > ARENA_SIZE - WALL_BUFFER
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Smart combat movement with threat-aware dodging.
52
+ */
53
+ function combatMove(
54
+ position: {x: number; y: number},
55
+ enemy: {position: {x: number; y: number}},
56
+ currentDistance: number,
57
+ targetDistance: number,
58
+ dodgeDirection: {x: number; y: number} | null,
59
+ tick: number,
60
+ strafeCyclePeriod: number,
61
+ strafeIntensity: number,
62
+ approachSpeed: number,
63
+ distanceDeadZone: number,
64
+ ): {x: number; y: number}
65
+ {
66
+ const deltaX = enemy.position.x - position.x;
67
+ const deltaY = enemy.position.y - position.y;
68
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
69
+
70
+ // Perpendicular directions
71
+ const strafeClockwise = {
72
+ x: -deltaY / normalizedDistance,
73
+ y: deltaX / normalizedDistance,
74
+ };
75
+ const strafeCounterClockwise = {
76
+ x: deltaY / normalizedDistance,
77
+ y: -deltaX / normalizedDistance,
78
+ };
79
+
80
+ let strafeDirection: {x: number; y: number};
81
+
82
+ if (dodgeDirection)
83
+ {
84
+ strafeDirection = dodgeDirection;
85
+ }
86
+ else if (isNearWall(position))
87
+ {
88
+ const futureClockwise = {
89
+ x: position.x + strafeClockwise.x * 100,
90
+ y: position.y + strafeClockwise.y * 100,
91
+ };
92
+ const futureCounterClockwise = {
93
+ x: position.x + strafeCounterClockwise.x * 100,
94
+ y: position.y + strafeCounterClockwise.y * 100,
95
+ };
96
+ const scoreClockwise = Math.min(
97
+ futureClockwise.x,
98
+ ARENA_SIZE - futureClockwise.x,
99
+ futureClockwise.y,
100
+ ARENA_SIZE - futureClockwise.y,
101
+ );
102
+ const scoreCounterClockwise = Math.min(
103
+ futureCounterClockwise.x,
104
+ ARENA_SIZE - futureCounterClockwise.x,
105
+ futureCounterClockwise.y,
106
+ ARENA_SIZE - futureCounterClockwise.y,
107
+ );
108
+ strafeDirection =
109
+ scoreClockwise > scoreCounterClockwise
110
+ ? strafeClockwise
111
+ : strafeCounterClockwise;
112
+ }
113
+ else
114
+ {
115
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
116
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
117
+ }
118
+
119
+ if (dodgeDirection)
120
+ {
121
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
122
+ }
123
+
124
+ let moveX = strafeDirection.x * strafeIntensity;
125
+ let moveY = strafeDirection.y * strafeIntensity;
126
+
127
+ if (currentDistance > targetDistance + distanceDeadZone)
128
+ {
129
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
130
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
131
+ }
132
+ else if (currentDistance < targetDistance - distanceDeadZone)
133
+ {
134
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
135
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
136
+ if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
137
+ if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
138
+ if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
139
+ if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
140
+ moveX += retreatX;
141
+ moveY += retreatY;
142
+ }
143
+
144
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
145
+ if (magnitude > 100)
146
+ {
147
+ moveX = (moveX / magnitude) * 100;
148
+ moveY = (moveY / magnitude) * 100;
149
+ }
150
+
151
+ return {x: moveX, y: moveY};
152
+ }
153
+
154
+ /**
155
+ * Bot: Warmage
156
+ *
157
+ * BEHAVIOR: Enhanced Battlemage with adaptive missile fitting. Uses fitMissileToBudget
158
+ * to maximize damage within safe attack windows instead of fixed quick/heavy configs.
159
+ * Same close-range playstyle: shields undodgeable threats, blinks to close distance
160
+ * or escape, aggressive hit-trading when health allows. The adaptive fitting means
161
+ * every attack is optimized for the current situation — no wasted cast time.
162
+ *
163
+ * PROGRESSION LINE: Battlemage → Warmage → Archmage
164
+ * - Battlemage (tier 1): Quick/heavy fixed configs, cast interruption, basic defense
165
+ * - Warmage (tier 2): Adaptive missile fitting, optimized damage windows
166
+ * - Archmage (tier 3): Future — supreme duelist, perfect tactical mastery
167
+ *
168
+ * TIER: 2 (enhanced Battlemage)
169
+ */
170
+ export const Warmage: WizardFunction = ({state}) =>
171
+ {
172
+ const targetDistance = useParam('targetDistance', 188, {
173
+ range: 150,
174
+ min: 0,
175
+ });
176
+ const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, steps: 5});
177
+ const blinkEscapeDistance = useParam('blinkEscapeDistance', 42, {
178
+ range: 190,
179
+ min: 0,
180
+ steps: 7,
181
+ });
182
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 299, {
183
+ range: 75,
184
+ min: 80,
185
+ max: 300,
186
+ });
187
+ const strafeIntensity = useParam('strafeIntensity', 33, {
188
+ range: 40,
189
+ min: 20,
190
+ max: 100,
191
+ steps: 7,
192
+ });
193
+ const approachSpeed = useParam('approachSpeed', 80, {
194
+ range: 30,
195
+ min: 10,
196
+ max: 80,
197
+ steps: 7,
198
+ });
199
+ const distanceDeadZone = useParam('distanceDeadZone', 33, {
200
+ range: 30,
201
+ min: 10,
202
+ max: 80,
203
+ steps: 7,
204
+ });
205
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
206
+ range: 75,
207
+ min: 50,
208
+ max: 300,
209
+ });
210
+ const blinkWindowMax = useParam('blinkWindowMax', 37, {
211
+ range: 30,
212
+ min: 15,
213
+ max: 80,
214
+ steps: 7,
215
+ });
216
+ const blinkDistanceOffset = useParam('blinkDistanceOffset', 100, {
217
+ range: 40,
218
+ min: 0,
219
+ max: 100,
220
+ steps: 7,
221
+ });
222
+ const attackRange = useParam('attackRange', 300, {
223
+ range: 150,
224
+ min: 200,
225
+ max: 700,
226
+ });
227
+ const flightTimeDivisor = useParam('flightTimeDivisor', 9.5, {
228
+ range: 4,
229
+ min: 2,
230
+ max: 12,
231
+ steps: 5,
232
+ });
233
+
234
+ const enemy = state.enemies[0];
235
+ if (!enemy)
236
+ {
237
+ return {move: {x: 0, y: 0}};
238
+ }
239
+
240
+ const distance = distanceTo(state.position, enemy.position);
241
+
242
+ // Analyze threats using simulation
243
+ const threats = getThreats(state);
244
+ const urgentThreat = getMostUrgentThreat(threats);
245
+
246
+ // === DEFENSE: Handle channeling ===
247
+ if (state.state === 'channeling')
248
+ {
249
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
250
+ {
251
+ return {move: {x: 0, y: 0}, cancel: true};
252
+ }
253
+ return {move: {x: 0, y: 0}};
254
+ }
255
+
256
+ // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
257
+ // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
258
+ if (
259
+ state.state === 'casting' &&
260
+ state.castingSpell === 'missile' &&
261
+ urgentThreat &&
262
+ urgentThreat.projectile.damage >= state.health
263
+ )
264
+ {
265
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
266
+ if (
267
+ urgentThreat.ticksToImpact <=
268
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
269
+ )
270
+ {
271
+ const dodgeDirection = getDodgeDirectionForMovement(
272
+ threats,
273
+ state.position,
274
+ );
275
+ const move = combatMove(
276
+ state.position,
277
+ enemy,
278
+ distance,
279
+ targetDistance,
280
+ dodgeDirection,
281
+ state.tick,
282
+ strafeCyclePeriod,
283
+ strafeIntensity,
284
+ approachSpeed,
285
+ distanceDeadZone,
286
+ );
287
+ return {move, cancel: true};
288
+ }
289
+ }
290
+
291
+ // === BLINK: Dodge missile + close gap simultaneously (first priority) ===
292
+ // Blink both dodges the incoming missile AND closes distance — better than shielding.
293
+ // Only shield when blink is on cooldown.
294
+ if (
295
+ state.state === 'idle' &&
296
+ urgentThreat &&
297
+ state.blinkCooldown === 0 &&
298
+ distance > targetDistance - blinkDistanceOffset &&
299
+ urgentThreat.ticksToImpact >= 10 &&
300
+ urgentThreat.ticksToImpact <= blinkWindowMax
301
+ )
302
+ {
303
+ const blinkTarget =
304
+ getBlinkToDecreaseDistance(
305
+ state.position,
306
+ enemy.position,
307
+ state.projectiles,
308
+ ) ?? enemy.position;
309
+ return {
310
+ move: {x: 0, y: 0},
311
+ startCast: {spell: 'blink', target: blinkTarget},
312
+ };
313
+ }
314
+
315
+ // === BLINK ESCAPE: Enemy too close, blink away to reestablish dueling range ===
316
+ // Warmage is a duelist at 280 range — if enemy closes to melee, blink out.
317
+ if (
318
+ state.state === 'idle' &&
319
+ distance < blinkEscapeDistance &&
320
+ state.blinkCooldown === 0 &&
321
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
322
+ )
323
+ {
324
+ const escapeTarget = getBlinkToIncreaseDistance(
325
+ state.position,
326
+ enemy.position,
327
+ state.projectiles,
328
+ MIN_BLINK_ESCAPE_DISTANCE,
329
+ );
330
+ if (escapeTarget)
331
+ {
332
+ return {
333
+ move: {x: 0, y: 0},
334
+ startCast: {spell: 'blink', target: escapeTarget},
335
+ };
336
+ }
337
+ }
338
+
339
+ // === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
340
+ if (
341
+ state.state === 'idle' &&
342
+ urgentThreat &&
343
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
344
+ )
345
+ {
346
+ if (
347
+ urgentThreat.canBlockInTime &&
348
+ urgentThreat.ticksToStartShield <= 3 &&
349
+ (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
350
+ )
351
+ {
352
+ return {
353
+ move: {x: 0, y: 0},
354
+ startCast: {spell: 'shield'},
355
+ };
356
+ }
357
+ }
358
+
359
+ // Movement
360
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
361
+ const move = combatMove(
362
+ state.position,
363
+ enemy,
364
+ distance,
365
+ targetDistance,
366
+ dodgeDirection,
367
+ state.tick,
368
+ strafeCyclePeriod,
369
+ strafeIntensity,
370
+ approachSpeed,
371
+ distanceDeadZone,
372
+ );
373
+
374
+ // === OFFENSE: Adaptive missile fitting ===
375
+ if (state.state === 'idle' && distance < attackRange)
376
+ {
377
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
378
+
379
+ // Account for enemy's active cast — their missile will arrive after cast completes + flight time
380
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
381
+ {
382
+ const remainingEnemyCast =
383
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
384
+ const estimatedFlightTime = distance / flightTimeDivisor;
385
+ nextThreatTime = Math.min(
386
+ nextThreatTime,
387
+ remainingEnemyCast + estimatedFlightTime,
388
+ );
389
+ }
390
+
391
+ const safeBudget =
392
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
393
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
394
+
395
+ // Warmage moves while casting, so always need homing to compensate
396
+ const enemyHP = Math.ceil(enemy.health);
397
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
398
+ const config = fitMissileToBudget(budgetTicks, distance, {
399
+ minTurnRate,
400
+ maxDamage: enemyHP,
401
+ lastMissileConfig: state.lastMissileConfig,
402
+ });
403
+
404
+ if (config && config.damage >= minDmg)
405
+ {
406
+ return {
407
+ move,
408
+ startCast: {
409
+ spell: 'missile',
410
+ config,
411
+ missileAI:
412
+ config.turnRate > 0 ? StandardHoming : (): MissileActions => ({}),
413
+ direction: angleTo(state.position, enemy.position),
414
+ },
415
+ };
416
+ }
417
+
418
+ // Budget too small for useful missile — wait for threat to pass, then fire
419
+ }
420
+
421
+ // === FALLBACK DEFENSE: Shield when no attack window exists ===
422
+ // If all offense checks failed and there's an undodgeable threat, shield it.
423
+ if (
424
+ state.state === 'idle' &&
425
+ urgentThreat &&
426
+ !urgentThreat.bestDodgeDirection &&
427
+ urgentThreat.canBlockInTime &&
428
+ urgentThreat.ticksToStartShield <= 3
429
+ )
430
+ {
431
+ return {
432
+ move: {x: 0, y: 0},
433
+ startCast: {spell: 'shield'},
434
+ };
435
+ }
436
+
437
+ return {move};
438
+ };