@vibemancer/core 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-7VDJHO22.js} +3 -3
- package/dist/chunk-7VDJHO22.js.map +1 -0
- package/dist/index-browser.d.ts +1 -1
- package/dist/index-browser.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +79 -78
- package/src/bots/berserker/01_Stormchaser.ts +457 -457
- package/src/bots/berserker/02_Stormcaller.ts +417 -417
- package/src/bots/berserker/03_Stormforger.ts +481 -481
- package/src/bots/caster/01_Flamecaller.ts +286 -286
- package/src/bots/caster/02_Pyromancer.ts +350 -350
- package/src/bots/caster/03_Infernalist.ts +492 -492
- package/src/bots/defensive/01_Turtle.ts +151 -151
- package/src/bots/defensive/02_Sentinel.ts +134 -134
- package/src/bots/defensive/03_Golem.ts +357 -357
- package/src/bots/duelist/01_Battlemage.ts +433 -433
- package/src/bots/duelist/02_Warmage.ts +438 -438
- package/src/bots/duelist/03_Archmage.ts +588 -588
- package/src/bots/homing/01_Bonemancer.ts +67 -67
- package/src/bots/homing/02_Lich.ts +356 -356
- package/src/bots/homing/03_Archlich.ts +220 -220
- package/src/bots/kiter/01_Spellspinner.ts +398 -398
- package/src/bots/kiter/02_Spellweaver.ts +378 -378
- package/src/bots/kiter/03_Spellbinder.ts +448 -448
- package/src/bots/melee/01_Shadowblade.ts +270 -270
- package/src/bots/melee/02_Nightblade.ts +437 -437
- package/src/bots/melee/03_Voidblade.ts +582 -582
- package/src/bots/sniper/01_Spellshot.ts +385 -385
- package/src/bots/sniper/02_Spelltracer.ts +441 -441
- package/src/bots/sniper/03_Spellseeker.ts +546 -546
- package/src/bots/standalone/Critter.ts +89 -89
- package/src/bots/standalone/Doombringer.ts +91 -91
- package/src/bots/standalone/Hogger.ts +228 -228
- package/src/bots/standalone/Rookie.ts +50 -50
- package/src/bots/standalone/TargetDummy.ts +21 -21
- package/src/bots/test/cheater.ts +405 -405
- package/src/bots/test/crasher.ts +81 -81
- package/src/engine/hooks-runtime.ts +394 -394
- package/src/engine/manual-match.ts +289 -289
- package/src/engine/missile-templates.ts +155 -155
- package/src/engine/physics.ts +143 -143
- package/src/engine/simulation.ts +828 -828
- package/src/engine/spells.ts +128 -128
- package/src/engine-version.ts +1 -1
- package/src/index.ts +23 -23
- package/src/rules.ts +254 -254
- package/src/types.ts +193 -193
- package/src/utils/index.ts +6 -6
- package/dist/chunk-L7Z7OFXD.js.map +0 -1
|
@@ -1,481 +1,481 @@
|
|
|
1
|
-
import {
|
|
2
|
-
WizardFunction,
|
|
3
|
-
MissileAIFunction,
|
|
4
|
-
MissileActions,
|
|
5
|
-
} from '../../types.js';
|
|
6
|
-
import {angleTo} from '../../utils/angles.js';
|
|
7
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
8
|
-
import {interceptAngle, moveInDirection} from '../../utils/movement.js';
|
|
9
|
-
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
10
|
-
import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
|
|
11
|
-
import {
|
|
12
|
-
getThreats,
|
|
13
|
-
getMostUrgentThreat,
|
|
14
|
-
getDodgeDirectionForMovement,
|
|
15
|
-
getBlinkToCenter,
|
|
16
|
-
fitMissileToBudget,
|
|
17
|
-
shouldForceShield,
|
|
18
|
-
MAX_CAST_BUDGET,
|
|
19
|
-
MIN_USEFUL_DAMAGE,
|
|
20
|
-
getEnemyVulnerabilityTicks,
|
|
21
|
-
} from '../shared.js';
|
|
22
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
23
|
-
|
|
24
|
-
const WALL_BUFFER = 80;
|
|
25
|
-
|
|
26
|
-
const SHIELD_BUFFER = 3;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Predictive homing missile AI - leads the target.
|
|
30
|
-
*/
|
|
31
|
-
const HomingAI: MissileAIFunction = ({
|
|
32
|
-
worldState,
|
|
33
|
-
missileState,
|
|
34
|
-
}) =>
|
|
35
|
-
{
|
|
36
|
-
const enemy = worldState.enemies[0];
|
|
37
|
-
if (!enemy) return {};
|
|
38
|
-
|
|
39
|
-
const angle = interceptAngle(
|
|
40
|
-
missileState.position,
|
|
41
|
-
enemy.position,
|
|
42
|
-
enemy.velocity,
|
|
43
|
-
missileState.speed,
|
|
44
|
-
);
|
|
45
|
-
if (angle !== null)
|
|
46
|
-
{
|
|
47
|
-
return {turnToward: moveInDirection(missileState.position, angle, 100)};
|
|
48
|
-
}
|
|
49
|
-
return {turnToward: enemy.position};
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const StraightAI: MissileAIFunction = () => ({});
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Check if position is near a wall.
|
|
56
|
-
*/
|
|
57
|
-
function isNearWall(position: {x: number; y: number}): boolean
|
|
58
|
-
{
|
|
59
|
-
return (
|
|
60
|
-
position.x < WALL_BUFFER ||
|
|
61
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
62
|
-
position.y < WALL_BUFFER ||
|
|
63
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Smart combat movement with threat-aware dodging.
|
|
69
|
-
*/
|
|
70
|
-
function combatMove(
|
|
71
|
-
position: {x: number; y: number},
|
|
72
|
-
enemy: {position: {x: number; y: number}},
|
|
73
|
-
currentDistance: number,
|
|
74
|
-
targetDistance: number,
|
|
75
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
76
|
-
tick: number,
|
|
77
|
-
strafeCyclePeriod: number,
|
|
78
|
-
strafeIntensity: number,
|
|
79
|
-
approachSpeed: number,
|
|
80
|
-
distanceDeadZone: number,
|
|
81
|
-
): {x: number; y: number}
|
|
82
|
-
{
|
|
83
|
-
const deltaX = enemy.position.x - position.x;
|
|
84
|
-
const deltaY = enemy.position.y - position.y;
|
|
85
|
-
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
86
|
-
|
|
87
|
-
const strafeClockwise = {
|
|
88
|
-
x: -deltaY / normalizedDistance,
|
|
89
|
-
y: deltaX / normalizedDistance,
|
|
90
|
-
};
|
|
91
|
-
const strafeCounterClockwise = {
|
|
92
|
-
x: deltaY / normalizedDistance,
|
|
93
|
-
y: -deltaX / normalizedDistance,
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
let strafeDirection: {x: number; y: number};
|
|
97
|
-
|
|
98
|
-
if (dodgeDirection)
|
|
99
|
-
{
|
|
100
|
-
strafeDirection = dodgeDirection;
|
|
101
|
-
}
|
|
102
|
-
else if (isNearWall(position))
|
|
103
|
-
{
|
|
104
|
-
const futureClockwise = {
|
|
105
|
-
x: position.x + strafeClockwise.x * 100,
|
|
106
|
-
y: position.y + strafeClockwise.y * 100,
|
|
107
|
-
};
|
|
108
|
-
const futureCounterClockwise = {
|
|
109
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
110
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
111
|
-
};
|
|
112
|
-
const scoreClockwise = Math.min(
|
|
113
|
-
futureClockwise.x,
|
|
114
|
-
ARENA_SIZE - futureClockwise.x,
|
|
115
|
-
futureClockwise.y,
|
|
116
|
-
ARENA_SIZE - futureClockwise.y,
|
|
117
|
-
);
|
|
118
|
-
const scoreCounterClockwise = Math.min(
|
|
119
|
-
futureCounterClockwise.x,
|
|
120
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
121
|
-
futureCounterClockwise.y,
|
|
122
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
123
|
-
);
|
|
124
|
-
strafeDirection =
|
|
125
|
-
scoreClockwise > scoreCounterClockwise
|
|
126
|
-
? strafeClockwise
|
|
127
|
-
: strafeCounterClockwise;
|
|
128
|
-
}
|
|
129
|
-
else
|
|
130
|
-
{
|
|
131
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
132
|
-
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (dodgeDirection)
|
|
136
|
-
{
|
|
137
|
-
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
let moveX = strafeDirection.x * strafeIntensity;
|
|
141
|
-
let moveY = strafeDirection.y * strafeIntensity;
|
|
142
|
-
|
|
143
|
-
if (currentDistance > targetDistance + distanceDeadZone)
|
|
144
|
-
{
|
|
145
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
146
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
147
|
-
}
|
|
148
|
-
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
149
|
-
{
|
|
150
|
-
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
151
|
-
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
152
|
-
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
153
|
-
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
154
|
-
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
155
|
-
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
156
|
-
moveX += retreatX;
|
|
157
|
-
moveY += retreatY;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
161
|
-
if (magnitude > 100)
|
|
162
|
-
{
|
|
163
|
-
moveX = (moveX / magnitude) * 100;
|
|
164
|
-
moveY = (moveY / magnitude) * 100;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return {x: moveX, y: moveY};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Bot: Stormforger
|
|
172
|
-
*
|
|
173
|
-
* BEHAVIOR: Enhanced Stormcaller with vulnerability exploitation. Takes the exact
|
|
174
|
-
* Stormcaller foundation (adaptive missile fitting + predictive homing) and adds
|
|
175
|
-
* a punish mode that fires fast straight missiles when the enemy is locked in
|
|
176
|
-
* GCD or cast animation. During vulnerability windows, uses getLeadPosition for
|
|
177
|
-
* accurate straight shots that arrive before the enemy can react.
|
|
178
|
-
*
|
|
179
|
-
* PROGRESSION LINE: Stormchaser → Stormcaller → Stormforger
|
|
180
|
-
* - Stormchaser (tier 1): Adaptive missiles, smart trading, aggressive defense
|
|
181
|
-
* - Stormcaller (tier 2): + fitMissileToBudget, predictive homing, optimized damage
|
|
182
|
-
* - Stormforger (tier 3): + vulnerability exploitation, punish missiles during enemy GCD
|
|
183
|
-
*
|
|
184
|
-
* TIER: 3 (elite Berserker line)
|
|
185
|
-
*/
|
|
186
|
-
export const Stormforger: WizardFunction = ({state}) =>
|
|
187
|
-
{
|
|
188
|
-
// Tunable parameters (run optimizer script to find optimal values)
|
|
189
|
-
const targetDistance = useParam('targetDistance', 271, {
|
|
190
|
-
range: 200,
|
|
191
|
-
min: 0,
|
|
192
|
-
});
|
|
193
|
-
const minTurnRate = useParam('minTurnRate', 0.5, {range: 1.5, steps: 5});
|
|
194
|
-
const shieldHealthThreshold = useParam('shieldHealthThreshold', 20, {
|
|
195
|
-
range: 23,
|
|
196
|
-
min: 1,
|
|
197
|
-
steps: 7,
|
|
198
|
-
});
|
|
199
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {
|
|
200
|
-
range: 75,
|
|
201
|
-
min: 80,
|
|
202
|
-
max: 300,
|
|
203
|
-
});
|
|
204
|
-
const strafeIntensity = useParam('strafeIntensity', 72, {
|
|
205
|
-
range: 40,
|
|
206
|
-
min: 20,
|
|
207
|
-
max: 100,
|
|
208
|
-
steps: 7,
|
|
209
|
-
});
|
|
210
|
-
const approachSpeed = useParam('approachSpeed', 69, {
|
|
211
|
-
range: 30,
|
|
212
|
-
min: 10,
|
|
213
|
-
max: 70,
|
|
214
|
-
steps: 7,
|
|
215
|
-
});
|
|
216
|
-
const distanceDeadZone = useParam('distanceDeadZone', 21, {
|
|
217
|
-
range: 25,
|
|
218
|
-
min: 10,
|
|
219
|
-
max: 60,
|
|
220
|
-
steps: 7,
|
|
221
|
-
});
|
|
222
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
223
|
-
range: 75,
|
|
224
|
-
min: 50,
|
|
225
|
-
max: 300,
|
|
226
|
-
});
|
|
227
|
-
const blinkWindowMax = useParam('blinkWindowMax', 81, {
|
|
228
|
-
range: 40,
|
|
229
|
-
min: 20,
|
|
230
|
-
max: 120,
|
|
231
|
-
steps: 7,
|
|
232
|
-
});
|
|
233
|
-
const attackRange = useParam('attackRange', 339, {
|
|
234
|
-
range: 200,
|
|
235
|
-
min: 300,
|
|
236
|
-
max: 800,
|
|
237
|
-
});
|
|
238
|
-
const flightTimeDivisor = useParam('flightTimeDivisor', 11.4, {
|
|
239
|
-
range: 4,
|
|
240
|
-
min: 2,
|
|
241
|
-
max: 12,
|
|
242
|
-
steps: 5,
|
|
243
|
-
});
|
|
244
|
-
const punishMinVulnerability = useParam('punishMinVulnerability', 30, {
|
|
245
|
-
range: 80,
|
|
246
|
-
min: 30,
|
|
247
|
-
steps: 7,
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
const enemy = state.enemies[0];
|
|
251
|
-
if (!enemy)
|
|
252
|
-
{
|
|
253
|
-
return {move: {x: 0, y: 0}};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
257
|
-
|
|
258
|
-
// Analyze threats using simulation
|
|
259
|
-
const threats = getThreats(state);
|
|
260
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
261
|
-
|
|
262
|
-
// === DEFENSE: Handle channeling state ===
|
|
263
|
-
if (state.state === 'channeling')
|
|
264
|
-
{
|
|
265
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
266
|
-
{
|
|
267
|
-
return {move: {x: 0, y: 0}, cancel: true};
|
|
268
|
-
}
|
|
269
|
-
return {move: {x: 0, y: 0}};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
|
|
273
|
-
// DPS trade: we accepted the hit when we started casting. Only cancel to survive.
|
|
274
|
-
if (
|
|
275
|
-
state.state === 'casting' &&
|
|
276
|
-
state.castingSpell === 'missile' &&
|
|
277
|
-
urgentThreat &&
|
|
278
|
-
urgentThreat.projectile.damage >= state.health
|
|
279
|
-
)
|
|
280
|
-
{
|
|
281
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
282
|
-
if (
|
|
283
|
-
urgentThreat.ticksToImpact <=
|
|
284
|
-
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
|
|
285
|
-
)
|
|
286
|
-
{
|
|
287
|
-
const dodgeDirection = getDodgeDirectionForMovement(
|
|
288
|
-
threats,
|
|
289
|
-
state.position,
|
|
290
|
-
);
|
|
291
|
-
const move = combatMove(
|
|
292
|
-
state.position,
|
|
293
|
-
enemy,
|
|
294
|
-
distance,
|
|
295
|
-
targetDistance,
|
|
296
|
-
dodgeDirection,
|
|
297
|
-
state.tick,
|
|
298
|
-
strafeCyclePeriod,
|
|
299
|
-
strafeIntensity,
|
|
300
|
-
approachSpeed,
|
|
301
|
-
distanceDeadZone,
|
|
302
|
-
);
|
|
303
|
-
return {move, cancel: true};
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// === BLINK: Dodge missile + reposition (first priority) ===
|
|
308
|
-
// Blink dodges the incoming missile — no GCD after, so can immediately fire back.
|
|
309
|
-
// Only shield when blink is on cooldown.
|
|
310
|
-
if (
|
|
311
|
-
state.state === 'idle' &&
|
|
312
|
-
urgentThreat &&
|
|
313
|
-
state.blinkCooldown === 0 &&
|
|
314
|
-
urgentThreat.ticksToImpact >= 10 &&
|
|
315
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
316
|
-
)
|
|
317
|
-
{
|
|
318
|
-
return {
|
|
319
|
-
move: {x: 0, y: 0},
|
|
320
|
-
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// === DEFENSE: Shield threats (when blink is on cooldown) ===
|
|
325
|
-
if (state.state === 'idle' && urgentThreat)
|
|
326
|
-
{
|
|
327
|
-
const forceShield = shouldForceShield(urgentThreat);
|
|
328
|
-
const undodgeable = !urgentThreat.bestDodgeDirection;
|
|
329
|
-
const healthLow = state.health < shieldHealthThreshold;
|
|
330
|
-
const healthCritical =
|
|
331
|
-
state.health <= urgentThreat.projectile.damage * 2 + 1;
|
|
332
|
-
const doShield =
|
|
333
|
-
forceShield || (undodgeable && healthLow) || healthCritical;
|
|
334
|
-
|
|
335
|
-
if (doShield && urgentThreat.canBlockInTime)
|
|
336
|
-
{
|
|
337
|
-
if (urgentThreat.ticksToStartShield <= 3)
|
|
338
|
-
{
|
|
339
|
-
return {
|
|
340
|
-
move: {x: 0, y: 0},
|
|
341
|
-
startCast: {spell: 'shield'},
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// === MOVEMENT: Smart combat movement ===
|
|
348
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
349
|
-
const move = combatMove(
|
|
350
|
-
state.position,
|
|
351
|
-
enemy,
|
|
352
|
-
distance,
|
|
353
|
-
targetDistance,
|
|
354
|
-
dodgeDirection,
|
|
355
|
-
state.tick,
|
|
356
|
-
strafeCyclePeriod,
|
|
357
|
-
strafeIntensity,
|
|
358
|
-
approachSpeed,
|
|
359
|
-
distanceDeadZone,
|
|
360
|
-
);
|
|
361
|
-
|
|
362
|
-
// === OFFENSE: Vulnerability punish + adaptive missile fitting ===
|
|
363
|
-
if (state.state === 'idle' && distance < attackRange)
|
|
364
|
-
{
|
|
365
|
-
const enemyHP = Math.ceil(enemy.health);
|
|
366
|
-
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
367
|
-
|
|
368
|
-
// --- PUNISH MODE: fast straight missiles during enemy GCD/cast ---
|
|
369
|
-
if (
|
|
370
|
-
vulnerabilityTicks >= punishMinVulnerability &&
|
|
371
|
-
distance < 500
|
|
372
|
-
)
|
|
373
|
-
{
|
|
374
|
-
const punishBudget = vulnerabilityTicks - distance / flightTimeDivisor;
|
|
375
|
-
|
|
376
|
-
if (punishBudget > 0)
|
|
377
|
-
{
|
|
378
|
-
const config = fitMissileToBudget(
|
|
379
|
-
Math.min(punishBudget, MAX_CAST_BUDGET),
|
|
380
|
-
distance,
|
|
381
|
-
{
|
|
382
|
-
minTurnRate: 0,
|
|
383
|
-
maxDamage: enemyHP,
|
|
384
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
385
|
-
},
|
|
386
|
-
);
|
|
387
|
-
|
|
388
|
-
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
389
|
-
{
|
|
390
|
-
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
391
|
-
const flightTime = distance / config.speed;
|
|
392
|
-
|
|
393
|
-
if (castTime + flightTime < vulnerabilityTicks)
|
|
394
|
-
{
|
|
395
|
-
const aimTarget =
|
|
396
|
-
config.turnRate > 0
|
|
397
|
-
? enemy.position
|
|
398
|
-
: getLeadPosition(
|
|
399
|
-
enemy.position,
|
|
400
|
-
enemy.velocity,
|
|
401
|
-
config.speed,
|
|
402
|
-
state.position,
|
|
403
|
-
);
|
|
404
|
-
return {
|
|
405
|
-
move,
|
|
406
|
-
startCast: {
|
|
407
|
-
spell: 'missile',
|
|
408
|
-
config,
|
|
409
|
-
missileAI:
|
|
410
|
-
config.turnRate > 0 ? HomingAI : StraightAI,
|
|
411
|
-
direction: angleTo(state.position, aimTarget),
|
|
412
|
-
},
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// --- STANDARD MODE: adaptive missile fitting with predictive homing ---
|
|
420
|
-
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
421
|
-
|
|
422
|
-
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
423
|
-
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
424
|
-
{
|
|
425
|
-
const remainingEnemyCast =
|
|
426
|
-
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
427
|
-
const estimatedFlightTime = distance / flightTimeDivisor;
|
|
428
|
-
nextThreatTime = Math.min(
|
|
429
|
-
nextThreatTime,
|
|
430
|
-
remainingEnemyCast + estimatedFlightTime,
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const safeBudget =
|
|
435
|
-
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
436
|
-
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
437
|
-
|
|
438
|
-
// Predictive homing compensates for movement during cast
|
|
439
|
-
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
440
|
-
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
441
|
-
minTurnRate,
|
|
442
|
-
maxDamage: enemyHP,
|
|
443
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
if (config && config.damage >= minDmg)
|
|
447
|
-
{
|
|
448
|
-
return {
|
|
449
|
-
move,
|
|
450
|
-
startCast: {
|
|
451
|
-
spell: 'missile',
|
|
452
|
-
config,
|
|
453
|
-
missileAI:
|
|
454
|
-
config.turnRate > 0
|
|
455
|
-
? HomingAI
|
|
456
|
-
: (): MissileActions => ({}),
|
|
457
|
-
direction: angleTo(state.position, enemy.position),
|
|
458
|
-
},
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Budget too small for useful missile — wait for threat to pass, then fire
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// === FALLBACK DEFENSE: Shield when no attack window exists ===
|
|
466
|
-
if (
|
|
467
|
-
state.state === 'idle' &&
|
|
468
|
-
urgentThreat &&
|
|
469
|
-
!urgentThreat.bestDodgeDirection &&
|
|
470
|
-
urgentThreat.canBlockInTime &&
|
|
471
|
-
urgentThreat.ticksToStartShield <= 3
|
|
472
|
-
)
|
|
473
|
-
{
|
|
474
|
-
return {
|
|
475
|
-
move: {x: 0, y: 0},
|
|
476
|
-
startCast: {spell: 'shield'},
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
return {move};
|
|
481
|
-
};
|
|
1
|
+
import {
|
|
2
|
+
WizardFunction,
|
|
3
|
+
MissileAIFunction,
|
|
4
|
+
MissileActions,
|
|
5
|
+
} from '../../types.js';
|
|
6
|
+
import {angleTo} from '../../utils/angles.js';
|
|
7
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
8
|
+
import {interceptAngle, moveInDirection} from '../../utils/movement.js';
|
|
9
|
+
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
10
|
+
import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
|
|
11
|
+
import {
|
|
12
|
+
getThreats,
|
|
13
|
+
getMostUrgentThreat,
|
|
14
|
+
getDodgeDirectionForMovement,
|
|
15
|
+
getBlinkToCenter,
|
|
16
|
+
fitMissileToBudget,
|
|
17
|
+
shouldForceShield,
|
|
18
|
+
MAX_CAST_BUDGET,
|
|
19
|
+
MIN_USEFUL_DAMAGE,
|
|
20
|
+
getEnemyVulnerabilityTicks,
|
|
21
|
+
} from '../shared.js';
|
|
22
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
23
|
+
|
|
24
|
+
const WALL_BUFFER = 80;
|
|
25
|
+
|
|
26
|
+
const SHIELD_BUFFER = 3;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Predictive homing missile AI - leads the target.
|
|
30
|
+
*/
|
|
31
|
+
const HomingAI: MissileAIFunction = ({
|
|
32
|
+
worldState,
|
|
33
|
+
missileState,
|
|
34
|
+
}) =>
|
|
35
|
+
{
|
|
36
|
+
const enemy = worldState.enemies[0];
|
|
37
|
+
if (!enemy) return {};
|
|
38
|
+
|
|
39
|
+
const angle = interceptAngle(
|
|
40
|
+
missileState.position,
|
|
41
|
+
enemy.position,
|
|
42
|
+
enemy.velocity,
|
|
43
|
+
missileState.speed,
|
|
44
|
+
);
|
|
45
|
+
if (angle !== null)
|
|
46
|
+
{
|
|
47
|
+
return {turnToward: moveInDirection(missileState.position, angle, 100)};
|
|
48
|
+
}
|
|
49
|
+
return {turnToward: enemy.position};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const StraightAI: MissileAIFunction = () => ({});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if position is near a wall.
|
|
56
|
+
*/
|
|
57
|
+
function isNearWall(position: {x: number; y: number}): boolean
|
|
58
|
+
{
|
|
59
|
+
return (
|
|
60
|
+
position.x < WALL_BUFFER ||
|
|
61
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
62
|
+
position.y < WALL_BUFFER ||
|
|
63
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Smart combat movement with threat-aware dodging.
|
|
69
|
+
*/
|
|
70
|
+
function combatMove(
|
|
71
|
+
position: {x: number; y: number},
|
|
72
|
+
enemy: {position: {x: number; y: number}},
|
|
73
|
+
currentDistance: number,
|
|
74
|
+
targetDistance: number,
|
|
75
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
76
|
+
tick: number,
|
|
77
|
+
strafeCyclePeriod: number,
|
|
78
|
+
strafeIntensity: number,
|
|
79
|
+
approachSpeed: number,
|
|
80
|
+
distanceDeadZone: number,
|
|
81
|
+
): {x: number; y: number}
|
|
82
|
+
{
|
|
83
|
+
const deltaX = enemy.position.x - position.x;
|
|
84
|
+
const deltaY = enemy.position.y - position.y;
|
|
85
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
86
|
+
|
|
87
|
+
const strafeClockwise = {
|
|
88
|
+
x: -deltaY / normalizedDistance,
|
|
89
|
+
y: deltaX / normalizedDistance,
|
|
90
|
+
};
|
|
91
|
+
const strafeCounterClockwise = {
|
|
92
|
+
x: deltaY / normalizedDistance,
|
|
93
|
+
y: -deltaX / normalizedDistance,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
let strafeDirection: {x: number; y: number};
|
|
97
|
+
|
|
98
|
+
if (dodgeDirection)
|
|
99
|
+
{
|
|
100
|
+
strafeDirection = dodgeDirection;
|
|
101
|
+
}
|
|
102
|
+
else if (isNearWall(position))
|
|
103
|
+
{
|
|
104
|
+
const futureClockwise = {
|
|
105
|
+
x: position.x + strafeClockwise.x * 100,
|
|
106
|
+
y: position.y + strafeClockwise.y * 100,
|
|
107
|
+
};
|
|
108
|
+
const futureCounterClockwise = {
|
|
109
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
110
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
111
|
+
};
|
|
112
|
+
const scoreClockwise = Math.min(
|
|
113
|
+
futureClockwise.x,
|
|
114
|
+
ARENA_SIZE - futureClockwise.x,
|
|
115
|
+
futureClockwise.y,
|
|
116
|
+
ARENA_SIZE - futureClockwise.y,
|
|
117
|
+
);
|
|
118
|
+
const scoreCounterClockwise = Math.min(
|
|
119
|
+
futureCounterClockwise.x,
|
|
120
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
121
|
+
futureCounterClockwise.y,
|
|
122
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
123
|
+
);
|
|
124
|
+
strafeDirection =
|
|
125
|
+
scoreClockwise > scoreCounterClockwise
|
|
126
|
+
? strafeClockwise
|
|
127
|
+
: strafeCounterClockwise;
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
{
|
|
131
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
132
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (dodgeDirection)
|
|
136
|
+
{
|
|
137
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
141
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
142
|
+
|
|
143
|
+
if (currentDistance > targetDistance + distanceDeadZone)
|
|
144
|
+
{
|
|
145
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
146
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
147
|
+
}
|
|
148
|
+
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
149
|
+
{
|
|
150
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
151
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
152
|
+
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
153
|
+
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
154
|
+
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
155
|
+
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
156
|
+
moveX += retreatX;
|
|
157
|
+
moveY += retreatY;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
161
|
+
if (magnitude > 100)
|
|
162
|
+
{
|
|
163
|
+
moveX = (moveX / magnitude) * 100;
|
|
164
|
+
moveY = (moveY / magnitude) * 100;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {x: moveX, y: moveY};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Bot: Stormforger
|
|
172
|
+
*
|
|
173
|
+
* BEHAVIOR: Enhanced Stormcaller with vulnerability exploitation. Takes the exact
|
|
174
|
+
* Stormcaller foundation (adaptive missile fitting + predictive homing) and adds
|
|
175
|
+
* a punish mode that fires fast straight missiles when the enemy is locked in
|
|
176
|
+
* GCD or cast animation. During vulnerability windows, uses getLeadPosition for
|
|
177
|
+
* accurate straight shots that arrive before the enemy can react.
|
|
178
|
+
*
|
|
179
|
+
* PROGRESSION LINE: Stormchaser → Stormcaller → Stormforger
|
|
180
|
+
* - Stormchaser (tier 1): Adaptive missiles, smart trading, aggressive defense
|
|
181
|
+
* - Stormcaller (tier 2): + fitMissileToBudget, predictive homing, optimized damage
|
|
182
|
+
* - Stormforger (tier 3): + vulnerability exploitation, punish missiles during enemy GCD
|
|
183
|
+
*
|
|
184
|
+
* TIER: 3 (elite Berserker line)
|
|
185
|
+
*/
|
|
186
|
+
export const Stormforger: WizardFunction = ({state}) =>
|
|
187
|
+
{
|
|
188
|
+
// Tunable parameters (run optimizer script to find optimal values)
|
|
189
|
+
const targetDistance = useParam('targetDistance', 271, {
|
|
190
|
+
range: 200,
|
|
191
|
+
min: 0,
|
|
192
|
+
});
|
|
193
|
+
const minTurnRate = useParam('minTurnRate', 0.5, {range: 1.5, steps: 5});
|
|
194
|
+
const shieldHealthThreshold = useParam('shieldHealthThreshold', 20, {
|
|
195
|
+
range: 23,
|
|
196
|
+
min: 1,
|
|
197
|
+
steps: 7,
|
|
198
|
+
});
|
|
199
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {
|
|
200
|
+
range: 75,
|
|
201
|
+
min: 80,
|
|
202
|
+
max: 300,
|
|
203
|
+
});
|
|
204
|
+
const strafeIntensity = useParam('strafeIntensity', 72, {
|
|
205
|
+
range: 40,
|
|
206
|
+
min: 20,
|
|
207
|
+
max: 100,
|
|
208
|
+
steps: 7,
|
|
209
|
+
});
|
|
210
|
+
const approachSpeed = useParam('approachSpeed', 69, {
|
|
211
|
+
range: 30,
|
|
212
|
+
min: 10,
|
|
213
|
+
max: 70,
|
|
214
|
+
steps: 7,
|
|
215
|
+
});
|
|
216
|
+
const distanceDeadZone = useParam('distanceDeadZone', 21, {
|
|
217
|
+
range: 25,
|
|
218
|
+
min: 10,
|
|
219
|
+
max: 60,
|
|
220
|
+
steps: 7,
|
|
221
|
+
});
|
|
222
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
223
|
+
range: 75,
|
|
224
|
+
min: 50,
|
|
225
|
+
max: 300,
|
|
226
|
+
});
|
|
227
|
+
const blinkWindowMax = useParam('blinkWindowMax', 81, {
|
|
228
|
+
range: 40,
|
|
229
|
+
min: 20,
|
|
230
|
+
max: 120,
|
|
231
|
+
steps: 7,
|
|
232
|
+
});
|
|
233
|
+
const attackRange = useParam('attackRange', 339, {
|
|
234
|
+
range: 200,
|
|
235
|
+
min: 300,
|
|
236
|
+
max: 800,
|
|
237
|
+
});
|
|
238
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 11.4, {
|
|
239
|
+
range: 4,
|
|
240
|
+
min: 2,
|
|
241
|
+
max: 12,
|
|
242
|
+
steps: 5,
|
|
243
|
+
});
|
|
244
|
+
const punishMinVulnerability = useParam('punishMinVulnerability', 30, {
|
|
245
|
+
range: 80,
|
|
246
|
+
min: 30,
|
|
247
|
+
steps: 7,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const enemy = state.enemies[0];
|
|
251
|
+
if (!enemy)
|
|
252
|
+
{
|
|
253
|
+
return {move: {x: 0, y: 0}};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
257
|
+
|
|
258
|
+
// Analyze threats using simulation
|
|
259
|
+
const threats = getThreats(state);
|
|
260
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
261
|
+
|
|
262
|
+
// === DEFENSE: Handle channeling state ===
|
|
263
|
+
if (state.state === 'channeling')
|
|
264
|
+
{
|
|
265
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
266
|
+
{
|
|
267
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
268
|
+
}
|
|
269
|
+
return {move: {x: 0, y: 0}};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
|
|
273
|
+
// DPS trade: we accepted the hit when we started casting. Only cancel to survive.
|
|
274
|
+
if (
|
|
275
|
+
state.state === 'casting' &&
|
|
276
|
+
state.castingSpell === 'missile' &&
|
|
277
|
+
urgentThreat &&
|
|
278
|
+
urgentThreat.projectile.damage >= state.health
|
|
279
|
+
)
|
|
280
|
+
{
|
|
281
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
282
|
+
if (
|
|
283
|
+
urgentThreat.ticksToImpact <=
|
|
284
|
+
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
|
|
285
|
+
)
|
|
286
|
+
{
|
|
287
|
+
const dodgeDirection = getDodgeDirectionForMovement(
|
|
288
|
+
threats,
|
|
289
|
+
state.position,
|
|
290
|
+
);
|
|
291
|
+
const move = combatMove(
|
|
292
|
+
state.position,
|
|
293
|
+
enemy,
|
|
294
|
+
distance,
|
|
295
|
+
targetDistance,
|
|
296
|
+
dodgeDirection,
|
|
297
|
+
state.tick,
|
|
298
|
+
strafeCyclePeriod,
|
|
299
|
+
strafeIntensity,
|
|
300
|
+
approachSpeed,
|
|
301
|
+
distanceDeadZone,
|
|
302
|
+
);
|
|
303
|
+
return {move, cancel: true};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// === BLINK: Dodge missile + reposition (first priority) ===
|
|
308
|
+
// Blink dodges the incoming missile — no GCD after, so can immediately fire back.
|
|
309
|
+
// Only shield when blink is on cooldown.
|
|
310
|
+
if (
|
|
311
|
+
state.state === 'idle' &&
|
|
312
|
+
urgentThreat &&
|
|
313
|
+
state.blinkCooldown === 0 &&
|
|
314
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
315
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
316
|
+
)
|
|
317
|
+
{
|
|
318
|
+
return {
|
|
319
|
+
move: {x: 0, y: 0},
|
|
320
|
+
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// === DEFENSE: Shield threats (when blink is on cooldown) ===
|
|
325
|
+
if (state.state === 'idle' && urgentThreat)
|
|
326
|
+
{
|
|
327
|
+
const forceShield = shouldForceShield(urgentThreat);
|
|
328
|
+
const undodgeable = !urgentThreat.bestDodgeDirection;
|
|
329
|
+
const healthLow = state.health < shieldHealthThreshold;
|
|
330
|
+
const healthCritical =
|
|
331
|
+
state.health <= urgentThreat.projectile.damage * 2 + 1;
|
|
332
|
+
const doShield =
|
|
333
|
+
forceShield || (undodgeable && healthLow) || healthCritical;
|
|
334
|
+
|
|
335
|
+
if (doShield && urgentThreat.canBlockInTime)
|
|
336
|
+
{
|
|
337
|
+
if (urgentThreat.ticksToStartShield <= 3)
|
|
338
|
+
{
|
|
339
|
+
return {
|
|
340
|
+
move: {x: 0, y: 0},
|
|
341
|
+
startCast: {spell: 'shield'},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// === MOVEMENT: Smart combat movement ===
|
|
348
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
349
|
+
const move = combatMove(
|
|
350
|
+
state.position,
|
|
351
|
+
enemy,
|
|
352
|
+
distance,
|
|
353
|
+
targetDistance,
|
|
354
|
+
dodgeDirection,
|
|
355
|
+
state.tick,
|
|
356
|
+
strafeCyclePeriod,
|
|
357
|
+
strafeIntensity,
|
|
358
|
+
approachSpeed,
|
|
359
|
+
distanceDeadZone,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// === OFFENSE: Vulnerability punish + adaptive missile fitting ===
|
|
363
|
+
if (state.state === 'idle' && distance < attackRange)
|
|
364
|
+
{
|
|
365
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
366
|
+
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
367
|
+
|
|
368
|
+
// --- PUNISH MODE: fast straight missiles during enemy GCD/cast ---
|
|
369
|
+
if (
|
|
370
|
+
vulnerabilityTicks >= punishMinVulnerability &&
|
|
371
|
+
distance < 500
|
|
372
|
+
)
|
|
373
|
+
{
|
|
374
|
+
const punishBudget = vulnerabilityTicks - distance / flightTimeDivisor;
|
|
375
|
+
|
|
376
|
+
if (punishBudget > 0)
|
|
377
|
+
{
|
|
378
|
+
const config = fitMissileToBudget(
|
|
379
|
+
Math.min(punishBudget, MAX_CAST_BUDGET),
|
|
380
|
+
distance,
|
|
381
|
+
{
|
|
382
|
+
minTurnRate: 0,
|
|
383
|
+
maxDamage: enemyHP,
|
|
384
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
389
|
+
{
|
|
390
|
+
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
391
|
+
const flightTime = distance / config.speed;
|
|
392
|
+
|
|
393
|
+
if (castTime + flightTime < vulnerabilityTicks)
|
|
394
|
+
{
|
|
395
|
+
const aimTarget =
|
|
396
|
+
config.turnRate > 0
|
|
397
|
+
? enemy.position
|
|
398
|
+
: getLeadPosition(
|
|
399
|
+
enemy.position,
|
|
400
|
+
enemy.velocity,
|
|
401
|
+
config.speed,
|
|
402
|
+
state.position,
|
|
403
|
+
);
|
|
404
|
+
return {
|
|
405
|
+
move,
|
|
406
|
+
startCast: {
|
|
407
|
+
spell: 'missile',
|
|
408
|
+
config,
|
|
409
|
+
missileAI:
|
|
410
|
+
config.turnRate > 0 ? HomingAI : StraightAI,
|
|
411
|
+
direction: angleTo(state.position, aimTarget),
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// --- STANDARD MODE: adaptive missile fitting with predictive homing ---
|
|
420
|
+
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
421
|
+
|
|
422
|
+
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
423
|
+
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
424
|
+
{
|
|
425
|
+
const remainingEnemyCast =
|
|
426
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
427
|
+
const estimatedFlightTime = distance / flightTimeDivisor;
|
|
428
|
+
nextThreatTime = Math.min(
|
|
429
|
+
nextThreatTime,
|
|
430
|
+
remainingEnemyCast + estimatedFlightTime,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const safeBudget =
|
|
435
|
+
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
436
|
+
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
437
|
+
|
|
438
|
+
// Predictive homing compensates for movement during cast
|
|
439
|
+
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
440
|
+
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
441
|
+
minTurnRate,
|
|
442
|
+
maxDamage: enemyHP,
|
|
443
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
if (config && config.damage >= minDmg)
|
|
447
|
+
{
|
|
448
|
+
return {
|
|
449
|
+
move,
|
|
450
|
+
startCast: {
|
|
451
|
+
spell: 'missile',
|
|
452
|
+
config,
|
|
453
|
+
missileAI:
|
|
454
|
+
config.turnRate > 0
|
|
455
|
+
? HomingAI
|
|
456
|
+
: (): MissileActions => ({}),
|
|
457
|
+
direction: angleTo(state.position, enemy.position),
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Budget too small for useful missile — wait for threat to pass, then fire
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// === FALLBACK DEFENSE: Shield when no attack window exists ===
|
|
466
|
+
if (
|
|
467
|
+
state.state === 'idle' &&
|
|
468
|
+
urgentThreat &&
|
|
469
|
+
!urgentThreat.bestDodgeDirection &&
|
|
470
|
+
urgentThreat.canBlockInTime &&
|
|
471
|
+
urgentThreat.ticksToStartShield <= 3
|
|
472
|
+
)
|
|
473
|
+
{
|
|
474
|
+
return {
|
|
475
|
+
move: {x: 0, y: 0},
|
|
476
|
+
startCast: {spell: 'shield'},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {move};
|
|
481
|
+
};
|