@vibemancer/core 0.1.0
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 +28 -0
- package/dist/chunk-L7Z7OFXD.js +9140 -0
- package/dist/chunk-L7Z7OFXD.js.map +1 -0
- package/dist/index-browser.d.ts +2602 -0
- package/dist/index-browser.js +407 -0
- package/dist/index-browser.js.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +750 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
- package/src/bots/berserker/01_Stormchaser.ts +457 -0
- package/src/bots/berserker/02_Stormcaller.ts +417 -0
- package/src/bots/berserker/03_Stormforger.ts +481 -0
- package/src/bots/caster/01_Flamecaller.ts +286 -0
- package/src/bots/caster/02_Pyromancer.ts +350 -0
- package/src/bots/caster/03_Infernalist.ts +492 -0
- package/src/bots/defensive/01_Turtle.ts +151 -0
- package/src/bots/defensive/02_Sentinel.ts +134 -0
- package/src/bots/defensive/03_Golem.ts +357 -0
- package/src/bots/duelist/01_Battlemage.ts +433 -0
- package/src/bots/duelist/02_Warmage.ts +438 -0
- package/src/bots/duelist/03_Archmage.ts +588 -0
- package/src/bots/homing/01_Bonemancer.ts +67 -0
- package/src/bots/homing/02_Lich.ts +356 -0
- package/src/bots/homing/03_Archlich.ts +220 -0
- package/src/bots/index.ts +30 -0
- package/src/bots/kiter/01_Spellspinner.ts +398 -0
- package/src/bots/kiter/02_Spellweaver.ts +378 -0
- package/src/bots/kiter/03_Spellbinder.ts +448 -0
- package/src/bots/melee/01_Shadowblade.ts +270 -0
- package/src/bots/melee/02_Nightblade.ts +437 -0
- package/src/bots/melee/03_Voidblade.ts +582 -0
- package/src/bots/registry.ts +207 -0
- package/src/bots/shared.ts +472 -0
- package/src/bots/sniper/01_Spellshot.ts +385 -0
- package/src/bots/sniper/02_Spelltracer.ts +441 -0
- package/src/bots/sniper/03_Spellseeker.ts +546 -0
- package/src/bots/standalone/Critter.ts +89 -0
- package/src/bots/standalone/Doombringer.ts +91 -0
- package/src/bots/standalone/Hogger.ts +228 -0
- package/src/bots/standalone/Rookie.ts +50 -0
- package/src/bots/standalone/TargetDummy.ts +21 -0
- package/src/bots/test/cheater.ts +405 -0
- package/src/bots/test/crasher.ts +81 -0
- package/src/engine/hooks-runtime.ts +394 -0
- package/src/engine/manual-match.ts +289 -0
- package/src/engine/missile-templates.ts +155 -0
- package/src/engine/optimizer.ts +220 -0
- package/src/engine/params-runtime.ts +189 -0
- package/src/engine/physics.ts +143 -0
- package/src/engine/sandbox-browser.ts +671 -0
- package/src/engine/sandbox-compile.ts +197 -0
- package/src/engine/sandbox-harness.ts +367 -0
- package/src/engine/sandbox.ts +332 -0
- package/src/engine/simulation.ts +828 -0
- package/src/engine/spells.ts +128 -0
- package/src/engine-version.ts +11 -0
- package/src/hooks/action-builders.ts +210 -0
- package/src/hooks/bot-wrapper.ts +84 -0
- package/src/hooks/index.ts +75 -0
- package/src/hooks/state-hooks.ts +354 -0
- package/src/hooks/threat-analysis.ts +365 -0
- package/src/hooks/types.ts +142 -0
- package/src/index-browser.ts +30 -0
- package/src/index.ts +24 -0
- package/src/rules.ts +254 -0
- package/src/stats.ts +262 -0
- package/src/testing.ts +207 -0
- package/src/trace.ts +430 -0
- package/src/types.ts +193 -0
- package/src/utils/angles.ts +47 -0
- package/src/utils/combat.ts +279 -0
- package/src/utils/distance.ts +21 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/movement.ts +108 -0
- package/src/utils/random.ts +65 -0
- package/src/utils/spatial.ts +63 -0
- package/src/utils/targeting.ts +45 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
+
import {angleTo} from '../../utils/angles.js';
|
|
3
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
+
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
5
|
+
import {
|
|
6
|
+
getThreats,
|
|
7
|
+
getMostUrgentThreat,
|
|
8
|
+
getDodgeDirectionForMovement,
|
|
9
|
+
getBlinkToCenter,
|
|
10
|
+
getBlinkToIncreaseDistance,
|
|
11
|
+
fitMissileToBudget,
|
|
12
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
13
|
+
shouldForceShield,
|
|
14
|
+
MAX_CAST_BUDGET,
|
|
15
|
+
getEnemyVulnerabilityTicks,
|
|
16
|
+
} from '../shared.js';
|
|
17
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
18
|
+
|
|
19
|
+
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
20
|
+
{
|
|
21
|
+
const enemy = worldState.enemies[0];
|
|
22
|
+
return {turnToward: enemy?.position};
|
|
23
|
+
};
|
|
24
|
+
const WALL_BUFFER = 50;
|
|
25
|
+
|
|
26
|
+
const SHIELD_BUFFER = 3;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Smart combat movement: dodge using threat analysis, avoid walls, commit to direction.
|
|
30
|
+
*/
|
|
31
|
+
function combatMove(
|
|
32
|
+
position: {x: number; y: number},
|
|
33
|
+
enemy: {position: {x: number; y: number}},
|
|
34
|
+
currentDistance: number,
|
|
35
|
+
minDistance: number,
|
|
36
|
+
maxDistance: number,
|
|
37
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
38
|
+
tick: number,
|
|
39
|
+
strafeCyclePeriod: number,
|
|
40
|
+
strafeIntensity: number,
|
|
41
|
+
approachSpeed: number,
|
|
42
|
+
): {x: number; y: number}
|
|
43
|
+
{
|
|
44
|
+
const deltaX = enemy.position.x - position.x;
|
|
45
|
+
const deltaY = enemy.position.y - position.y;
|
|
46
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
47
|
+
|
|
48
|
+
const strafeClockwise = {
|
|
49
|
+
x: -deltaY / normalizedDistance,
|
|
50
|
+
y: deltaX / normalizedDistance,
|
|
51
|
+
};
|
|
52
|
+
const strafeCounterClockwise = {
|
|
53
|
+
x: deltaY / normalizedDistance,
|
|
54
|
+
y: -deltaX / normalizedDistance,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let strafeDirection: {x: number; y: number};
|
|
58
|
+
|
|
59
|
+
if (dodgeDirection)
|
|
60
|
+
{
|
|
61
|
+
strafeDirection = dodgeDirection;
|
|
62
|
+
}
|
|
63
|
+
else if (
|
|
64
|
+
position.x < WALL_BUFFER ||
|
|
65
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
66
|
+
position.y < WALL_BUFFER ||
|
|
67
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
68
|
+
)
|
|
69
|
+
{
|
|
70
|
+
const futureClockwise = {
|
|
71
|
+
x: position.x + strafeClockwise.x * 100,
|
|
72
|
+
y: position.y + strafeClockwise.y * 100,
|
|
73
|
+
};
|
|
74
|
+
const futureCounterClockwise = {
|
|
75
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
76
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
77
|
+
};
|
|
78
|
+
const scoreClockwise = Math.min(
|
|
79
|
+
futureClockwise.x,
|
|
80
|
+
ARENA_SIZE - futureClockwise.x,
|
|
81
|
+
futureClockwise.y,
|
|
82
|
+
ARENA_SIZE - futureClockwise.y,
|
|
83
|
+
);
|
|
84
|
+
const scoreCounterClockwise = Math.min(
|
|
85
|
+
futureCounterClockwise.x,
|
|
86
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
87
|
+
futureCounterClockwise.y,
|
|
88
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
89
|
+
);
|
|
90
|
+
strafeDirection =
|
|
91
|
+
scoreClockwise > scoreCounterClockwise
|
|
92
|
+
? strafeClockwise
|
|
93
|
+
: strafeCounterClockwise;
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
{
|
|
97
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
98
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// When actively dodging, commit fully
|
|
102
|
+
if (dodgeDirection)
|
|
103
|
+
{
|
|
104
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
108
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
109
|
+
|
|
110
|
+
// Distance management
|
|
111
|
+
if (currentDistance > maxDistance)
|
|
112
|
+
{
|
|
113
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
114
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
115
|
+
}
|
|
116
|
+
else if (currentDistance < minDistance)
|
|
117
|
+
{
|
|
118
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
119
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
120
|
+
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
121
|
+
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
122
|
+
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
123
|
+
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
124
|
+
moveX += retreatX;
|
|
125
|
+
moveY += retreatY;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
129
|
+
if (magnitude > 100)
|
|
130
|
+
{
|
|
131
|
+
moveX = (moveX / magnitude) * 100;
|
|
132
|
+
moveY = (moveY / magnitude) * 100;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {x: moveX, y: moveY};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bot: Spelltracer
|
|
140
|
+
*
|
|
141
|
+
* BEHAVIOR: Enhanced ranged sniper with adaptive missile fitting and intercept
|
|
142
|
+
* prediction. Uses fitMissileToBudget to find the highest-damage fast missile
|
|
143
|
+
* that fits the safe window, then fires it along the predicted intercept angle.
|
|
144
|
+
* Maintains medium-long range (300-450), shields undodgeable threats with proper
|
|
145
|
+
* timing, emergency blinks, and distance blinks when cornered. The key mechanic
|
|
146
|
+
* is still PREDICTION — but now with adaptive damage optimization.
|
|
147
|
+
*
|
|
148
|
+
* PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
|
|
149
|
+
* - Spellshot (tier 1): Fixed config intercept prediction, non-homing missiles
|
|
150
|
+
* - Spelltracer (tier 2): + adaptive fitting, timed defense, distance management
|
|
151
|
+
* - Spellseeker (tier 3): Future — perfect prediction, multi-angle attacks
|
|
152
|
+
*
|
|
153
|
+
* TIER: 2 (enhanced Spellshot)
|
|
154
|
+
*/
|
|
155
|
+
export const Spelltracer: WizardFunction = ({state}) =>
|
|
156
|
+
{
|
|
157
|
+
// Tunable parameters (run optimizer script to find optimal values)
|
|
158
|
+
const dangerDistance = useParam('dangerDistance', 199, {
|
|
159
|
+
range: 175,
|
|
160
|
+
min: 0,
|
|
161
|
+
});
|
|
162
|
+
const targetDistance = useParam('targetDistance', 555, {range: 200, min: 0});
|
|
163
|
+
const distanceDeadZone = useParam('distanceDeadZone', 12, {
|
|
164
|
+
range: 50,
|
|
165
|
+
min: 0,
|
|
166
|
+
});
|
|
167
|
+
const rangeMin = targetDistance - distanceDeadZone;
|
|
168
|
+
const rangeMax = targetDistance + distanceDeadZone;
|
|
169
|
+
const minTurnRate = useParam('minTurnRate', 0.9, {range: 1.5, steps: 5});
|
|
170
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 140, {
|
|
171
|
+
range: 75,
|
|
172
|
+
min: 80,
|
|
173
|
+
max: 300,
|
|
174
|
+
});
|
|
175
|
+
const strafeIntensity = useParam('strafeIntensity', 48, {
|
|
176
|
+
range: 40,
|
|
177
|
+
min: 20,
|
|
178
|
+
max: 100,
|
|
179
|
+
steps: 7,
|
|
180
|
+
});
|
|
181
|
+
const approachSpeed = useParam('approachSpeed', 80, {
|
|
182
|
+
range: 30,
|
|
183
|
+
min: 10,
|
|
184
|
+
max: 80,
|
|
185
|
+
steps: 7,
|
|
186
|
+
});
|
|
187
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
188
|
+
range: 75,
|
|
189
|
+
min: 50,
|
|
190
|
+
max: 300,
|
|
191
|
+
});
|
|
192
|
+
const blinkWindowMax = useParam('blinkWindowMax', 23, {
|
|
193
|
+
range: 40,
|
|
194
|
+
min: 20,
|
|
195
|
+
max: 120,
|
|
196
|
+
steps: 7,
|
|
197
|
+
});
|
|
198
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
|
|
199
|
+
range: 4,
|
|
200
|
+
min: 3,
|
|
201
|
+
max: 12,
|
|
202
|
+
steps: 5,
|
|
203
|
+
});
|
|
204
|
+
const enemyResponseEstimate = useParam('enemyResponseEstimate', 217, {
|
|
205
|
+
range: 100,
|
|
206
|
+
min: 50,
|
|
207
|
+
max: 300,
|
|
208
|
+
steps: 7,
|
|
209
|
+
});
|
|
210
|
+
const threatFallbackThreshold = useParam('threatFallbackThreshold', 250, {
|
|
211
|
+
range: 100,
|
|
212
|
+
min: 100,
|
|
213
|
+
max: 400,
|
|
214
|
+
steps: 7,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const enemy = state.enemies[0];
|
|
218
|
+
if (!enemy)
|
|
219
|
+
{
|
|
220
|
+
return {move: {x: 0, y: 0}};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
224
|
+
|
|
225
|
+
const threats = getThreats(state);
|
|
226
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
227
|
+
|
|
228
|
+
// === DEFENSE: Handle channeling ===
|
|
229
|
+
if (state.state === 'channeling')
|
|
230
|
+
{
|
|
231
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
232
|
+
{
|
|
233
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
234
|
+
}
|
|
235
|
+
return {move: {x: 0, y: 0}};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
|
|
239
|
+
// DPS trade: we accepted the hit when we started casting. Only cancel to survive.
|
|
240
|
+
if (
|
|
241
|
+
state.state === 'casting' &&
|
|
242
|
+
state.castingSpell === 'missile' &&
|
|
243
|
+
urgentThreat &&
|
|
244
|
+
urgentThreat.projectile.damage >= state.health
|
|
245
|
+
)
|
|
246
|
+
{
|
|
247
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
248
|
+
if (
|
|
249
|
+
urgentThreat.ticksToImpact <=
|
|
250
|
+
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
|
|
251
|
+
)
|
|
252
|
+
{
|
|
253
|
+
const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
|
|
254
|
+
const cancelMove = combatMove(
|
|
255
|
+
state.position,
|
|
256
|
+
enemy,
|
|
257
|
+
distance,
|
|
258
|
+
rangeMin,
|
|
259
|
+
rangeMax,
|
|
260
|
+
dodgeDir,
|
|
261
|
+
state.tick,
|
|
262
|
+
strafeCyclePeriod,
|
|
263
|
+
strafeIntensity,
|
|
264
|
+
approachSpeed,
|
|
265
|
+
);
|
|
266
|
+
return {move: cancelMove, cancel: true};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// === BLINK: Dodge undodgeable/forced-shield threats (offensive alternative to shielding) ===
|
|
271
|
+
// When we would otherwise shield, blink instead — no GCD after blink, so can fire back immediately.
|
|
272
|
+
// Only for threats that can't be dodged by movement (homing/undodgeable).
|
|
273
|
+
if (
|
|
274
|
+
state.state === 'idle' &&
|
|
275
|
+
urgentThreat &&
|
|
276
|
+
state.blinkCooldown === 0 &&
|
|
277
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
278
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
279
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
280
|
+
)
|
|
281
|
+
{
|
|
282
|
+
return {
|
|
283
|
+
move: {x: 0, y: 0},
|
|
284
|
+
startCast: {
|
|
285
|
+
spell: 'blink',
|
|
286
|
+
target:
|
|
287
|
+
distance < targetDistance
|
|
288
|
+
? (getBlinkToIncreaseDistance(
|
|
289
|
+
state.position,
|
|
290
|
+
enemy.position,
|
|
291
|
+
state.projectiles,
|
|
292
|
+
) ?? getBlinkToCenter(state.position))
|
|
293
|
+
: getBlinkToCenter(state.position),
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// === DEFENSE: Shield undodgeable or high-damage homing threats (when blink on cooldown) ===
|
|
299
|
+
if (
|
|
300
|
+
state.state === 'idle' &&
|
|
301
|
+
urgentThreat &&
|
|
302
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
303
|
+
)
|
|
304
|
+
{
|
|
305
|
+
if (urgentThreat.canBlockInTime)
|
|
306
|
+
{
|
|
307
|
+
if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
|
|
308
|
+
{
|
|
309
|
+
return {
|
|
310
|
+
move: {x: 0, y: 0},
|
|
311
|
+
startCast: {spell: 'shield'},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// === DISTANCE BLINK: enemy too close, blink to reestablish range ===
|
|
318
|
+
if (
|
|
319
|
+
state.state === 'idle' &&
|
|
320
|
+
distance < dangerDistance &&
|
|
321
|
+
state.blinkCooldown === 0 &&
|
|
322
|
+
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
323
|
+
)
|
|
324
|
+
{
|
|
325
|
+
const blinkTarget = getBlinkToIncreaseDistance(
|
|
326
|
+
state.position,
|
|
327
|
+
enemy.position,
|
|
328
|
+
state.projectiles,
|
|
329
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
330
|
+
);
|
|
331
|
+
if (blinkTarget)
|
|
332
|
+
{
|
|
333
|
+
return {
|
|
334
|
+
move: {x: 0, y: 0},
|
|
335
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Smart combat movement
|
|
341
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
342
|
+
const move = combatMove(
|
|
343
|
+
state.position,
|
|
344
|
+
enemy,
|
|
345
|
+
distance,
|
|
346
|
+
rangeMin,
|
|
347
|
+
rangeMax,
|
|
348
|
+
dodgeDirection,
|
|
349
|
+
state.tick,
|
|
350
|
+
strafeCyclePeriod,
|
|
351
|
+
strafeIntensity,
|
|
352
|
+
approachSpeed,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// === OFFENSE: Adaptive homing missiles ===
|
|
356
|
+
// Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
|
|
357
|
+
const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
|
|
358
|
+
const hasNearbyLethalMissile = state.projectiles.some(
|
|
359
|
+
(p) =>
|
|
360
|
+
!myProjectileIds.has(p.id) &&
|
|
361
|
+
p.damage >= state.health &&
|
|
362
|
+
distanceTo(state.position, p.position) / p.speed <
|
|
363
|
+
MAX_CAST_BUDGET + GCD_DURATION,
|
|
364
|
+
);
|
|
365
|
+
if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
|
|
366
|
+
{
|
|
367
|
+
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
368
|
+
|
|
369
|
+
// Anticipate enemy's next missile based on their current state
|
|
370
|
+
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
371
|
+
{
|
|
372
|
+
const remainingEnemyCast =
|
|
373
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
374
|
+
nextThreatTime = Math.min(
|
|
375
|
+
nextThreatTime,
|
|
376
|
+
remainingEnemyCast + distance / flightTimeDivisor,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
// No imminent threat — estimate enemy's likely response time
|
|
380
|
+
if (nextThreatTime > threatFallbackThreshold)
|
|
381
|
+
{
|
|
382
|
+
const enemyLockTime = getEnemyVulnerabilityTicks(enemy);
|
|
383
|
+
nextThreatTime = Math.min(
|
|
384
|
+
nextThreatTime,
|
|
385
|
+
enemyLockTime + enemyResponseEstimate + distance / flightTimeDivisor,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const safeBudget =
|
|
390
|
+
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
391
|
+
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
392
|
+
|
|
393
|
+
// Try adaptive fitting for the safe budget
|
|
394
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
395
|
+
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
396
|
+
minTurnRate,
|
|
397
|
+
maxDamage: enemyHP,
|
|
398
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// HIGH-DAMAGE PRIORITY: Sniper missiles need to chip through shields.
|
|
402
|
+
// Damage 5 through 90% shield = 0.5 chip (useless).
|
|
403
|
+
// Damage 25 through 90% shield = 2.5 chip (kills in ~24 hits).
|
|
404
|
+
// Always prefer the adaptive config if damage >= 15; otherwise use a
|
|
405
|
+
// fixed big-shot fallback that guarantees meaningful shield chip.
|
|
406
|
+
const BIG_SHOT = {damage: 25, speed: 10, turnRate: 0.5, duration: 80};
|
|
407
|
+
|
|
408
|
+
if (config && config.damage >= 15)
|
|
409
|
+
{
|
|
410
|
+
return {
|
|
411
|
+
move,
|
|
412
|
+
startCast: {
|
|
413
|
+
spell: 'missile',
|
|
414
|
+
config,
|
|
415
|
+
missileAI: HomingAI,
|
|
416
|
+
direction: angleTo(state.position, enemy.position),
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Adaptive fitting produced weak missile or failed — fire big shot instead
|
|
422
|
+
// This ensures Spelltracer always does meaningful damage, even if it takes
|
|
423
|
+
// a hit during the longer cast (trade is worthwhile at high damage).
|
|
424
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > 50)
|
|
425
|
+
{
|
|
426
|
+
return {
|
|
427
|
+
move,
|
|
428
|
+
startCast: {
|
|
429
|
+
spell: 'missile',
|
|
430
|
+
config: BIG_SHOT,
|
|
431
|
+
missileAI: HomingAI,
|
|
432
|
+
direction: angleTo(state.position, enemy.position),
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Imminent threat — wait for it to pass, then fire
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return {move};
|
|
441
|
+
};
|