@vibemancer/core 0.1.4 → 0.1.6
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.
- package/README.md +2 -2
- package/dist/chunk-G5T65F3W.js +10617 -0
- package/dist/chunk-G5T65F3W.js.map +1 -0
- package/dist/index-browser.d.ts +1328 -1051
- package/dist/index-browser.js +59 -17
- package/dist/index.d.ts +53 -3
- package/dist/index.js +204 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -15
- package/src/banned-bot-names.ts +53 -0
- package/src/bots/Hero.ts +867 -0
- package/src/bots/berserker/01_Stormchaser.ts +162 -120
- package/src/bots/berserker/02_Stormcaller.ts +160 -116
- package/src/bots/berserker/03_Stormforger.ts +162 -129
- package/src/bots/caster/01_Flamecaller.ts +126 -84
- package/src/bots/caster/02_Pyromancer.ts +148 -101
- package/src/bots/caster/03_Infernalist.ts +180 -160
- package/src/bots/defensive/01_Turtle.ts +48 -44
- package/src/bots/defensive/02_Sentinel.ts +57 -53
- package/src/bots/defensive/03_Golem.ts +85 -97
- package/src/bots/duelist/01_Battlemage.ts +157 -122
- package/src/bots/duelist/02_Warmage.ts +166 -121
- package/src/bots/duelist/03_Archmage.ts +198 -178
- package/src/bots/homing/01_Bonemancer.ts +20 -32
- package/src/bots/homing/02_Lich.ts +145 -93
- package/src/bots/homing/03_Archlich.ts +129 -60
- package/src/bots/kiter/01_Spellspinner.ts +145 -107
- package/src/bots/kiter/02_Spellweaver.ts +153 -105
- package/src/bots/kiter/03_Spellbinder.ts +168 -123
- package/src/bots/melee/01_Shadowblade.ts +131 -75
- package/src/bots/melee/02_Nightblade.ts +174 -130
- package/src/bots/melee/03_Voidblade.ts +266 -168
- package/src/bots/registry.ts +44 -37
- package/src/bots/shared.ts +185 -25
- package/src/bots/sniper/01_Spellshot.ts +151 -98
- package/src/bots/sniper/02_Spelltracer.ts +173 -128
- package/src/bots/sniper/03_Spellseeker.ts +177 -152
- package/src/bots/standalone/Critter.ts +46 -52
- package/src/bots/standalone/Doombringer.ts +36 -40
- package/src/bots/standalone/Hogger.ts +120 -89
- package/src/bots/standalone/Rookie.ts +21 -23
- package/src/bots/standalone/TargetDummy.ts +5 -6
- package/src/bots/test/cheater.ts +91 -85
- package/src/bots/test/crasher.ts +26 -28
- package/src/engine/bundle-fight.ts +242 -0
- package/src/engine/hooks-runtime.ts +58 -18
- package/src/engine/manual-match.ts +33 -25
- package/src/engine/missile-templates.ts +25 -43
- package/src/engine/optimizer.ts +7 -7
- package/src/engine/params-runtime.ts +3 -3
- package/src/engine/physics.ts +33 -23
- package/src/engine/sandbox-browser.ts +8 -7
- package/src/engine/sandbox-compile.ts +1 -1
- package/src/engine/sandbox-harness.ts +299 -367
- package/src/engine/sandbox.ts +3 -3
- package/src/engine/simulation.ts +291 -76
- package/src/engine/spells.ts +9 -12
- package/src/engine-version.ts +1 -1
- package/src/hooks/action-builders.ts +76 -21
- package/src/hooks/index.ts +17 -9
- package/src/hooks/state-hooks.ts +61 -25
- package/src/hooks/threat-analysis.ts +44 -20
- package/src/hooks/types.ts +62 -5
- package/src/index-browser.ts +1 -0
- package/src/index.ts +3 -0
- package/src/rules.ts +209 -63
- package/src/stats.ts +2 -2
- package/src/testing.ts +6 -30
- package/src/trace.ts +63 -3
- package/src/types.ts +127 -14
- package/src/utils/angles.ts +1 -0
- package/src/utils/combat.ts +98 -6
- package/src/utils/spatial.ts +3 -3
- package/dist/chunk-74FFJCFF.js +0 -9140
- package/dist/chunk-74FFJCFF.js.map +0 -1
- package/src/hooks/bot-wrapper.ts +0 -84
package/src/bots/Hero.ts
ADDED
|
@@ -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
|
+
}
|