@vibemancer/core 0.1.4 → 0.1.5

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 (74) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-W7HWJFQ4.js +10599 -0
  3. package/dist/chunk-W7HWJFQ4.js.map +1 -0
  4. package/dist/index-browser.d.ts +1300 -1051
  5. package/dist/index-browser.js +55 -17
  6. package/dist/index.d.ts +53 -3
  7. package/dist/index.js +200 -54
  8. package/dist/index.js.map +1 -1
  9. package/package.json +21 -15
  10. package/src/bots/Hero.ts +867 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +162 -120
  12. package/src/bots/berserker/02_Stormcaller.ts +160 -116
  13. package/src/bots/berserker/03_Stormforger.ts +162 -129
  14. package/src/bots/caster/01_Flamecaller.ts +126 -84
  15. package/src/bots/caster/02_Pyromancer.ts +148 -101
  16. package/src/bots/caster/03_Infernalist.ts +180 -160
  17. package/src/bots/defensive/01_Turtle.ts +48 -44
  18. package/src/bots/defensive/02_Sentinel.ts +57 -53
  19. package/src/bots/defensive/03_Golem.ts +85 -97
  20. package/src/bots/duelist/01_Battlemage.ts +157 -122
  21. package/src/bots/duelist/02_Warmage.ts +166 -121
  22. package/src/bots/duelist/03_Archmage.ts +198 -178
  23. package/src/bots/homing/01_Bonemancer.ts +20 -32
  24. package/src/bots/homing/02_Lich.ts +145 -93
  25. package/src/bots/homing/03_Archlich.ts +129 -60
  26. package/src/bots/kiter/01_Spellspinner.ts +145 -107
  27. package/src/bots/kiter/02_Spellweaver.ts +153 -105
  28. package/src/bots/kiter/03_Spellbinder.ts +168 -123
  29. package/src/bots/melee/01_Shadowblade.ts +131 -75
  30. package/src/bots/melee/02_Nightblade.ts +174 -130
  31. package/src/bots/melee/03_Voidblade.ts +266 -168
  32. package/src/bots/registry.ts +44 -37
  33. package/src/bots/shared.ts +185 -25
  34. package/src/bots/sniper/01_Spellshot.ts +151 -98
  35. package/src/bots/sniper/02_Spelltracer.ts +173 -128
  36. package/src/bots/sniper/03_Spellseeker.ts +177 -152
  37. package/src/bots/standalone/Critter.ts +46 -52
  38. package/src/bots/standalone/Doombringer.ts +36 -40
  39. package/src/bots/standalone/Hogger.ts +120 -89
  40. package/src/bots/standalone/Rookie.ts +21 -23
  41. package/src/bots/standalone/TargetDummy.ts +5 -6
  42. package/src/bots/test/cheater.ts +91 -85
  43. package/src/bots/test/crasher.ts +26 -28
  44. package/src/engine/bundle-fight.ts +242 -0
  45. package/src/engine/hooks-runtime.ts +58 -18
  46. package/src/engine/manual-match.ts +33 -25
  47. package/src/engine/missile-templates.ts +25 -43
  48. package/src/engine/optimizer.ts +7 -7
  49. package/src/engine/params-runtime.ts +3 -3
  50. package/src/engine/physics.ts +33 -23
  51. package/src/engine/sandbox-browser.ts +8 -7
  52. package/src/engine/sandbox-compile.ts +1 -1
  53. package/src/engine/sandbox-harness.ts +299 -367
  54. package/src/engine/sandbox.ts +3 -3
  55. package/src/engine/simulation.ts +291 -76
  56. package/src/engine/spells.ts +9 -12
  57. package/src/engine-version.ts +1 -1
  58. package/src/hooks/action-builders.ts +76 -21
  59. package/src/hooks/index.ts +17 -9
  60. package/src/hooks/state-hooks.ts +61 -25
  61. package/src/hooks/threat-analysis.ts +44 -20
  62. package/src/hooks/types.ts +62 -5
  63. package/src/index.ts +2 -0
  64. package/src/rules.ts +209 -63
  65. package/src/stats.ts +2 -2
  66. package/src/testing.ts +6 -30
  67. package/src/trace.ts +63 -3
  68. package/src/types.ts +127 -14
  69. package/src/utils/angles.ts +1 -0
  70. package/src/utils/combat.ts +98 -6
  71. package/src/utils/spatial.ts +3 -3
  72. package/dist/chunk-74FFJCFF.js +0 -9140
  73. package/dist/chunk-74FFJCFF.js.map +0 -1
  74. package/src/hooks/bot-wrapper.ts +0 -84
