@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,588 +1,588 @@
1
- import {WizardFunction, MissileAIFunction} from '../../types.js';
2
- import {angleTo} from '../../utils/angles.js';
3
- import {distanceTo} from '../../utils/distance.js';
4
- import {
5
- ARENA_SIZE,
6
- GCD_DURATION,
7
- SHIELD_CAST_TIME,
8
- BLINK_CAST_TIME,
9
- } from '../../rules.js';
10
- import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
11
- import {
12
- getThreats,
13
- getMostUrgentThreat,
14
- getDodgeDirectionForMovement,
15
- fitMissileToBudget,
16
- getBlinkToDecreaseDistance,
17
- getBlinkToIncreaseDistance,
18
- shouldForceShield,
19
- MIN_BLINK_ESCAPE_DISTANCE,
20
- MAX_CAST_BUDGET,
21
- MIN_USEFUL_DAMAGE,
22
- getEnemyVulnerabilityTicks,
23
- isSafeToAttack,
24
- } from '../shared.js';
25
- import {useParam} from '../../engine/params-runtime.js';
26
-
27
- const WALL_BUFFER = 60;
28
- const SHIELD_BUFFER = 3;
29
-
30
- const HomingAI: MissileAIFunction = ({worldState}) =>
31
- {
32
- const enemy = worldState.enemies[0];
33
- return {turnToward: enemy?.position};
34
- };
35
-
36
- const StraightAI: MissileAIFunction = () => ({});
37
-
38
- function isNearWall(position: {x: number; y: number}): boolean
39
- {
40
- return (
41
- position.x < WALL_BUFFER ||
42
- position.x > ARENA_SIZE - WALL_BUFFER ||
43
- position.y < WALL_BUFFER ||
44
- position.y > ARENA_SIZE - WALL_BUFFER
45
- );
46
- }
47
-
48
- /**
49
- * Smart combat movement with threat-aware dodging.
50
- */
51
- function combatMove(
52
- position: {x: number; y: number},
53
- enemy: {position: {x: number; y: number}},
54
- currentDistance: number,
55
- targetDistance: number,
56
- dodgeDirection: {x: number; y: number} | null,
57
- tick: number,
58
- strafeCyclePeriod: number,
59
- strafeIntensity: number,
60
- approachSpeed: number,
61
- distanceDeadZone: number,
62
- ): {x: number; y: number}
63
- {
64
- const deltaX = enemy.position.x - position.x;
65
- const deltaY = enemy.position.y - position.y;
66
- const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
67
-
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
- if (dodgeDirection)
80
- {
81
- strafeDirection = dodgeDirection;
82
- }
83
- else if (isNearWall(position))
84
- {
85
- const futureClockwise = {
86
- x: position.x + strafeClockwise.x * 100,
87
- y: position.y + strafeClockwise.y * 100,
88
- };
89
- const futureCounterClockwise = {
90
- x: position.x + strafeCounterClockwise.x * 100,
91
- y: position.y + strafeCounterClockwise.y * 100,
92
- };
93
- const scoreClockwise = Math.min(
94
- futureClockwise.x,
95
- ARENA_SIZE - futureClockwise.x,
96
- futureClockwise.y,
97
- ARENA_SIZE - futureClockwise.y,
98
- );
99
- const scoreCounterClockwise = Math.min(
100
- futureCounterClockwise.x,
101
- ARENA_SIZE - futureCounterClockwise.x,
102
- futureCounterClockwise.y,
103
- ARENA_SIZE - futureCounterClockwise.y,
104
- );
105
- strafeDirection =
106
- scoreClockwise > scoreCounterClockwise
107
- ? strafeClockwise
108
- : strafeCounterClockwise;
109
- }
110
- else
111
- {
112
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
113
- strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
114
- }
115
-
116
- if (dodgeDirection)
117
- {
118
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
119
- }
120
-
121
- let moveX = strafeDirection.x * strafeIntensity;
122
- let moveY = strafeDirection.y * strafeIntensity;
123
-
124
- if (currentDistance > targetDistance + distanceDeadZone)
125
- {
126
- moveX += (deltaX / normalizedDistance) * approachSpeed;
127
- moveY += (deltaY / normalizedDistance) * approachSpeed;
128
- }
129
- else if (currentDistance < targetDistance - distanceDeadZone)
130
- {
131
- let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
132
- let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
133
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
134
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
135
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
136
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
137
- moveX += retreatX;
138
- moveY += retreatY;
139
- }
140
-
141
- const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
142
- if (magnitude > 100)
143
- {
144
- moveX = (moveX / magnitude) * 100;
145
- moveY = (moveY / magnitude) * 100;
146
- }
147
-
148
- return {x: moveX, y: moveY};
149
- }
150
-
151
- /**
152
- * Bot: Archmage
153
- *
154
- * BEHAVIOR: Versatile duelist that adapts missile choice based on distance and HP.
155
- * Close range → straight missiles (no turn cost = more damage). Mid/far range → homing.
156
- * Uses dual-blink aggressively (gap-close during vulnerability, escape when trade is bad).
157
- * HP-aware: ahead → aggressive close range; behind → defensive ranged kiting.
158
- *
159
- * KEY IMPROVEMENTS OVER WARMAGE:
160
- * - Range-adaptive missiles: straight close, homing far
161
- * - Vulnerability-timed blinks: gap-close during enemy cast/GCD
162
- * - HP-aware aggression: adjusts distance + risk tolerance based on HP differential
163
- * - Progressive cast-cancel: graduated thresholds
164
- *
165
- * PROGRESSION LINE: Battlemage → Warmage → Archmage
166
- * TIER: 3 (elite Duelist line)
167
- */
168
- export const Archmage: WizardFunction = ({state}) =>
169
- {
170
- const targetDistance = useParam('targetDistance', 214, {
171
- range: 150,
172
- min: 0,
173
- });
174
- const minTurnRate = useParam('minTurnRate', -14.6, {range: 1.5, steps: 5});
175
- const blinkEscapeDistance = useParam('blinkEscapeDistance', 442, {
176
- range: 190,
177
- min: 0,
178
- steps: 7,
179
- });
180
- const punishMinVulnerability = useParam('punishMinVulnerability', 85, {
181
- range: 30,
182
- min: 10,
183
- steps: 5,
184
- });
185
- const aggressiveHPThreshold = useParam('aggressiveHPThreshold', 4, {
186
- range: 20,
187
- min: 0,
188
- steps: 5,
189
- });
190
- const preferBlinkDodge =
191
- useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
192
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 248, {
193
- range: 75,
194
- min: 80,
195
- max: 250,
196
- });
197
- const strafeIntensity = useParam('strafeIntensity', 100, {
198
- range: 40,
199
- min: 20,
200
- max: 100,
201
- steps: 7,
202
- });
203
- const approachSpeed = useParam('approachSpeed', 57, {
204
- range: 30,
205
- min: 10,
206
- max: 80,
207
- steps: 7,
208
- });
209
- const distanceDeadZone = useParam('distanceDeadZone', 37, {
210
- range: 30,
211
- min: 10,
212
- max: 80,
213
- steps: 7,
214
- });
215
- const aggressiveDistanceOffset = useParam('aggressiveDistanceOffset', 99, {
216
- range: 50,
217
- min: 0,
218
- max: 100,
219
- steps: 7,
220
- });
221
- const cautiousDistanceOffset = useParam('cautiousDistanceOffset', 42, {
222
- range: 50,
223
- min: 0,
224
- max: 120,
225
- steps: 7,
226
- });
227
- const cancelHeavyDmg = useParam('cancelHeavyDmg', 40, {
228
- range: 15,
229
- min: 5,
230
- max: 40,
231
- steps: 5,
232
- });
233
- const cancelMediumDmg = useParam('cancelMediumDmg', 20, {
234
- range: 10,
235
- min: 3,
236
- max: 25,
237
- steps: 5,
238
- });
239
- const cancelCastRatio = useParam('cancelCastRatio', 0.74, {
240
- range: 0.4,
241
- min: 0.3,
242
- max: 1.0,
243
- steps: 5,
244
- });
245
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
246
- range: 75,
247
- min: 50,
248
- max: 300,
249
- });
250
- const blinkWindowMax = useParam('blinkWindowMax', 29, {
251
- range: 40,
252
- min: 20,
253
- max: 120,
254
- steps: 7,
255
- });
256
- const blinkGapThreshold = useParam('blinkGapThreshold', 80, {
257
- range: 75,
258
- min: 20,
259
- max: 200,
260
- steps: 7,
261
- });
262
- const flightTimeDivisor = useParam('flightTimeDivisor', 4.5, {
263
- range: 4,
264
- min: 2,
265
- max: 12,
266
- steps: 5,
267
- });
268
- const closeRangeStraightThreshold = useParam(
269
- 'closeRangeStraightThreshold',
270
- 50,
271
- {range: 100, min: 50, max: 300, steps: 7},
272
- );
273
- const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
274
- range: 25,
275
- min: 10,
276
- max: 80,
277
- steps: 5,
278
- });
279
-
280
- const enemy = state.enemies[0];
281
- if (!enemy)
282
- {
283
- return {move: {x: 0, y: 0}};
284
- }
285
-
286
- const distance = distanceTo(state.position, enemy.position);
287
- const threats = getThreats(state);
288
- const urgentThreat = getMostUrgentThreat(threats);
289
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
290
-
291
- // HP-aware distance: ahead → fight closer, behind → kite at range
292
- const hpDiff = state.health - enemy.health;
293
- const isAggressive = hpDiff >= -aggressiveHPThreshold;
294
- const effectiveTargetDistance = isAggressive
295
- ? targetDistance - aggressiveDistanceOffset
296
- : targetDistance + cautiousDistanceOffset;
297
-
298
- const move = combatMove(
299
- state.position,
300
- enemy,
301
- distance,
302
- effectiveTargetDistance,
303
- dodgeDirection,
304
- state.tick,
305
- strafeCyclePeriod,
306
- strafeIntensity,
307
- approachSpeed,
308
- distanceDeadZone,
309
- );
310
-
311
- // === STEP 1: CHANNELING ===
312
- if (state.state === 'channeling')
313
- {
314
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
315
- {
316
- return {move, cancel: true};
317
- }
318
- return {move: {x: 0, y: 0}};
319
- }
320
-
321
- // === STEP 2: PROGRESSIVE CAST-CANCEL ===
322
- if (
323
- state.state === 'casting' &&
324
- state.castingSpell === 'missile' &&
325
- urgentThreat &&
326
- !urgentThreat.bestDodgeDirection
327
- )
328
- {
329
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
330
- const defenseTime =
331
- state.blinkCooldown === 0
332
- ? BLINK_CAST_TIME
333
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
334
- const willArriveInWindow =
335
- urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
336
-
337
- if (willArriveInWindow)
338
- {
339
- const incomingDamage = urgentThreat.projectile.damage;
340
- const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
341
-
342
- if (incomingDamage >= state.health || incomingDamage >= cancelHeavyDmg)
343
- {
344
- return {move, cancel: true};
345
- }
346
- if (incomingDamage >= cancelMediumDmg && castRatio < cancelCastRatio)
347
- {
348
- return {move, cancel: true};
349
- }
350
- }
351
- }
352
-
353
- // === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
354
- if (
355
- preferBlinkDodge &&
356
- state.state === 'idle' &&
357
- urgentThreat &&
358
- state.blinkCooldown === 0 &&
359
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
360
- urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
361
- urgentThreat.ticksToImpact <= blinkWindowMax
362
- )
363
- {
364
- // Aggressive: blink toward enemy to dodge + close gap
365
- // Defensive: blink away to dodge + increase distance
366
- if (isAggressive && distance > effectiveTargetDistance)
367
- {
368
- const blinkTarget = getBlinkToDecreaseDistance(
369
- state.position,
370
- enemy.position,
371
- state.projectiles,
372
- );
373
- if (blinkTarget)
374
- {
375
- return {
376
- move: {x: 0, y: 0},
377
- startCast: {spell: 'blink', target: blinkTarget},
378
- };
379
- }
380
- }
381
- const blinkTarget = getBlinkToIncreaseDistance(
382
- state.position,
383
- enemy.position,
384
- state.projectiles,
385
- MIN_BLINK_ESCAPE_DISTANCE,
386
- );
387
- if (blinkTarget)
388
- {
389
- return {
390
- move: {x: 0, y: 0},
391
- startCast: {spell: 'blink', target: blinkTarget},
392
- };
393
- }
394
- }
395
-
396
- // === STEP 4: BLINK ESCAPE — enemy too close ===
397
- if (
398
- state.state === 'idle' &&
399
- distance < blinkEscapeDistance &&
400
- state.blinkCooldown === 0 &&
401
- (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
402
- )
403
- {
404
- const escapeTarget = getBlinkToIncreaseDistance(
405
- state.position,
406
- enemy.position,
407
- state.projectiles,
408
- MIN_BLINK_ESCAPE_DISTANCE,
409
- );
410
- if (escapeTarget)
411
- {
412
- return {
413
- move: {x: 0, y: 0},
414
- startCast: {spell: 'blink', target: escapeTarget},
415
- };
416
- }
417
- }
418
-
419
- // === STEP 5: SHIELD ===
420
- if (
421
- state.state === 'idle' &&
422
- urgentThreat &&
423
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
424
- )
425
- {
426
- if (
427
- urgentThreat.canBlockInTime &&
428
- urgentThreat.ticksToStartShield <= SHIELD_BUFFER &&
429
- (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
430
- )
431
- {
432
- return {
433
- move: {x: 0, y: 0},
434
- startCast: {spell: 'shield'},
435
- };
436
- }
437
- }
438
-
439
- // === STEP 6: VULNERABILITY BLINK — gap-close during enemy cast/GCD ===
440
- if (
441
- state.state === 'idle' &&
442
- state.blinkCooldown === 0 &&
443
- isAggressive &&
444
- distance > effectiveTargetDistance + blinkGapThreshold &&
445
- (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
446
- )
447
- {
448
- const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
449
- if (vulnerabilityTicks >= punishMinVulnerability)
450
- {
451
- const blinkTarget = getBlinkToDecreaseDistance(
452
- state.position,
453
- enemy.position,
454
- state.projectiles,
455
- );
456
- if (blinkTarget)
457
- {
458
- return {
459
- move: {x: 0, y: 0},
460
- startCast: {spell: 'blink', target: blinkTarget},
461
- };
462
- }
463
- }
464
- }
465
-
466
- // === STEP 7: OFFENSE ===
467
- if (state.state === 'idle' && distance < 500)
468
- {
469
- const offenseDefenseTime =
470
- state.blinkCooldown === 0
471
- ? BLINK_CAST_TIME
472
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
473
- const enemyHP = Math.ceil(enemy.health);
474
- const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
475
-
476
- // --- PUNISH MODE: range-adaptive missiles during vulnerability ---
477
- if (vulnerabilityTicks >= punishMinVulnerability)
478
- {
479
- const flightTimeEstimate = distance / flightTimeDivisor;
480
- const punishBudget = vulnerabilityTicks - flightTimeEstimate;
481
- const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
482
- const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
483
- const effectiveBudget = Math.min(
484
- punishBudget,
485
- safeBudget,
486
- MAX_CAST_BUDGET,
487
- );
488
-
489
- if (effectiveBudget > 0)
490
- {
491
- // Close range → straight for max damage, far → light homing
492
- const punishTurnRate = distance < closeRangeStraightThreshold ? 0 : 0.2;
493
- const config = fitMissileToBudget(effectiveBudget, distance, {
494
- minTurnRate: punishTurnRate,
495
- maxDamage: enemyHP,
496
- lastMissileConfig: state.lastMissileConfig,
497
- });
498
-
499
- if (config && config.damage >= MIN_USEFUL_DAMAGE)
500
- {
501
- const castTime = getMissileCastTime(config, state.lastMissileConfig);
502
- const actualFlight = distance / config.speed;
503
-
504
- if (
505
- castTime + actualFlight < vulnerabilityTicks &&
506
- isSafeToAttack(threats, castTime)
507
- )
508
- {
509
- const aimTarget =
510
- config.turnRate > 0
511
- ? enemy.position
512
- : getLeadPosition(
513
- enemy.position,
514
- enemy.velocity,
515
- config.speed,
516
- state.position,
517
- );
518
- return {
519
- move,
520
- startCast: {
521
- spell: 'missile',
522
- config,
523
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
524
- direction: angleTo(state.position, aimTarget),
525
- },
526
- };
527
- }
528
- }
529
- }
530
- }
531
-
532
- // --- STANDARD MODE: range-adaptive missiles ---
533
- const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
534
- const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
535
- const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
536
-
537
- // Adapt turn rate to distance: close → straight, far → homing
538
- const effectiveMinTurnRate =
539
- distance < closeRangeStraightThreshold ? 0 : minTurnRate;
540
- const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
541
- let config = fitMissileToBudget(budgetTicks, distance, {
542
- minTurnRate: effectiveMinTurnRate,
543
- maxDamage: enemyHP,
544
- lastMissileConfig: state.lastMissileConfig,
545
- });
546
-
547
- if (
548
- config &&
549
- config.damage >= minDmg &&
550
- isSafeToAttack(threats, safeAttackCastEstimate)
551
- )
552
- {
553
- return {
554
- move,
555
- startCast: {
556
- spell: 'missile',
557
- config,
558
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
559
- direction: angleTo(state.position, enemy.position),
560
- },
561
- };
562
- }
563
-
564
- // Fallback: fire bigger missile, accept incoming hit
565
- if (!config || config.damage < minDmg)
566
- {
567
- config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
568
- minTurnRate: effectiveMinTurnRate,
569
- maxDamage: enemyHP,
570
- lastMissileConfig: state.lastMissileConfig,
571
- });
572
- if (config)
573
- {
574
- return {
575
- move,
576
- startCast: {
577
- spell: 'missile',
578
- config,
579
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
580
- direction: angleTo(state.position, enemy.position),
581
- },
582
- };
583
- }
584
- }
585
- }
586
-
587
- return {move};
588
- };
1
+ import {WizardFunction, MissileAIFunction} from '../../types.js';
2
+ import {angleTo} from '../../utils/angles.js';
3
+ import {distanceTo} from '../../utils/distance.js';
4
+ import {
5
+ ARENA_SIZE,
6
+ GCD_DURATION,
7
+ SHIELD_CAST_TIME,
8
+ BLINK_CAST_TIME,
9
+ } from '../../rules.js';
10
+ import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
11
+ import {
12
+ getThreats,
13
+ getMostUrgentThreat,
14
+ getDodgeDirectionForMovement,
15
+ fitMissileToBudget,
16
+ getBlinkToDecreaseDistance,
17
+ getBlinkToIncreaseDistance,
18
+ shouldForceShield,
19
+ MIN_BLINK_ESCAPE_DISTANCE,
20
+ MAX_CAST_BUDGET,
21
+ MIN_USEFUL_DAMAGE,
22
+ getEnemyVulnerabilityTicks,
23
+ isSafeToAttack,
24
+ } from '../shared.js';
25
+ import {useParam} from '../../engine/params-runtime.js';
26
+
27
+ const WALL_BUFFER = 60;
28
+ const SHIELD_BUFFER = 3;
29
+
30
+ const HomingAI: MissileAIFunction = ({worldState}) =>
31
+ {
32
+ const enemy = worldState.enemies[0];
33
+ return {turnToward: enemy?.position};
34
+ };
35
+
36
+ const StraightAI: MissileAIFunction = () => ({});
37
+
38
+ function isNearWall(position: {x: number; y: number}): boolean
39
+ {
40
+ return (
41
+ position.x < WALL_BUFFER ||
42
+ position.x > ARENA_SIZE - WALL_BUFFER ||
43
+ position.y < WALL_BUFFER ||
44
+ position.y > ARENA_SIZE - WALL_BUFFER
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Smart combat movement with threat-aware dodging.
50
+ */
51
+ function combatMove(
52
+ position: {x: number; y: number},
53
+ enemy: {position: {x: number; y: number}},
54
+ currentDistance: number,
55
+ targetDistance: number,
56
+ dodgeDirection: {x: number; y: number} | null,
57
+ tick: number,
58
+ strafeCyclePeriod: number,
59
+ strafeIntensity: number,
60
+ approachSpeed: number,
61
+ distanceDeadZone: number,
62
+ ): {x: number; y: number}
63
+ {
64
+ const deltaX = enemy.position.x - position.x;
65
+ const deltaY = enemy.position.y - position.y;
66
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
67
+
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
+ if (dodgeDirection)
80
+ {
81
+ strafeDirection = dodgeDirection;
82
+ }
83
+ else if (isNearWall(position))
84
+ {
85
+ const futureClockwise = {
86
+ x: position.x + strafeClockwise.x * 100,
87
+ y: position.y + strafeClockwise.y * 100,
88
+ };
89
+ const futureCounterClockwise = {
90
+ x: position.x + strafeCounterClockwise.x * 100,
91
+ y: position.y + strafeCounterClockwise.y * 100,
92
+ };
93
+ const scoreClockwise = Math.min(
94
+ futureClockwise.x,
95
+ ARENA_SIZE - futureClockwise.x,
96
+ futureClockwise.y,
97
+ ARENA_SIZE - futureClockwise.y,
98
+ );
99
+ const scoreCounterClockwise = Math.min(
100
+ futureCounterClockwise.x,
101
+ ARENA_SIZE - futureCounterClockwise.x,
102
+ futureCounterClockwise.y,
103
+ ARENA_SIZE - futureCounterClockwise.y,
104
+ );
105
+ strafeDirection =
106
+ scoreClockwise > scoreCounterClockwise
107
+ ? strafeClockwise
108
+ : strafeCounterClockwise;
109
+ }
110
+ else
111
+ {
112
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
113
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
114
+ }
115
+
116
+ if (dodgeDirection)
117
+ {
118
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
119
+ }
120
+
121
+ let moveX = strafeDirection.x * strafeIntensity;
122
+ let moveY = strafeDirection.y * strafeIntensity;
123
+
124
+ if (currentDistance > targetDistance + distanceDeadZone)
125
+ {
126
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
127
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
128
+ }
129
+ else if (currentDistance < targetDistance - distanceDeadZone)
130
+ {
131
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
132
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
133
+ if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
134
+ if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
135
+ if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
136
+ if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
137
+ moveX += retreatX;
138
+ moveY += retreatY;
139
+ }
140
+
141
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
142
+ if (magnitude > 100)
143
+ {
144
+ moveX = (moveX / magnitude) * 100;
145
+ moveY = (moveY / magnitude) * 100;
146
+ }
147
+
148
+ return {x: moveX, y: moveY};
149
+ }
150
+
151
+ /**
152
+ * Bot: Archmage
153
+ *
154
+ * BEHAVIOR: Versatile duelist that adapts missile choice based on distance and HP.
155
+ * Close range → straight missiles (no turn cost = more damage). Mid/far range → homing.
156
+ * Uses dual-blink aggressively (gap-close during vulnerability, escape when trade is bad).
157
+ * HP-aware: ahead → aggressive close range; behind → defensive ranged kiting.
158
+ *
159
+ * KEY IMPROVEMENTS OVER WARMAGE:
160
+ * - Range-adaptive missiles: straight close, homing far
161
+ * - Vulnerability-timed blinks: gap-close during enemy cast/GCD
162
+ * - HP-aware aggression: adjusts distance + risk tolerance based on HP differential
163
+ * - Progressive cast-cancel: graduated thresholds
164
+ *
165
+ * PROGRESSION LINE: Battlemage → Warmage → Archmage
166
+ * TIER: 3 (elite Duelist line)
167
+ */
168
+ export const Archmage: WizardFunction = ({state}) =>
169
+ {
170
+ const targetDistance = useParam('targetDistance', 214, {
171
+ range: 150,
172
+ min: 0,
173
+ });
174
+ const minTurnRate = useParam('minTurnRate', -14.6, {range: 1.5, steps: 5});
175
+ const blinkEscapeDistance = useParam('blinkEscapeDistance', 442, {
176
+ range: 190,
177
+ min: 0,
178
+ steps: 7,
179
+ });
180
+ const punishMinVulnerability = useParam('punishMinVulnerability', 85, {
181
+ range: 30,
182
+ min: 10,
183
+ steps: 5,
184
+ });
185
+ const aggressiveHPThreshold = useParam('aggressiveHPThreshold', 4, {
186
+ range: 20,
187
+ min: 0,
188
+ steps: 5,
189
+ });
190
+ const preferBlinkDodge =
191
+ useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
192
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 248, {
193
+ range: 75,
194
+ min: 80,
195
+ max: 250,
196
+ });
197
+ const strafeIntensity = useParam('strafeIntensity', 100, {
198
+ range: 40,
199
+ min: 20,
200
+ max: 100,
201
+ steps: 7,
202
+ });
203
+ const approachSpeed = useParam('approachSpeed', 57, {
204
+ range: 30,
205
+ min: 10,
206
+ max: 80,
207
+ steps: 7,
208
+ });
209
+ const distanceDeadZone = useParam('distanceDeadZone', 37, {
210
+ range: 30,
211
+ min: 10,
212
+ max: 80,
213
+ steps: 7,
214
+ });
215
+ const aggressiveDistanceOffset = useParam('aggressiveDistanceOffset', 99, {
216
+ range: 50,
217
+ min: 0,
218
+ max: 100,
219
+ steps: 7,
220
+ });
221
+ const cautiousDistanceOffset = useParam('cautiousDistanceOffset', 42, {
222
+ range: 50,
223
+ min: 0,
224
+ max: 120,
225
+ steps: 7,
226
+ });
227
+ const cancelHeavyDmg = useParam('cancelHeavyDmg', 40, {
228
+ range: 15,
229
+ min: 5,
230
+ max: 40,
231
+ steps: 5,
232
+ });
233
+ const cancelMediumDmg = useParam('cancelMediumDmg', 20, {
234
+ range: 10,
235
+ min: 3,
236
+ max: 25,
237
+ steps: 5,
238
+ });
239
+ const cancelCastRatio = useParam('cancelCastRatio', 0.74, {
240
+ range: 0.4,
241
+ min: 0.3,
242
+ max: 1.0,
243
+ steps: 5,
244
+ });
245
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
246
+ range: 75,
247
+ min: 50,
248
+ max: 300,
249
+ });
250
+ const blinkWindowMax = useParam('blinkWindowMax', 29, {
251
+ range: 40,
252
+ min: 20,
253
+ max: 120,
254
+ steps: 7,
255
+ });
256
+ const blinkGapThreshold = useParam('blinkGapThreshold', 80, {
257
+ range: 75,
258
+ min: 20,
259
+ max: 200,
260
+ steps: 7,
261
+ });
262
+ const flightTimeDivisor = useParam('flightTimeDivisor', 4.5, {
263
+ range: 4,
264
+ min: 2,
265
+ max: 12,
266
+ steps: 5,
267
+ });
268
+ const closeRangeStraightThreshold = useParam(
269
+ 'closeRangeStraightThreshold',
270
+ 50,
271
+ {range: 100, min: 50, max: 300, steps: 7},
272
+ );
273
+ const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
274
+ range: 25,
275
+ min: 10,
276
+ max: 80,
277
+ steps: 5,
278
+ });
279
+
280
+ const enemy = state.enemies[0];
281
+ if (!enemy)
282
+ {
283
+ return {move: {x: 0, y: 0}};
284
+ }
285
+
286
+ const distance = distanceTo(state.position, enemy.position);
287
+ const threats = getThreats(state);
288
+ const urgentThreat = getMostUrgentThreat(threats);
289
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
290
+
291
+ // HP-aware distance: ahead → fight closer, behind → kite at range
292
+ const hpDiff = state.health - enemy.health;
293
+ const isAggressive = hpDiff >= -aggressiveHPThreshold;
294
+ const effectiveTargetDistance = isAggressive
295
+ ? targetDistance - aggressiveDistanceOffset
296
+ : targetDistance + cautiousDistanceOffset;
297
+
298
+ const move = combatMove(
299
+ state.position,
300
+ enemy,
301
+ distance,
302
+ effectiveTargetDistance,
303
+ dodgeDirection,
304
+ state.tick,
305
+ strafeCyclePeriod,
306
+ strafeIntensity,
307
+ approachSpeed,
308
+ distanceDeadZone,
309
+ );
310
+
311
+ // === STEP 1: CHANNELING ===
312
+ if (state.state === 'channeling')
313
+ {
314
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
315
+ {
316
+ return {move, cancel: true};
317
+ }
318
+ return {move: {x: 0, y: 0}};
319
+ }
320
+
321
+ // === STEP 2: PROGRESSIVE CAST-CANCEL ===
322
+ if (
323
+ state.state === 'casting' &&
324
+ state.castingSpell === 'missile' &&
325
+ urgentThreat &&
326
+ !urgentThreat.bestDodgeDirection
327
+ )
328
+ {
329
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
330
+ const defenseTime =
331
+ state.blinkCooldown === 0
332
+ ? BLINK_CAST_TIME
333
+ : SHIELD_CAST_TIME + SHIELD_BUFFER;
334
+ const willArriveInWindow =
335
+ urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
336
+
337
+ if (willArriveInWindow)
338
+ {
339
+ const incomingDamage = urgentThreat.projectile.damage;
340
+ const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
341
+
342
+ if (incomingDamage >= state.health || incomingDamage >= cancelHeavyDmg)
343
+ {
344
+ return {move, cancel: true};
345
+ }
346
+ if (incomingDamage >= cancelMediumDmg && castRatio < cancelCastRatio)
347
+ {
348
+ return {move, cancel: true};
349
+ }
350
+ }
351
+ }
352
+
353
+ // === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
354
+ if (
355
+ preferBlinkDodge &&
356
+ state.state === 'idle' &&
357
+ urgentThreat &&
358
+ state.blinkCooldown === 0 &&
359
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
360
+ urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
361
+ urgentThreat.ticksToImpact <= blinkWindowMax
362
+ )
363
+ {
364
+ // Aggressive: blink toward enemy to dodge + close gap
365
+ // Defensive: blink away to dodge + increase distance
366
+ if (isAggressive && distance > effectiveTargetDistance)
367
+ {
368
+ const blinkTarget = getBlinkToDecreaseDistance(
369
+ state.position,
370
+ enemy.position,
371
+ state.projectiles,
372
+ );
373
+ if (blinkTarget)
374
+ {
375
+ return {
376
+ move: {x: 0, y: 0},
377
+ startCast: {spell: 'blink', target: blinkTarget},
378
+ };
379
+ }
380
+ }
381
+ const blinkTarget = getBlinkToIncreaseDistance(
382
+ state.position,
383
+ enemy.position,
384
+ state.projectiles,
385
+ MIN_BLINK_ESCAPE_DISTANCE,
386
+ );
387
+ if (blinkTarget)
388
+ {
389
+ return {
390
+ move: {x: 0, y: 0},
391
+ startCast: {spell: 'blink', target: blinkTarget},
392
+ };
393
+ }
394
+ }
395
+
396
+ // === STEP 4: BLINK ESCAPE — enemy too close ===
397
+ if (
398
+ state.state === 'idle' &&
399
+ distance < blinkEscapeDistance &&
400
+ state.blinkCooldown === 0 &&
401
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
402
+ )
403
+ {
404
+ const escapeTarget = getBlinkToIncreaseDistance(
405
+ state.position,
406
+ enemy.position,
407
+ state.projectiles,
408
+ MIN_BLINK_ESCAPE_DISTANCE,
409
+ );
410
+ if (escapeTarget)
411
+ {
412
+ return {
413
+ move: {x: 0, y: 0},
414
+ startCast: {spell: 'blink', target: escapeTarget},
415
+ };
416
+ }
417
+ }
418
+
419
+ // === STEP 5: SHIELD ===
420
+ if (
421
+ state.state === 'idle' &&
422
+ urgentThreat &&
423
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
424
+ )
425
+ {
426
+ if (
427
+ urgentThreat.canBlockInTime &&
428
+ urgentThreat.ticksToStartShield <= SHIELD_BUFFER &&
429
+ (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
430
+ )
431
+ {
432
+ return {
433
+ move: {x: 0, y: 0},
434
+ startCast: {spell: 'shield'},
435
+ };
436
+ }
437
+ }
438
+
439
+ // === STEP 6: VULNERABILITY BLINK — gap-close during enemy cast/GCD ===
440
+ if (
441
+ state.state === 'idle' &&
442
+ state.blinkCooldown === 0 &&
443
+ isAggressive &&
444
+ distance > effectiveTargetDistance + blinkGapThreshold &&
445
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
446
+ )
447
+ {
448
+ const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
449
+ if (vulnerabilityTicks >= punishMinVulnerability)
450
+ {
451
+ const blinkTarget = getBlinkToDecreaseDistance(
452
+ state.position,
453
+ enemy.position,
454
+ state.projectiles,
455
+ );
456
+ if (blinkTarget)
457
+ {
458
+ return {
459
+ move: {x: 0, y: 0},
460
+ startCast: {spell: 'blink', target: blinkTarget},
461
+ };
462
+ }
463
+ }
464
+ }
465
+
466
+ // === STEP 7: OFFENSE ===
467
+ if (state.state === 'idle' && distance < 500)
468
+ {
469
+ const offenseDefenseTime =
470
+ state.blinkCooldown === 0
471
+ ? BLINK_CAST_TIME
472
+ : SHIELD_CAST_TIME + SHIELD_BUFFER;
473
+ const enemyHP = Math.ceil(enemy.health);
474
+ const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
475
+
476
+ // --- PUNISH MODE: range-adaptive missiles during vulnerability ---
477
+ if (vulnerabilityTicks >= punishMinVulnerability)
478
+ {
479
+ const flightTimeEstimate = distance / flightTimeDivisor;
480
+ const punishBudget = vulnerabilityTicks - flightTimeEstimate;
481
+ const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
482
+ const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
483
+ const effectiveBudget = Math.min(
484
+ punishBudget,
485
+ safeBudget,
486
+ MAX_CAST_BUDGET,
487
+ );
488
+
489
+ if (effectiveBudget > 0)
490
+ {
491
+ // Close range → straight for max damage, far → light homing
492
+ const punishTurnRate = distance < closeRangeStraightThreshold ? 0 : 0.2;
493
+ const config = fitMissileToBudget(effectiveBudget, distance, {
494
+ minTurnRate: punishTurnRate,
495
+ maxDamage: enemyHP,
496
+ lastMissileConfig: state.lastMissileConfig,
497
+ });
498
+
499
+ if (config && config.damage >= MIN_USEFUL_DAMAGE)
500
+ {
501
+ const castTime = getMissileCastTime(config, state.lastMissileConfig);
502
+ const actualFlight = distance / config.speed;
503
+
504
+ if (
505
+ castTime + actualFlight < vulnerabilityTicks &&
506
+ isSafeToAttack(threats, castTime)
507
+ )
508
+ {
509
+ const aimTarget =
510
+ config.turnRate > 0
511
+ ? enemy.position
512
+ : getLeadPosition(
513
+ enemy.position,
514
+ enemy.velocity,
515
+ config.speed,
516
+ state.position,
517
+ );
518
+ return {
519
+ move,
520
+ startCast: {
521
+ spell: 'missile',
522
+ config,
523
+ missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
524
+ direction: angleTo(state.position, aimTarget),
525
+ },
526
+ };
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ // --- STANDARD MODE: range-adaptive missiles ---
533
+ const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
534
+ const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
535
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
536
+
537
+ // Adapt turn rate to distance: close → straight, far → homing
538
+ const effectiveMinTurnRate =
539
+ distance < closeRangeStraightThreshold ? 0 : minTurnRate;
540
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
541
+ let config = fitMissileToBudget(budgetTicks, distance, {
542
+ minTurnRate: effectiveMinTurnRate,
543
+ maxDamage: enemyHP,
544
+ lastMissileConfig: state.lastMissileConfig,
545
+ });
546
+
547
+ if (
548
+ config &&
549
+ config.damage >= minDmg &&
550
+ isSafeToAttack(threats, safeAttackCastEstimate)
551
+ )
552
+ {
553
+ return {
554
+ move,
555
+ startCast: {
556
+ spell: 'missile',
557
+ config,
558
+ missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
559
+ direction: angleTo(state.position, enemy.position),
560
+ },
561
+ };
562
+ }
563
+
564
+ // Fallback: fire bigger missile, accept incoming hit
565
+ if (!config || config.damage < minDmg)
566
+ {
567
+ config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
568
+ minTurnRate: effectiveMinTurnRate,
569
+ maxDamage: enemyHP,
570
+ lastMissileConfig: state.lastMissileConfig,
571
+ });
572
+ if (config)
573
+ {
574
+ return {
575
+ move,
576
+ startCast: {
577
+ spell: 'missile',
578
+ config,
579
+ missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
580
+ direction: angleTo(state.position, enemy.position),
581
+ },
582
+ };
583
+ }
584
+ }
585
+ }
586
+
587
+ return {move};
588
+ };