@@ -0,0 +1,867 @@
1
+ import type {FinalAction, MissileFunction} from '../hooks/index.js';
2
+ import {usePosition, useEnemy, useStatus, useBlinkCooldown, useHealth, useTick, useThreats, useProjectiles, useMyProjectiles, useLastMissileConfig, useCastingSpell, useCastProgress, useRef, idle, move as moveAction, missile, shield, blink, cancel, getMissileContext, turnToward, flyStraight} from '../hooks/index.js';
3
+ import {angleTo} from '../utils/angles.js';
4
+ import {distanceTo} from '../utils/distance.js';
5
+ import {RULES, ARENA_MIN, ARENA_MAX} from '../rules.js';
6
+ import {getMissileCastTime, getLeadPosition} from '../utils/combat.js';
7
+ import {
8
+ getMostUrgentThreat,
9
+ getDodgeDirectionForMovement,
10
+ fitMissileToBudget,
11
+ getBlinkToDecreaseDistance,
12
+ getBlinkToIncreaseDistance,
13
+ shouldForceShield,
14
+ MIN_BLINK_ESCAPE_DISTANCE,
15
+ MAX_CAST_BUDGET,
16
+ MIN_USEFUL_DAMAGE,
17
+ getEnemyVulnerabilityTicks,
18
+ isSafeToAttack,
19
+ keepOffWalls,
20
+ getPreCastReposition,
21
+ } from './shared.js';
22
+ import {useParam} from '../engine/params-runtime.js';
23
+ import {Spellshot} from './sniper/01_Spellshot.js';
24
+
25
+ const WALL_BUFFER = 60;
26
+ const SHIELD_BUFFER = 3;
27
+ // A single hit at/above this damage would (near-)one-shot Hero from full HP, so it is
28
+ // never worth strafe-gambling — the dodge sim can misjudge a slow monster homer (e.g.
29
+ // Doombringer's 60-dmg turn-0.5 missile) and one failed strafe = death. Treat such
30
+ // lethal hits as undodgeable (cancel/blink/shield) like a hard-homer. No competitive
31
+ // bot fires this big (all ≤30), so this only ever fires vs the max-damage benchmark.
32
+ const LETHAL_HIT_DAMAGE = 50;
33
+ // The sniper champion (Spellshot) uniquely fires spd-12 missiles (every other bot is
34
+ // <= 11.3), so seeing one identifies it. Two exploits of its FIXED strategy, both gated on
35
+ // this signature (zero competitive impact): (1) it force-shields incoming HOMING missiles
36
+ // >= 25 dmg but eats smaller ones FULL, so cap our missiles at SNIPER_DAMAGE_CAP to slip
37
+ // under the shield; (2) its snipes are dodgeable (turn-0.3) — we only eat them while
38
+ // GCD-locked, so only cast in its post-fire GCD window, leaving us idle to dodge the rest.
39
+ const SNIPER_MISSILE_SPEED = 11.5;
40
+ const SNIPER_DAMAGE_CAP = 24;
41
+
42
+ const HomingAI: MissileFunction = () =>
43
+ {
44
+ const ctx = getMissileContext();
45
+ const enemy = ctx.worldState.enemies[0];
46
+ return enemy ? turnToward(enemy.position.x, enemy.position.y) : flyStraight();
47
+ };
48
+
49
+ const StraightAI: MissileFunction = () => flyStraight();
50
+
51
+ function isNearWall(position: {x: number; y: number}): boolean
52
+ {
53
+ return (
54
+ position.x < ARENA_MIN + WALL_BUFFER ||
55
+ position.x > ARENA_MAX - WALL_BUFFER ||
56
+ position.y < ARENA_MIN + WALL_BUFFER ||
57
+ position.y > ARENA_MAX - WALL_BUFFER
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Smart combat movement with threat-aware dodging.
63
+ */
64
+ function combatMove(
65
+ position: {x: number; y: number},
66
+ enemy: {position: {x: number; y: number}},
67
+ currentDistance: number,
68
+ targetDistance: number,
69
+ dodgeDirection: {x: number; y: number} | null,
70
+ tick: number,
71
+ strafeCyclePeriod: number,
72
+ strafeIntensity: number,
73
+ approachSpeed: number,
74
+ distanceDeadZone: number,
75
+ centerBias: number,
76
+ ): {x: number; y: number}
77
+ {
78
+ const deltaX = enemy.position.x - position.x;
79
+ const deltaY = enemy.position.y - position.y;
80
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
81
+
82
+ // Approach the center-side of the enemy so knockback pushes toward center, not lava.
83
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
84
+ const toCenterX = center - enemy.position.x;
85
+ const toCenterY = center - enemy.position.y;
86
+ const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
87
+ const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
88
+ const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
89
+ const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
90
+
91
+ const strafeClockwise = {
92
+ x: -deltaY / normalizedDistance,
93
+ y: deltaX / normalizedDistance,
94
+ };
95
+ const strafeCounterClockwise = {
96
+ x: deltaY / normalizedDistance,
97
+ y: -deltaX / normalizedDistance,
98
+ };
99
+
100
+ let strafeDirection: {x: number; y: number};
101
+
102
+ if (dodgeDirection)
103
+ {
104
+ strafeDirection = dodgeDirection;
105
+ }
106
+ else if (isNearWall(position))
107
+ {
108
+ const futureClockwise = {
109
+ x: position.x + strafeClockwise.x * 100,
110
+ y: position.y + strafeClockwise.y * 100,
111
+ };
112
+ const futureCounterClockwise = {
113
+ x: position.x + strafeCounterClockwise.x * 100,
114
+ y: position.y + strafeCounterClockwise.y * 100,
115
+ };
116
+ const scoreClockwise = Math.min(
117
+ futureClockwise.x - ARENA_MIN,
118
+ ARENA_MAX - futureClockwise.x,
119
+ futureClockwise.y - ARENA_MIN,
120
+ ARENA_MAX - futureClockwise.y,
121
+ );
122
+ const scoreCounterClockwise = Math.min(
123
+ futureCounterClockwise.x - ARENA_MIN,
124
+ ARENA_MAX - futureCounterClockwise.x,
125
+ futureCounterClockwise.y - ARENA_MIN,
126
+ ARENA_MAX - futureCounterClockwise.y,
127
+ );
128
+ strafeDirection =
129
+ scoreClockwise > scoreCounterClockwise
130
+ ? strafeClockwise
131
+ : strafeCounterClockwise;
132
+ }
133
+ else
134
+ {
135
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
136
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
137
+ }
138
+
139
+ if (dodgeDirection)
140
+ {
141
+ return {x: strafeDirection.x, y: strafeDirection.y};
142
+ }
143
+
144
+ let moveX = strafeDirection.x * strafeIntensity;
145
+ let moveY = strafeDirection.y * strafeIntensity;
146
+
147
+ if (currentDistance > targetDistance + distanceDeadZone)
148
+ {
149
+ moveX += (approachDirX / approachMag) * approachSpeed;
150
+ moveY += (approachDirY / approachMag) * approachSpeed;
151
+ }
152
+ else if (currentDistance < targetDistance - distanceDeadZone)
153
+ {
154
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
155
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
156
+ if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
157
+ if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0) retreatX = 0;
158
+ if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
159
+ if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0) retreatY = 0;
160
+ moveX += retreatX;
161
+ moveY += retreatY;
162
+ }
163
+
164
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
165
+ if (magnitude > 1)
166
+ {
167
+ moveX = moveX / magnitude;
168
+ moveY = moveY / magnitude;
169
+ }
170
+
171
+ return {x: moveX, y: moveY};
172
+ }
173
+
174
+ /**
175
+ * Bot: Hero
176
+ *
177
+ * BEHAVIOR: Adaptive duelist that classifies the opponent's range/missile style from
178
+ * live visible signals (how close they hold / whether they're closing, and their active
179
+ * missiles) and counters by adjusting engagement distance — KITE aggressors (close /
180
+ * closing / fast-straight stabbers) and CLOSE on kiters/casters (far / homing missiles).
181
+ * This opponent-classification layer sits on top of Archmage's HP-aware, range-adaptive
182
+ * engine: close range → straight missiles (no turn cost = more damage), mid/far → homing,
183
+ * aggressive dual-blink (gap-close during vulnerability, escape when a trade is bad), and
184
+ * HP-aware aggression (ahead → fight closer, behind → kite at range).
185
+ *
186
+ * KEY IMPROVEMENTS OVER ARCHMAGE:
187
+ * - Opponent classification: reads range/missile style and counters via distance offset
188
+ * - Counters aggressors by kiting, counters kiters/casters by closing in
189
+ *
190
+ * INHERITED FROM ARCHMAGE:
191
+ * - Range-adaptive missiles: straight close, homing far
192
+ * - Vulnerability-timed blinks: gap-close during enemy cast/GCD
193
+ * - HP-aware aggression: adjusts distance + risk tolerance based on HP differential
194
+ * - Progressive cast-cancel: graduated thresholds
195
+ */
196
+ export function Hero(): FinalAction
197
+ {
198
+ const position = usePosition();
199
+ const enemy = useEnemy();
200
+ const status = useStatus();
201
+ const blinkCooldown = useBlinkCooldown();
202
+ const health = useHealth();
203
+ const tick = useTick();
204
+ const threats = useThreats();
205
+ const projectiles = useProjectiles();
206
+ const myProjectiles = useMyProjectiles();
207
+ const lastMissileConfig = useLastMissileConfig();
208
+ const castingSpell = useCastingSpell();
209
+ const castProgressData = useCastProgress();
210
+
211
+
212
+ const targetDistance = useParam('targetDistance', 310, {
213
+ range: 150,
214
+ min: 0,
215
+ });
216
+ const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, min: 0, steps: 5});
217
+ const blinkEscapeDistance = useParam('blinkEscapeDistance', 233, {
218
+ range: 190,
219
+ min: 0,
220
+ steps: 7,
221
+ });
222
+ const punishMinVulnerability = useParam('punishMinVulnerability', 35, {
223
+ range: 30,
224
+ min: 10,
225
+ steps: 5,
226
+ });
227
+ const aggressiveHPThreshold = useParam('aggressiveHPThreshold', 8, {
228
+ range: 20,
229
+ min: 0,
230
+ steps: 5,
231
+ });
232
+ const preferBlinkDodge =
233
+ useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
234
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 249, {
235
+ range: 75,
236
+ min: 80,
237
+ max: 250,
238
+ });
239
+ const strafeIntensity = useParam('strafeIntensity', 0.97, {
240
+ range: 0.4,
241
+ min: 0.2,
242
+ max: 1,
243
+ steps: 7,
244
+ });
245
+ const approachSpeed = useParam('approachSpeed', 0.7, {
246
+ range: 0.3,
247
+ min: 0.1,
248
+ max: 0.8,
249
+ steps: 7,
250
+ });
251
+ const distanceDeadZone = useParam('distanceDeadZone', 30, {
252
+ range: 30,
253
+ min: 10,
254
+ max: 80,
255
+ steps: 7,
256
+ });
257
+ const aggressiveDistanceOffset = useParam('aggressiveDistanceOffset', 11, {
258
+ range: 50,
259
+ min: 0,
260
+ max: 100,
261
+ steps: 7,
262
+ });
263
+ const cautiousDistanceOffset = useParam('cautiousDistanceOffset', 31, {
264
+ range: 50,
265
+ min: 0,
266
+ max: 120,
267
+ steps: 7,
268
+ });
269
+ const cancelHeavyDmg = useParam('cancelHeavyDmg', 30, {
270
+ range: 15,
271
+ min: 5,
272
+ max: 40,
273
+ steps: 5,
274
+ });
275
+ const cancelMediumDmg = useParam('cancelMediumDmg', 21, {
276
+ range: 10,
277
+ min: 3,
278
+ max: 25,
279
+ steps: 5,
280
+ });
281
+ const cancelCastRatio = useParam('cancelCastRatio', 0.6, {
282
+ range: 0.4,
283
+ min: 0.3,
284
+ max: 1.0,
285
+ steps: 5,
286
+ });
287
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
288
+ range: 75,
289
+ min: 50,
290
+ max: 300,
291
+ });
292
+ const blinkWindowMax = useParam('blinkWindowMax', 69, {
293
+ range: 40,
294
+ min: 20,
295
+ max: 120,
296
+ steps: 7,
297
+ });
298
+ const blinkGapThreshold = useParam('blinkGapThreshold', 111, {
299
+ range: 75,
300
+ min: 20,
301
+ max: 200,
302
+ steps: 7,
303
+ });
304
+ const flightTimeDivisor = useParam('flightTimeDivisor', 8.9, {
305
+ range: 4,
306
+ min: 2,
307
+ max: 12,
308
+ steps: 5,
309
+ });
310
+ const closeRangeStraightThreshold = useParam(
311
+ 'closeRangeStraightThreshold',
312
+ 50,
313
+ {range: 100, min: 50, max: 300, steps: 7},
314
+ );
315
+ const centerBias = useParam('centerBias', 0.48, {
316
+ range: 0.4,
317
+ min: 0,
318
+ max: 0.8,
319
+ steps: 5,
320
+ });
321
+ const maxBlinkDistance = useParam('maxBlinkDistance', 200, {
322
+ range: 100,
323
+ min: 50,
324
+ max: 300,
325
+ steps: 7,
326
+ });
327
+ // A missile whose turn rate is at/above this re-tracks faster than Hero can
328
+ // strafe clear, so the dodge sim wrongly reports it dodgeable. Treat such
329
+ // hard-homing missiles as undodgeable and defend (blink/shield) instead of
330
+ // strafing — cheap homing spam (e.g. dmg-12 turn-1) is the main attrition leak.
331
+ const hardHomingTurn = useParam('hardHomingTurn', 0.8, {
332
+ range: 0.7,
333
+ min: 0.3,
334
+ max: 2,
335
+ steps: 7,
336
+ });
337
+ // DEFENSIVE blinks (escaping a melee closer / breaking a hard-homing lock) get a
338
+ // larger reach than offensive gap-close blinks: blinking further clears fast
339
+ // point-blank stabbers and breaks homing locks, while offensive blinks stay short
340
+ // so they don't overshoot the enemy. (Blinking everyone further trades — it disrupts
341
+ // caster matchups like Pyromancer — so only the get-away blinks reach further.)
342
+ const escapeBlinkCap = useParam('escapeBlinkCap', 280, {
343
+ range: 100,
344
+ min: 150,
345
+ max: 320,
346
+ steps: 7,
347
+ });
348
+ // Enemy HP at/below which Hero goes for the KILL even into incoming fire — the
349
+ // match ends before their damage matters. Only fires when Hero will survive the
350
+ // in-flight hits, so it grabs close races (enemy left at 1 HP) without trading.
351
+ const finisherEnemyHP = useParam('finisherEnemyHP', 8, {
352
+ range: 8,
353
+ min: 1,
354
+ max: 20,
355
+ steps: 7,
356
+ });
357
+ // Sticky opponent model (persists per match, resets between). Once Hero has seen a
358
+ // lethal (≥LETHAL_HIT_DAMAGE) missile, it knows it faces the max-damage benchmark and
359
+ // holds arena-center for the rest of the match: at center every wall is ~400u away, so
360
+ // the ~330u knockback from that monster missile (shields don't block knockback) can
361
+ // never reach lava. Added at the END of the hook block to keep useParam indices stable.
362
+ const oppModel = useRef<{sawLethal: boolean; sawSniper: boolean; losingLatched: boolean; sawMidHomer: boolean; mirrorMatchTicks: number}>({sawLethal: false, sawSniper: false, losingLatched: false, sawMidHomer: false, mirrorMatchTicks: 0});
363
+
364
+ function capBlinkDistance(target: {x: number; y: number}, cap: number = maxBlinkDistance): {x: number; y: number}
365
+ {
366
+ const dx = target.x - position.x;
367
+ const dy = target.y - position.y;
368
+ const dist = Math.sqrt(dx * dx + dy * dy);
369
+ if (dist <= cap) return target;
370
+ const scale = cap / dist;
371
+ return {x: position.x + dx * scale, y: position.y + dy * scale};
372
+ }
373
+
374
+ // Blink perpendicular to an incoming missile's heading. Moving sideways to the
375
+ // missile's velocity breaks a homing lock harder than blinking toward/away from
376
+ // the enemy (which a tracker re-acquires), and lands 0 damage vs a shield's leak.
377
+ // Picks whichever of the two perpendicular sides stays further off walls/lava.
378
+ function blinkPerpendicularTo(proj: {position: {x: number; y: number}; rotation: number}): {x: number; y: number}
379
+ {
380
+ const rad = proj.rotation * (Math.PI / 180);
381
+ const hx = Math.cos(rad);
382
+ const hy = Math.sin(rad);
383
+ const wallScore = (t: {x: number; y: number}): number =>
384
+ Math.min(t.x - ARENA_MIN, ARENA_MAX - t.x, t.y - ARENA_MIN, ARENA_MAX - t.y);
385
+ const sideA = capBlinkDistance({x: position.x - hy * MIN_BLINK_ESCAPE_DISTANCE, y: position.y + hx * MIN_BLINK_ESCAPE_DISTANCE}, escapeBlinkCap);
386
+ const sideB = capBlinkDistance({x: position.x + hy * MIN_BLINK_ESCAPE_DISTANCE, y: position.y - hx * MIN_BLINK_ESCAPE_DISTANCE}, escapeBlinkCap);
387
+ return wallScore(sideA) >= wallScore(sideB) ? sideA : sideB;
388
+ }
389
+
390
+ const distance = distanceTo(position, enemy.position);
391
+ const urgentThreat = getMostUrgentThreat(threats);
392
+ const dodgeDirection = getDodgeDirectionForMovement(threats, position);
393
+ // Hard-homing incoming missile → can't be out-strafed; must blink or shield it.
394
+ // A lethal big hit (≥LETHAL_HIT_DAMAGE) is treated the same: never strafe-gamble a
395
+ // missile that would one-shot Hero, even if the dodge sim thinks it's dodgeable.
396
+ const hardHomingThreat =
397
+ urgentThreat !== null &&
398
+ urgentThreat.willHit &&
399
+ (urgentThreat.projectile.turnRate >= hardHomingTurn ||
400
+ urgentThreat.projectile.damage >= LETHAL_HIT_DAMAGE);
401
+
402
+ // HP-aware distance: ahead → fight closer, behind → kite at range
403
+ const hpDiff = health - enemy.health;
404
+ const isAggressive = hpDiff >= -aggressiveHPThreshold;
405
+ // --- OPPONENT CLASSIFICATION → counter by adjusting engagement distance ---
406
+ // Read the opponent's range/missile style from visible signals (no persistent
407
+ // state): how close they hold / whether they're closing, and their live missiles.
408
+ const toHeroX = position.x - enemy.position.x;
409
+ const toHeroY = position.y - enemy.position.y;
410
+ const toHeroMag = Math.hypot(toHeroX, toHeroY) || 1;
411
+ const enemyClosingRate = (enemy.velocity.x * toHeroX + enemy.velocity.y * toHeroY) / toHeroMag; // >0 = approaching
412
+ // There is exactly one enemy (1v1 duel) and EnemyState carries no id, so the
413
+ // opponent's live missiles are every in-flight projectile that isn't ours.
414
+ const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
415
+ const enemyMissiles = projectiles.filter((p) => !myProjectileIds.has(p.id));
416
+ const enemyHoming = enemyMissiles.some((p) => p.turnRate >= 0.5);
417
+ const enemyFastStraight = enemyMissiles.some((p) => p.turnRate < 0.3 && p.speed >= 6);
418
+ // A monster missile (only the max-damage benchmark fires this big) one-shots Hero and
419
+ // its knockback (~330u, ignores shields) flings Hero into lava — so blink it (0 dmg +
420
+ // 0 knockback) when blink is up, else hold toward center and shield it from safety.
421
+ const lethalMissile = enemyMissiles.find((p) => p.damage >= LETHAL_HIT_DAMAGE) ?? null;
422
+ if (lethalMissile) oppModel.current.sawLethal = true;
423
+ const lethalShooter = oppModel.current.sawLethal;
424
+ // Identify the relentless-homer champions by their unique missile fingerprints (sticky):
425
+ // the SNIPER by its spd-12 shot; the advancing BERSERKER by its dmg≥14 + long-life
426
+ // (≥120 ticks) + hard-homing (turn≥0.8) shot (dmg distinguishes it from the dmg-12
427
+ // long-life casters Flamecaller/Bonemancer that Hero already beats). Both relentlessly
428
+ // connect during Hero's GCD, so both get the same anti-homer toolkit.
429
+ if (enemyMissiles.some((p) =>
430
+ p.speed >= SNIPER_MISSILE_SPEED ||
431
+ (p.damage >= 14 && p.remainingTicks >= 120 && p.turnRate >= 0.8))) oppModel.current.sawSniper = true;
432
+ const sawSniper = oppModel.current.sawSniper;
433
+ // REACTIVE DELEGATION — the adaptable answer to Hero's RPS hard-counters. If Hero is
434
+ // CLEARLY losing (failing to dent the enemy well into the match), its duelist strategy is
435
+ // hard-countered (Archlich/Archmage — Hero can't out-duel them). Switch to Spellshot,
436
+ // which beats BOTH 100%. The trigger is SELF-CORRECTING: it only fires when Hero is
437
+ // genuinely losing, which never happens vs the look-alikes Hero beats (Golem/Lich — Hero
438
+ // dents them early), so it can't regress them. Spellshot() is called unconditionally each
439
+ // tick (stable hook order) and only RETURNED once latched (sticky for the rest of match).
440
+ const spellshotAction = Spellshot();
441
+ const om2 = oppModel.current;
442
+ // Sticky: enemy is a turn-0.5 big homer (the Archlich/Golem/Lich/Archmage class) — NOT a
443
+ // melee/turn-1 bot (Spellshot loses to those, so we must never delegate vs them).
444
+ if (enemyMissiles.some((p) => p.damage >= 25 && p.turnRate >= 0.45 && p.turnRate <= 0.6)) om2.sawMidHomer = true;
445
+ // Latch to Spellshot ONLY when BOTH hold: (1) the enemy is that turn-0.5 big-homer class,
446
+ // and (2) Hero literally can't damage it — still near full HP deep into the match (dealt
447
+ // <~4). Among the class, only Archlich keeps its HP this high (Hero dents Golem/Lich/the
448
+ // Archmage-mirror well below); so this fires ONLY vs Archlich. Spellshot beats it 100%.
449
+ if (!om2.losingLatched && om2.sawMidHomer && tick > 700 && enemy.health > 56)
450
+ {
451
+ om2.losingLatched = true;
452
+ }
453
+ if (om2.losingLatched) return spellshotAction;
454
+
455
+ // MIRROR detection (sticky): the enemy keeps firing missiles with the EXACT config Hero
456
+ // just fired (same dmg/spd/turn). Uniquely the near-mirror — identical logic + identical
457
+ // inputs produce the same missile every exchange. Count ticks-with-a-match; ≥80 means it
458
+ // matched across more than one missile's flight (~60t each), ruling out a one-off
459
+ // coincidence (Golem/Lich fire turn-0.5 but don't track Hero's evolving config + timing).
460
+ if (lastMissileConfig && enemyMissiles.some((p) =>
461
+ Math.abs(p.damage - lastMissileConfig.damage) < 1.5 &&
462
+ Math.abs(p.turnRate - lastMissileConfig.turnRate) < 0.06 &&
463
+ Math.abs(p.speed - lastMissileConfig.speed) < 0.6)) om2.mirrorMatchTicks++;
464
+ const sawMirror = om2.mirrorMatchTicks >= 80;
465
+
466
+ // Counter by MISSILE TYPE first (it distinguishes ranged from melee even at close
467
+ // range, where distance alone can't): a fast-straight stabber is a melee threat —
468
+ // hold base distance so we're not in point-blank range; a homing caster/kiter — close
469
+ // in HARD to land missiles and pressure, even once we're already close. Distance is
470
+ // only a fallback when no missiles are visible yet.
471
+ let opponentOffset = 0;
472
+ if (sawMirror)
473
+ {
474
+ // MIRROR: hold ~Archmage's range instead of closing. Closing (the -190 branch below)
475
+ // is what diverges Hero's movement and costs it the even trade; holding keeps the
476
+ // geometry symmetric → draw rather than a snowball loss.
477
+ opponentOffset = 31;
478
+ }
479
+ else if (enemyFastStraight || enemyClosingRate > 0.3)
480
+ {
481
+ opponentOffset = 31; // melee stabber / charging aggressor → hold the OLD (farther) distance, avoid point-blank
482
+ }
483
+ else if (enemyHoming || distance > 430)
484
+ {
485
+ opponentOffset = -190; // caster / kiter → close in HARD (land missiles + pressure)
486
+ }
487
+
488
+ const effectiveTargetDistance = Math.max(120, Math.min(560, (isAggressive
489
+ ? targetDistance - aggressiveDistanceOffset
490
+ : targetDistance + cautiousDistanceOffset) + opponentOffset));
491
+
492
+ const move = combatMove(
493
+ position,
494
+ enemy,
495
+ distance,
496
+ effectiveTargetDistance,
497
+ dodgeDirection,
498
+ tick,
499
+ strafeCyclePeriod,
500
+ strafeIntensity,
501
+ approachSpeed,
502
+ distanceDeadZone,
503
+ centerBias,
504
+ );
505
+ let safeMove = keepOffWalls(position, move);
506
+ // vs the max-damage benchmark: drift to arena center (while still casting/strafing) so
507
+ // Hero is already central when the monster missile lands — its ~330u shielded-hit
508
+ // knockback then can't reach lava. Only engages once a ≥50-dmg missile has been seen.
509
+ if (lethalShooter || sawSniper)
510
+ {
511
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
512
+ const cdx = center - position.x;
513
+ const cdy = center - position.y;
514
+ const cdist = Math.hypot(cdx, cdy);
515
+ if (cdist > 70) safeMove = {x: cdx / cdist, y: cdy / cdist};
516
+ }
517
+ // === STEP 1: CHANNELING ===
518
+ if (status === 'channeling')
519
+ {
520
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
521
+ {
522
+ return cancel().move(safeMove.x, safeMove.y);
523
+ }
524
+ return idle();
525
+ }
526
+
527
+ // === STEP 2: PROGRESSIVE CAST-CANCEL ===
528
+ if (
529
+ status === 'casting' &&
530
+ castingSpell === 'missile' &&
531
+ urgentThreat &&
532
+ (!urgentThreat.bestDodgeDirection || hardHomingThreat)
533
+ )
534
+ {
535
+ const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
536
+ const defenseTime =
537
+ blinkCooldown === 0
538
+ ? RULES.BLINK_CAST_TIME
539
+ : RULES.SHIELD_CAST_TIME + SHIELD_BUFFER;
540
+ const willArriveInWindow =
541
+ urgentThreat.ticksToImpact <= remainingCast + RULES.GCD_DURATION + defenseTime;
542
+
543
+ if (willArriveInWindow)
544
+ {
545
+ const incomingDamage = urgentThreat.projectile.damage;
546
+ const castRatio = ((castProgressData?.current) ?? 0) / ((castProgressData?.total) ?? 1);
547
+
548
+ if (incomingDamage >= health || incomingDamage >= cancelHeavyDmg)
549
+ {
550
+ return cancel().move(safeMove.x, safeMove.y);
551
+ }
552
+ if (incomingDamage >= cancelMediumDmg && castRatio < cancelCastRatio)
553
+ {
554
+ return cancel().move(safeMove.x, safeMove.y);
555
+ }
556
+ // Cheap hard-homer: can't strafe it and it's below the damage thresholds,
557
+ // so abandon an early cast to free up a blink/shield rather than eat it.
558
+ if (hardHomingThreat && castRatio < cancelCastRatio)
559
+ {
560
+ return cancel().move(safeMove.x, safeMove.y);
561
+ }
562
+ }
563
+ }
564
+
565
+ // === LETHAL MISSILE — perpendicular-blink the instant blink is up (0 dmg AND 0
566
+ // knockback). Shielding a 60-dmg monster blocks the damage but NOT the ~330u
567
+ // knockback, which flings Hero into lava. A wider window than the normal blink-dodge:
568
+ // this hit WILL land and is the only thing that matters. (Only Doombringer fires ≥50.)
569
+ if (status === 'idle' && lethalMissile && blinkCooldown === 0)
570
+ {
571
+ const perp = blinkPerpendicularTo(lethalMissile);
572
+ return blink(perp.x, perp.y);
573
+ }
574
+
575
+ // === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
576
+ if (
577
+ preferBlinkDodge &&
578
+ status === 'idle' &&
579
+ urgentThreat &&
580
+ blinkCooldown === 0 &&
581
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat) || hardHomingThreat) &&
582
+ urgentThreat.ticksToImpact >= RULES.BLINK_CAST_TIME &&
583
+ urgentThreat.ticksToImpact <= blinkWindowMax
584
+ )
585
+ {
586
+ // Hard-homing missile: blink perpendicular to its heading to break the lock
587
+ // (toward/away gets re-acquired). 0 damage vs a shield's ~10% leak.
588
+ if (hardHomingThreat)
589
+ {
590
+ const perp = blinkPerpendicularTo(urgentThreat.projectile);
591
+ return blink(perp.x, perp.y);
592
+ }
593
+ // Aggressive: blink toward enemy to dodge + close gap
594
+ // Defensive: blink away to dodge + increase distance
595
+ if (isAggressive && distance > effectiveTargetDistance)
596
+ {
597
+ const blinkTarget = getBlinkToDecreaseDistance(
598
+ position,
599
+ enemy.position,
600
+ projectiles,
601
+ );
602
+ if (blinkTarget)
603
+ {
604
+ const capped = capBlinkDistance(blinkTarget);
605
+ return blink(capped.x, capped.y);
606
+ }
607
+ }
608
+ const blinkTarget = getBlinkToIncreaseDistance(
609
+ position,
610
+ enemy.position,
611
+ projectiles,
612
+ MIN_BLINK_ESCAPE_DISTANCE,
613
+ );
614
+ if (blinkTarget)
615
+ {
616
+ const capped = capBlinkDistance(blinkTarget, escapeBlinkCap);
617
+ return blink(capped.x, capped.y);
618
+ }
619
+ }
620
+
621
+ // === STEP 4: BLINK ESCAPE — enemy too close ===
622
+ if (
623
+ status === 'idle' &&
624
+ distance < blinkEscapeDistance &&
625
+ blinkCooldown === 0 &&
626
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
627
+ )
628
+ {
629
+ const escapeTarget = getBlinkToIncreaseDistance(
630
+ position,
631
+ enemy.position,
632
+ projectiles,
633
+ MIN_BLINK_ESCAPE_DISTANCE,
634
+ );
635
+ if (escapeTarget)
636
+ {
637
+ const capped = capBlinkDistance(escapeTarget, escapeBlinkCap);
638
+ return blink(capped.x, capped.y);
639
+ }
640
+ }
641
+
642
+ // === STEP 5: SHIELD ===
643
+ if (
644
+ status === 'idle' &&
645
+ urgentThreat &&
646
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat) || hardHomingThreat)
647
+ )
648
+ {
649
+ if (
650
+ urgentThreat.canBlockInTime &&
651
+ urgentThreat.ticksToStartShield <= SHIELD_BUFFER &&
652
+ (blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
653
+ )
654
+ {
655
+ return shield();
656
+ }
657
+ }
658
+
659
+ // === Preemptive shield — shield when enemy is casting a missile at close range ===
660
+ if (
661
+ status === 'idle' &&
662
+ distance < 300 &&
663
+ enemy.status === 'casting' &&
664
+ enemy.castingSpell === 'missile'
665
+ )
666
+ {
667
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
668
+ const estimatedFlightTicks = distance / 8;
669
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
670
+
671
+ if (
672
+ ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
673
+ ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
674
+ )
675
+ {
676
+ return shield();
677
+ }
678
+ }
679
+
680
+ // === STEP 6: VULNERABILITY BLINK — gap-close during enemy cast/GCD ===
681
+ if (
682
+ status === 'idle' &&
683
+ blinkCooldown === 0 &&
684
+ isAggressive &&
685
+ distance > effectiveTargetDistance + blinkGapThreshold &&
686
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
687
+ )
688
+ {
689
+ const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
690
+ if (vulnerabilityTicks >= punishMinVulnerability)
691
+ {
692
+ const blinkTarget = getBlinkToDecreaseDistance(
693
+ position,
694
+ enemy.position,
695
+ projectiles,
696
+ );
697
+ if (blinkTarget)
698
+ {
699
+ const capped = capBlinkDistance(blinkTarget);
700
+ return blink(capped.x, capped.y);
701
+ }
702
+ }
703
+ }
704
+
705
+ // === STEP 7: OFFENSE ===
706
+ if (status === 'idle' && distance < 500)
707
+ {
708
+ // A lethal missile is inbound and blink is down — do NOT start a cast (we'd cancel
709
+ // it and lose the shield-timing race). Move toward arena center so the un-shieldable
710
+ // ~330u knockback can't fling Hero into lava; STEP 5 shields it from safety.
711
+ if (lethalMissile)
712
+ {
713
+ const center = (ARENA_MIN + ARENA_MAX) / 2;
714
+ const cdx = center - position.x;
715
+ const cdy = center - position.y;
716
+ const cmag = Math.hypot(cdx, cdy) || 1;
717
+ return moveAction(cdx / cmag, cdy / cmag);
718
+ }
719
+
720
+ const reposition = getPreCastReposition(position, enemy.position);
721
+ if (reposition) return moveAction(reposition.x, reposition.y);
722
+
723
+ const offenseDefenseTime =
724
+ blinkCooldown === 0
725
+ ? RULES.BLINK_CAST_TIME
726
+ : RULES.SHIELD_CAST_TIME + SHIELD_BUFFER;
727
+ const enemyHP = Math.ceil(enemy.health);
728
+ // vs the identified sniper: cap missiles just under its >=25 force-shield threshold
729
+ // so they land FULL (small homers out-race its snipes) instead of being shielded to ~3.
730
+ const offenseMaxDamage = sawSniper ? Math.min(enemyHP, SNIPER_DAMAGE_CAP) : enemyHP;
731
+ const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
732
+
733
+ // --- FINISHER: enemy is one shot from death. Take the kill even into incoming
734
+ // fire (the match ends before their damage matters) — but ONLY if Hero survives
735
+ // every in-flight hit, so it can't suicide. Bypasses the attack-safety guard that
736
+ // otherwise forfeits won-by-a-hair races (e.g. enemy left at 1 HP while Hero defends).
737
+ if (enemyHP <= finisherEnemyHP)
738
+ {
739
+ const killConfig = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
740
+ minTurnRate: distance < closeRangeStraightThreshold ? 0 : minTurnRate,
741
+ maxDamage: Math.max(enemyHP, MIN_USEFUL_DAMAGE),
742
+ lastMissileConfig: lastMissileConfig,
743
+ });
744
+ const incomingDmg = threats.filter((t) => t.willHit).reduce((s, t) => s + t.projectile.damage, 0);
745
+ if (killConfig && killConfig.damage >= enemyHP && health > incomingDmg)
746
+ {
747
+ const aimTarget =
748
+ killConfig.turnRate > 0
749
+ ? enemy.position
750
+ : getLeadPosition(enemy.position, enemy.velocity, killConfig.speed, position);
751
+ return missile(killConfig, killConfig.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, aimTarget)).move(safeMove.x, safeMove.y);
752
+ }
753
+ }
754
+
755
+ // vs the sniper: only START a cast in its post-fire GCD window (enemy.status
756
+ // 'gcd_locked'). Then our own GCD ends BEFORE its next snipe (~one full cast away),
757
+ // leaving us idle to DODGE that snipe — the snipes are turn-0.3 dodgeable; we only
758
+ // eat them while GCD-locked. Otherwise hold and dodge. (Finisher above bypasses this.)
759
+ if (sawSniper && enemy.status !== 'gcd_locked')
760
+ {
761
+ return moveAction(safeMove.x, safeMove.y);
762
+ }
763
+
764
+ // --- PUNISH MODE: range-adaptive missiles during vulnerability ---
765
+ // Skipped vs the sniper: its window-clamped budget makes short missiles that fall
766
+ // short of / can't chase the blinking target. STANDARD below fires a full-budget homer.
767
+ if (!sawSniper && vulnerabilityTicks >= punishMinVulnerability)
768
+ {
769
+ const flightTimeEstimate = distance / flightTimeDivisor;
770
+ const punishBudget = vulnerabilityTicks - flightTimeEstimate;
771
+ const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
772
+ const safeBudget = nextThreatTime - RULES.GCD_DURATION - offenseDefenseTime;
773
+ const effectiveBudget = Math.min(
774
+ punishBudget,
775
+ safeBudget,
776
+ MAX_CAST_BUDGET,
777
+ );
778
+
779
+ if (effectiveBudget > 0)
780
+ {
781
+ // Close range → straight for max damage, far → light homing; vs the sniper
782
+ // force real homing so the missile chases its strafing/blinking dodge.
783
+ const punishTurnRate = sawSniper ? 0.5 : (distance < closeRangeStraightThreshold ? 0 : 0.2);
784
+ const config = fitMissileToBudget(effectiveBudget, distance, {
785
+ minTurnRate: punishTurnRate,
786
+ maxDamage: offenseMaxDamage,
787
+ lastMissileConfig: lastMissileConfig,
788
+ });
789
+
790
+ if (config && config.damage >= MIN_USEFUL_DAMAGE)
791
+ {
792
+ const castTime = getMissileCastTime(config, lastMissileConfig);
793
+ const actualFlight = distance / config.speed;
794
+ // During the enemy's GCD they can't shield, and after it ends they still
795
+ // need SHIELD_CAST_TIME to channel one — so a missile landing within
796
+ // gcd + SHIELD_CAST_TIME is genuinely unblockable (matchup-independent).
797
+ const punishWindow =
798
+ enemy.status === 'gcd_locked'
799
+ ? vulnerabilityTicks + RULES.SHIELD_CAST_TIME
800
+ : vulnerabilityTicks;
801
+
802
+ if (
803
+ castTime + actualFlight < punishWindow &&
804
+ isSafeToAttack(threats, castTime)
805
+ )
806
+ {
807
+ const aimTarget =
808
+ config.turnRate > 0
809
+ ? enemy.position
810
+ : getLeadPosition(
811
+ enemy.position,
812
+ enemy.velocity,
813
+ config.speed,
814
+ position,
815
+ );
816
+ return missile(config, config.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, aimTarget)).move(safeMove.x, safeMove.y);
817
+ }
818
+ }
819
+ }
820
+ }
821
+
822
+ // --- STANDARD MODE: range-adaptive missiles ---
823
+ const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
824
+ const safeBudget = nextThreatTime - RULES.GCD_DURATION - offenseDefenseTime;
825
+ // vs the sniper: full-budget (long-lived, connects on the strafing target) homing.
826
+ const budgetTicks = sawSniper ? MAX_CAST_BUDGET : Math.min(safeBudget, MAX_CAST_BUDGET);
827
+
828
+ // Adapt turn rate to distance: close → straight, far → homing; vs the sniper force homing.
829
+ const effectiveMinTurnRate = sawSniper
830
+ ? Math.max(minTurnRate, 0.5)
831
+ : (distance < closeRangeStraightThreshold ? 0 : minTurnRate);
832
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
833
+ // vs the sniper: provision extra range — it strafes/blinks (~300u) away during our
834
+ // cast + the missile's flight, so the homer needs the LIFE to chase, not fall short.
835
+ const fitDistance = sawSniper ? distance + 300 : distance;
836
+ let config = fitMissileToBudget(budgetTicks, fitDistance, {
837
+ minTurnRate: effectiveMinTurnRate,
838
+ maxDamage: offenseMaxDamage,
839
+ lastMissileConfig: lastMissileConfig,
840
+ });
841
+
842
+ if (config && config.damage >= minDmg)
843
+ {
844
+ const actualCastTime = getMissileCastTime(config, lastMissileConfig);
845
+ if (isSafeToAttack(threats, actualCastTime))
846
+ {
847
+ return missile(config, config.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
848
+ }
849
+ }
850
+
851
+ // Fallback: fire bigger missile, accept incoming hit — but only if no undodgeable threat
852
+ if (!config || config.damage < minDmg)
853
+ {
854
+ config = fitMissileToBudget(MAX_CAST_BUDGET, fitDistance, {
855
+ minTurnRate: effectiveMinTurnRate,
856
+ maxDamage: offenseMaxDamage,
857
+ lastMissileConfig: lastMissileConfig,
858
+ });
859
+ if (config && (!urgentThreat || urgentThreat.bestDodgeDirection !== null))
860
+ {
861
+ return missile(config, config.turnRate > 0 ? HomingAI : StraightAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
862
+ }
863
+ }
864
+ }
865
+
866
+ return moveAction(safeMove.x, safeMove.y);
867
+ }