@vibemancer/core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-OL7ETV6V.js} +3 -3
- package/dist/chunk-OL7ETV6V.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,457 +1,457 @@
|
|
|
1
|
-
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
-
import {angleTo} from '../../utils/angles.js';
|
|
3
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
-
import {interceptAngle, moveInDirection} from '../../utils/movement.js';
|
|
5
|
-
import {getMissileCastTime} from '../../utils/combat.js';
|
|
6
|
-
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
7
|
-
import {
|
|
8
|
-
getThreats,
|
|
9
|
-
getMostUrgentThreat,
|
|
10
|
-
getDodgeDirectionForMovement,
|
|
11
|
-
isSafeToAttack,
|
|
12
|
-
getBlinkToCenter,
|
|
13
|
-
shouldForceShield,
|
|
14
|
-
} from '../shared.js';
|
|
15
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
16
|
-
const WALL_BUFFER = 80;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Standard homing missile: higher damage for faster kills. s=5 × dur=150 = 750u range.
|
|
20
|
-
*/
|
|
21
|
-
const STANDARD_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Quick attack config for use under pressure (shorter cast time).
|
|
25
|
-
* Same low turn/duration for warmup compatibility.
|
|
26
|
-
*/
|
|
27
|
-
const QUICK_CONFIG = {damage: 10, speed: 5, turnRate: 1, duration: 80};
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Predictive homing missile AI - leads the target.
|
|
31
|
-
*/
|
|
32
|
-
const StormchaserMissileAI: MissileAIFunction = ({
|
|
33
|
-
worldState,
|
|
34
|
-
missileState,
|
|
35
|
-
}) =>
|
|
36
|
-
{
|
|
37
|
-
const enemy = worldState.enemies[0];
|
|
38
|
-
if (!enemy) return {};
|
|
39
|
-
|
|
40
|
-
const angle = interceptAngle(
|
|
41
|
-
missileState.position,
|
|
42
|
-
enemy.position,
|
|
43
|
-
enemy.velocity,
|
|
44
|
-
missileState.speed,
|
|
45
|
-
);
|
|
46
|
-
if (angle !== null)
|
|
47
|
-
{
|
|
48
|
-
return {turnToward: moveInDirection(missileState.position, angle, 100)};
|
|
49
|
-
}
|
|
50
|
-
return {turnToward: enemy.position};
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Check if position is near a wall.
|
|
55
|
-
*/
|
|
56
|
-
function isNearWall(position: {x: number; y: number}): boolean
|
|
57
|
-
{
|
|
58
|
-
return (
|
|
59
|
-
position.x < WALL_BUFFER ||
|
|
60
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
61
|
-
position.y < WALL_BUFFER ||
|
|
62
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Smart combat movement with threat-aware dodging.
|
|
68
|
-
*/
|
|
69
|
-
function combatMove(
|
|
70
|
-
position: {x: number; y: number},
|
|
71
|
-
enemy: {position: {x: number; y: number}},
|
|
72
|
-
currentDistance: number,
|
|
73
|
-
targetDistance: number,
|
|
74
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
75
|
-
tick: number,
|
|
76
|
-
strafeCyclePeriod: number,
|
|
77
|
-
strafeIntensity: number,
|
|
78
|
-
approachSpeed: number,
|
|
79
|
-
distanceDeadZone: number,
|
|
80
|
-
): {x: number; y: number}
|
|
81
|
-
{
|
|
82
|
-
const deltaX = enemy.position.x - position.x;
|
|
83
|
-
const deltaY = enemy.position.y - position.y;
|
|
84
|
-
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
85
|
-
|
|
86
|
-
// Perpendicular directions for strafing
|
|
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
|
-
// Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
|
|
99
|
-
if (dodgeDirection)
|
|
100
|
-
{
|
|
101
|
-
strafeDirection = dodgeDirection;
|
|
102
|
-
}
|
|
103
|
-
// Priority 2: Avoid walls
|
|
104
|
-
else if (isNearWall(position))
|
|
105
|
-
{
|
|
106
|
-
const futureClockwise = {
|
|
107
|
-
x: position.x + strafeClockwise.x * 100,
|
|
108
|
-
y: position.y + strafeClockwise.y * 100,
|
|
109
|
-
};
|
|
110
|
-
const futureCounterClockwise = {
|
|
111
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
112
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
113
|
-
};
|
|
114
|
-
const scoreClockwise = Math.min(
|
|
115
|
-
futureClockwise.x,
|
|
116
|
-
ARENA_SIZE - futureClockwise.x,
|
|
117
|
-
futureClockwise.y,
|
|
118
|
-
ARENA_SIZE - futureClockwise.y,
|
|
119
|
-
);
|
|
120
|
-
const scoreCounterClockwise = Math.min(
|
|
121
|
-
futureCounterClockwise.x,
|
|
122
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
123
|
-
futureCounterClockwise.y,
|
|
124
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
125
|
-
);
|
|
126
|
-
strafeDirection =
|
|
127
|
-
scoreClockwise > scoreCounterClockwise
|
|
128
|
-
? strafeClockwise
|
|
129
|
-
: strafeCounterClockwise;
|
|
130
|
-
}
|
|
131
|
-
// Priority 3: Commit to strafe direction (unpredictability)
|
|
132
|
-
else
|
|
133
|
-
{
|
|
134
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
135
|
-
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// When actively dodging, commit fully — no approach/retreat dilution
|
|
139
|
-
if (dodgeDirection)
|
|
140
|
-
{
|
|
141
|
-
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
let moveX = strafeDirection.x * strafeIntensity;
|
|
145
|
-
let moveY = strafeDirection.y * strafeIntensity;
|
|
146
|
-
|
|
147
|
-
// Distance management (tighter band than other bots, only when not dodging)
|
|
148
|
-
if (currentDistance > targetDistance + distanceDeadZone)
|
|
149
|
-
{
|
|
150
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
151
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
152
|
-
}
|
|
153
|
-
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
154
|
-
{
|
|
155
|
-
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
156
|
-
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
157
|
-
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
158
|
-
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
159
|
-
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
160
|
-
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
161
|
-
moveX += retreatX;
|
|
162
|
-
moveY += retreatY;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Normalize to max speed
|
|
166
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
167
|
-
if (magnitude > 100)
|
|
168
|
-
{
|
|
169
|
-
moveX = (moveX / magnitude) * 100;
|
|
170
|
-
moveY = (moveY / magnitude) * 100;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return {x: moveX, y: moveY};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Bot: Stormchaser
|
|
178
|
-
*
|
|
179
|
-
* BEHAVIOR: Fights aggressively while managing defense intelligently. Uses
|
|
180
|
-
* two fixed missile configs (standard homing + quick attack) with predictive
|
|
181
|
-
* homing missile AI (interceptAngle on the missile itself). Blink-dodges
|
|
182
|
-
* incoming threats, shields when blink is on cooldown. Tight distance
|
|
183
|
-
* management (350 units, ±30 band).
|
|
184
|
-
*
|
|
185
|
-
* PROGRESSION LINE: Stormchaser → Stormcaller → Stormforger
|
|
186
|
-
* - Stormchaser (tier 1): Fixed missiles, predictive homing AI, blink-dodge
|
|
187
|
-
* - Stormcaller (tier 2): + fitMissileToBudget, predictive homing, optimized damage
|
|
188
|
-
* - Stormforger (tier 3): Future — supreme berserker, perfect aggression
|
|
189
|
-
*
|
|
190
|
-
* TIER: 1 (base)
|
|
191
|
-
*/
|
|
192
|
-
export const Stormchaser: WizardFunction = ({state}) =>
|
|
193
|
-
{
|
|
194
|
-
const targetDistance = useParam('targetDistance', 191, {
|
|
195
|
-
range: 150,
|
|
196
|
-
min: 0,
|
|
197
|
-
});
|
|
198
|
-
const healthLowThreshold = useParam('healthLowThreshold', 45, {
|
|
199
|
-
range: 20,
|
|
200
|
-
min: 1,
|
|
201
|
-
steps: 5,
|
|
202
|
-
});
|
|
203
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 130, {
|
|
204
|
-
range: 75,
|
|
205
|
-
min: 80,
|
|
206
|
-
max: 300,
|
|
207
|
-
});
|
|
208
|
-
const strafeIntensity = useParam('strafeIntensity', 42, {
|
|
209
|
-
range: 40,
|
|
210
|
-
min: 20,
|
|
211
|
-
max: 100,
|
|
212
|
-
steps: 7,
|
|
213
|
-
});
|
|
214
|
-
const approachSpeed = useParam('approachSpeed', 68, {
|
|
215
|
-
range: 30,
|
|
216
|
-
min: 10,
|
|
217
|
-
max: 70,
|
|
218
|
-
steps: 7,
|
|
219
|
-
});
|
|
220
|
-
const distanceDeadZone = useParam('distanceDeadZone', 26, {
|
|
221
|
-
range: 25,
|
|
222
|
-
min: 10,
|
|
223
|
-
max: 60,
|
|
224
|
-
steps: 7,
|
|
225
|
-
});
|
|
226
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
227
|
-
range: 75,
|
|
228
|
-
min: 50,
|
|
229
|
-
max: 300,
|
|
230
|
-
});
|
|
231
|
-
const blinkWindowMax = useParam('blinkWindowMax', 25, {
|
|
232
|
-
range: 30,
|
|
233
|
-
min: 15,
|
|
234
|
-
max: 80,
|
|
235
|
-
steps: 7,
|
|
236
|
-
});
|
|
237
|
-
const cancelMinRemainingCast = useParam('cancelMinRemainingCast', 5, {
|
|
238
|
-
range: 50,
|
|
239
|
-
min: 5,
|
|
240
|
-
max: 150,
|
|
241
|
-
steps: 7,
|
|
242
|
-
});
|
|
243
|
-
const attackRange = useParam('attackRange', 676, {
|
|
244
|
-
range: 200,
|
|
245
|
-
min: 300,
|
|
246
|
-
max: 800,
|
|
247
|
-
});
|
|
248
|
-
const healthDamageRatio = useParam('healthDamageRatio', 5, {
|
|
249
|
-
range: 2,
|
|
250
|
-
min: 0.5,
|
|
251
|
-
max: 5,
|
|
252
|
-
steps: 7,
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
const enemy = state.enemies[0];
|
|
256
|
-
if (!enemy)
|
|
257
|
-
{
|
|
258
|
-
return {move: {x: 0, y: 0}};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
262
|
-
|
|
263
|
-
// Analyze threats using simulation
|
|
264
|
-
const threats = getThreats(state);
|
|
265
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
266
|
-
|
|
267
|
-
// === DEFENSE: Handle channeling state ===
|
|
268
|
-
if (state.state === 'channeling')
|
|
269
|
-
{
|
|
270
|
-
// Cancel shield if no imminent danger
|
|
271
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
272
|
-
{
|
|
273
|
-
return {move: {x: 0, y: 0}, cancel: true};
|
|
274
|
-
}
|
|
275
|
-
return {move: {x: 0, y: 0}};
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// === DEFENSE: Cancel missile cast for threats arriving during vulnerability window ===
|
|
279
|
-
if (
|
|
280
|
-
state.state === 'casting' &&
|
|
281
|
-
state.castingSpell === 'missile' &&
|
|
282
|
-
urgentThreat
|
|
283
|
-
)
|
|
284
|
-
{
|
|
285
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
286
|
-
const incomingDamage = urgentThreat.projectile.damage;
|
|
287
|
-
const arrivesInGCD =
|
|
288
|
-
urgentThreat.ticksToImpact > remainingCast &&
|
|
289
|
-
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION;
|
|
290
|
-
|
|
291
|
-
// Cancel for undodgeable threats when trade isn't worth it
|
|
292
|
-
if (!urgentThreat.bestDodgeDirection)
|
|
293
|
-
{
|
|
294
|
-
const canSurviveHit =
|
|
295
|
-
state.health > incomingDamage * healthDamageRatio + 1;
|
|
296
|
-
const tradeWorthIt =
|
|
297
|
-
canSurviveHit && incomingDamage <= QUICK_CONFIG.damage;
|
|
298
|
-
if (
|
|
299
|
-
!tradeWorthIt &&
|
|
300
|
-
remainingCast > cancelMinRemainingCast &&
|
|
301
|
-
urgentThreat.ticksToImpact <=
|
|
302
|
-
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
|
|
303
|
-
)
|
|
304
|
-
{
|
|
305
|
-
const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
|
|
306
|
-
const cancelMove = combatMove(
|
|
307
|
-
state.position,
|
|
308
|
-
enemy,
|
|
309
|
-
distance,
|
|
310
|
-
targetDistance,
|
|
311
|
-
dodgeDir,
|
|
312
|
-
state.tick,
|
|
313
|
-
strafeCyclePeriod,
|
|
314
|
-
strafeIntensity,
|
|
315
|
-
approachSpeed,
|
|
316
|
-
distanceDeadZone,
|
|
317
|
-
);
|
|
318
|
-
return {move: cancelMove, cancel: true};
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Cancel for ANY threat arriving during GCD when health is critical
|
|
323
|
-
// At low health, surviving is more important than finishing the attack.
|
|
324
|
-
if (
|
|
325
|
-
arrivesInGCD &&
|
|
326
|
-
state.health <= incomingDamage + 1 &&
|
|
327
|
-
remainingCast > 10
|
|
328
|
-
)
|
|
329
|
-
{
|
|
330
|
-
const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
|
|
331
|
-
const cancelMove = combatMove(
|
|
332
|
-
state.position,
|
|
333
|
-
enemy,
|
|
334
|
-
distance,
|
|
335
|
-
targetDistance,
|
|
336
|
-
dodgeDir,
|
|
337
|
-
state.tick,
|
|
338
|
-
strafeCyclePeriod,
|
|
339
|
-
strafeIntensity,
|
|
340
|
-
approachSpeed,
|
|
341
|
-
distanceDeadZone,
|
|
342
|
-
);
|
|
343
|
-
return {move: cancelMove, cancel: true};
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// === BLINK: Dodge missile + reposition (first priority) ===
|
|
348
|
-
// Blink dodges the incoming missile — no GCD after, so can immediately fire back.
|
|
349
|
-
// Only shield when blink is on cooldown.
|
|
350
|
-
if (
|
|
351
|
-
state.state === 'idle' &&
|
|
352
|
-
urgentThreat &&
|
|
353
|
-
state.blinkCooldown === 0 &&
|
|
354
|
-
urgentThreat.ticksToImpact >= 10 &&
|
|
355
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
356
|
-
)
|
|
357
|
-
{
|
|
358
|
-
return {
|
|
359
|
-
move: {x: 0, y: 0},
|
|
360
|
-
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// === DEFENSE: Shield threats (when blink is on cooldown) ===
|
|
365
|
-
if (state.state === 'idle' && urgentThreat)
|
|
366
|
-
{
|
|
367
|
-
const forceShield = shouldForceShield(urgentThreat);
|
|
368
|
-
const undodgeable = !urgentThreat.bestDodgeDirection;
|
|
369
|
-
const healthLow = state.health < healthLowThreshold;
|
|
370
|
-
const healthCritical =
|
|
371
|
-
state.health <= urgentThreat.projectile.damage * healthDamageRatio + 1;
|
|
372
|
-
const shouldShield =
|
|
373
|
-
forceShield || (undodgeable && healthLow) || healthCritical;
|
|
374
|
-
|
|
375
|
-
if (shouldShield && urgentThreat.canBlockInTime)
|
|
376
|
-
{
|
|
377
|
-
if (urgentThreat.ticksToStartShield <= 3)
|
|
378
|
-
{
|
|
379
|
-
return {
|
|
380
|
-
move: {x: 0, y: 0},
|
|
381
|
-
startCast: {spell: 'shield'},
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// === MOVEMENT: Smart combat movement ===
|
|
388
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
389
|
-
const move = combatMove(
|
|
390
|
-
state.position,
|
|
391
|
-
enemy,
|
|
392
|
-
distance,
|
|
393
|
-
targetDistance,
|
|
394
|
-
dodgeDirection,
|
|
395
|
-
state.tick,
|
|
396
|
-
strafeCyclePeriod,
|
|
397
|
-
strafeIntensity,
|
|
398
|
-
approachSpeed,
|
|
399
|
-
distanceDeadZone,
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
// === OFFENSE: Attack aggressively while moving ===
|
|
403
|
-
if (state.state === 'idle' && distance < attackRange)
|
|
404
|
-
{
|
|
405
|
-
// Primary: standard homing missile
|
|
406
|
-
const standardCastTime = getMissileCastTime(STANDARD_CONFIG);
|
|
407
|
-
if (isSafeToAttack(threats, standardCastTime))
|
|
408
|
-
{
|
|
409
|
-
return {
|
|
410
|
-
move,
|
|
411
|
-
startCast: {
|
|
412
|
-
spell: 'missile',
|
|
413
|
-
config: STANDARD_CONFIG,
|
|
414
|
-
missileAI: StormchaserMissileAI,
|
|
415
|
-
direction: angleTo(state.position, enemy.position),
|
|
416
|
-
},
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Fallback: quick missile under pressure (shorter cast = smaller vulnerability window)
|
|
421
|
-
const quickCastTime = getMissileCastTime(QUICK_CONFIG);
|
|
422
|
-
if (isSafeToAttack(threats, quickCastTime))
|
|
423
|
-
{
|
|
424
|
-
return {
|
|
425
|
-
move,
|
|
426
|
-
startCast: {
|
|
427
|
-
spell: 'missile',
|
|
428
|
-
config: QUICK_CONFIG,
|
|
429
|
-
missileAI: StormchaserMissileAI,
|
|
430
|
-
direction: angleTo(state.position, enemy.position),
|
|
431
|
-
},
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Not safe to attack — wait for threat to pass, then fire
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// === FALLBACK DEFENSE: Shield when no attack window exists ===
|
|
439
|
-
// If all offense checks failed and there's an undodgeable threat, shield it
|
|
440
|
-
// rather than sitting idle and taking the hit.
|
|
441
|
-
if (
|
|
442
|
-
state.state === 'idle' &&
|
|
443
|
-
urgentThreat &&
|
|
444
|
-
!urgentThreat.bestDodgeDirection &&
|
|
445
|
-
urgentThreat.canBlockInTime &&
|
|
446
|
-
urgentThreat.ticksToStartShield <= 3
|
|
447
|
-
)
|
|
448
|
-
{
|
|
449
|
-
return {
|
|
450
|
-
move: {x: 0, y: 0},
|
|
451
|
-
startCast: {spell: 'shield'},
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Default: just move (dodging/positioning)
|
|
456
|
-
return {move};
|
|
457
|
-
};
|
|
1
|
+
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
+
import {angleTo} from '../../utils/angles.js';
|
|
3
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
+
import {interceptAngle, moveInDirection} from '../../utils/movement.js';
|
|
5
|
+
import {getMissileCastTime} from '../../utils/combat.js';
|
|
6
|
+
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
7
|
+
import {
|
|
8
|
+
getThreats,
|
|
9
|
+
getMostUrgentThreat,
|
|
10
|
+
getDodgeDirectionForMovement,
|
|
11
|
+
isSafeToAttack,
|
|
12
|
+
getBlinkToCenter,
|
|
13
|
+
shouldForceShield,
|
|
14
|
+
} from '../shared.js';
|
|
15
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
16
|
+
const WALL_BUFFER = 80;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Standard homing missile: higher damage for faster kills. s=5 × dur=150 = 750u range.
|
|
20
|
+
*/
|
|
21
|
+
const STANDARD_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Quick attack config for use under pressure (shorter cast time).
|
|
25
|
+
* Same low turn/duration for warmup compatibility.
|
|
26
|
+
*/
|
|
27
|
+
const QUICK_CONFIG = {damage: 10, speed: 5, turnRate: 1, duration: 80};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Predictive homing missile AI - leads the target.
|
|
31
|
+
*/
|
|
32
|
+
const StormchaserMissileAI: MissileAIFunction = ({
|
|
33
|
+
worldState,
|
|
34
|
+
missileState,
|
|
35
|
+
}) =>
|
|
36
|
+
{
|
|
37
|
+
const enemy = worldState.enemies[0];
|
|
38
|
+
if (!enemy) return {};
|
|
39
|
+
|
|
40
|
+
const angle = interceptAngle(
|
|
41
|
+
missileState.position,
|
|
42
|
+
enemy.position,
|
|
43
|
+
enemy.velocity,
|
|
44
|
+
missileState.speed,
|
|
45
|
+
);
|
|
46
|
+
if (angle !== null)
|
|
47
|
+
{
|
|
48
|
+
return {turnToward: moveInDirection(missileState.position, angle, 100)};
|
|
49
|
+
}
|
|
50
|
+
return {turnToward: enemy.position};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if position is near a wall.
|
|
55
|
+
*/
|
|
56
|
+
function isNearWall(position: {x: number; y: number}): boolean
|
|
57
|
+
{
|
|
58
|
+
return (
|
|
59
|
+
position.x < WALL_BUFFER ||
|
|
60
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
61
|
+
position.y < WALL_BUFFER ||
|
|
62
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Smart combat movement with threat-aware dodging.
|
|
68
|
+
*/
|
|
69
|
+
function combatMove(
|
|
70
|
+
position: {x: number; y: number},
|
|
71
|
+
enemy: {position: {x: number; y: number}},
|
|
72
|
+
currentDistance: number,
|
|
73
|
+
targetDistance: number,
|
|
74
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
75
|
+
tick: number,
|
|
76
|
+
strafeCyclePeriod: number,
|
|
77
|
+
strafeIntensity: number,
|
|
78
|
+
approachSpeed: number,
|
|
79
|
+
distanceDeadZone: number,
|
|
80
|
+
): {x: number; y: number}
|
|
81
|
+
{
|
|
82
|
+
const deltaX = enemy.position.x - position.x;
|
|
83
|
+
const deltaY = enemy.position.y - position.y;
|
|
84
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
85
|
+
|
|
86
|
+
// Perpendicular directions for strafing
|
|
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
|
+
// Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
|
|
99
|
+
if (dodgeDirection)
|
|
100
|
+
{
|
|
101
|
+
strafeDirection = dodgeDirection;
|
|
102
|
+
}
|
|
103
|
+
// Priority 2: Avoid walls
|
|
104
|
+
else if (isNearWall(position))
|
|
105
|
+
{
|
|
106
|
+
const futureClockwise = {
|
|
107
|
+
x: position.x + strafeClockwise.x * 100,
|
|
108
|
+
y: position.y + strafeClockwise.y * 100,
|
|
109
|
+
};
|
|
110
|
+
const futureCounterClockwise = {
|
|
111
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
112
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
113
|
+
};
|
|
114
|
+
const scoreClockwise = Math.min(
|
|
115
|
+
futureClockwise.x,
|
|
116
|
+
ARENA_SIZE - futureClockwise.x,
|
|
117
|
+
futureClockwise.y,
|
|
118
|
+
ARENA_SIZE - futureClockwise.y,
|
|
119
|
+
);
|
|
120
|
+
const scoreCounterClockwise = Math.min(
|
|
121
|
+
futureCounterClockwise.x,
|
|
122
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
123
|
+
futureCounterClockwise.y,
|
|
124
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
125
|
+
);
|
|
126
|
+
strafeDirection =
|
|
127
|
+
scoreClockwise > scoreCounterClockwise
|
|
128
|
+
? strafeClockwise
|
|
129
|
+
: strafeCounterClockwise;
|
|
130
|
+
}
|
|
131
|
+
// Priority 3: Commit to strafe direction (unpredictability)
|
|
132
|
+
else
|
|
133
|
+
{
|
|
134
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
135
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// When actively dodging, commit fully — no approach/retreat dilution
|
|
139
|
+
if (dodgeDirection)
|
|
140
|
+
{
|
|
141
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
145
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
146
|
+
|
|
147
|
+
// Distance management (tighter band than other bots, only when not dodging)
|
|
148
|
+
if (currentDistance > targetDistance + distanceDeadZone)
|
|
149
|
+
{
|
|
150
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
151
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
152
|
+
}
|
|
153
|
+
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
154
|
+
{
|
|
155
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
156
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
157
|
+
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
158
|
+
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
159
|
+
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
160
|
+
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
161
|
+
moveX += retreatX;
|
|
162
|
+
moveY += retreatY;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Normalize to max speed
|
|
166
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
167
|
+
if (magnitude > 100)
|
|
168
|
+
{
|
|
169
|
+
moveX = (moveX / magnitude) * 100;
|
|
170
|
+
moveY = (moveY / magnitude) * 100;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {x: moveX, y: moveY};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Bot: Stormchaser
|
|
178
|
+
*
|
|
179
|
+
* BEHAVIOR: Fights aggressively while managing defense intelligently. Uses
|
|
180
|
+
* two fixed missile configs (standard homing + quick attack) with predictive
|
|
181
|
+
* homing missile AI (interceptAngle on the missile itself). Blink-dodges
|
|
182
|
+
* incoming threats, shields when blink is on cooldown. Tight distance
|
|
183
|
+
* management (350 units, ±30 band).
|
|
184
|
+
*
|
|
185
|
+
* PROGRESSION LINE: Stormchaser → Stormcaller → Stormforger
|
|
186
|
+
* - Stormchaser (tier 1): Fixed missiles, predictive homing AI, blink-dodge
|
|
187
|
+
* - Stormcaller (tier 2): + fitMissileToBudget, predictive homing, optimized damage
|
|
188
|
+
* - Stormforger (tier 3): Future — supreme berserker, perfect aggression
|
|
189
|
+
*
|
|
190
|
+
* TIER: 1 (base)
|
|
191
|
+
*/
|
|
192
|
+
export const Stormchaser: WizardFunction = ({state}) =>
|
|
193
|
+
{
|
|
194
|
+
const targetDistance = useParam('targetDistance', 191, {
|
|
195
|
+
range: 150,
|
|
196
|
+
min: 0,
|
|
197
|
+
});
|
|
198
|
+
const healthLowThreshold = useParam('healthLowThreshold', 45, {
|
|
199
|
+
range: 20,
|
|
200
|
+
min: 1,
|
|
201
|
+
steps: 5,
|
|
202
|
+
});
|
|
203
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 130, {
|
|
204
|
+
range: 75,
|
|
205
|
+
min: 80,
|
|
206
|
+
max: 300,
|
|
207
|
+
});
|
|
208
|
+
const strafeIntensity = useParam('strafeIntensity', 42, {
|
|
209
|
+
range: 40,
|
|
210
|
+
min: 20,
|
|
211
|
+
max: 100,
|
|
212
|
+
steps: 7,
|
|
213
|
+
});
|
|
214
|
+
const approachSpeed = useParam('approachSpeed', 68, {
|
|
215
|
+
range: 30,
|
|
216
|
+
min: 10,
|
|
217
|
+
max: 70,
|
|
218
|
+
steps: 7,
|
|
219
|
+
});
|
|
220
|
+
const distanceDeadZone = useParam('distanceDeadZone', 26, {
|
|
221
|
+
range: 25,
|
|
222
|
+
min: 10,
|
|
223
|
+
max: 60,
|
|
224
|
+
steps: 7,
|
|
225
|
+
});
|
|
226
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
227
|
+
range: 75,
|
|
228
|
+
min: 50,
|
|
229
|
+
max: 300,
|
|
230
|
+
});
|
|
231
|
+
const blinkWindowMax = useParam('blinkWindowMax', 25, {
|
|
232
|
+
range: 30,
|
|
233
|
+
min: 15,
|
|
234
|
+
max: 80,
|
|
235
|
+
steps: 7,
|
|
236
|
+
});
|
|
237
|
+
const cancelMinRemainingCast = useParam('cancelMinRemainingCast', 5, {
|
|
238
|
+
range: 50,
|
|
239
|
+
min: 5,
|
|
240
|
+
max: 150,
|
|
241
|
+
steps: 7,
|
|
242
|
+
});
|
|
243
|
+
const attackRange = useParam('attackRange', 676, {
|
|
244
|
+
range: 200,
|
|
245
|
+
min: 300,
|
|
246
|
+
max: 800,
|
|
247
|
+
});
|
|
248
|
+
const healthDamageRatio = useParam('healthDamageRatio', 5, {
|
|
249
|
+
range: 2,
|
|
250
|
+
min: 0.5,
|
|
251
|
+
max: 5,
|
|
252
|
+
steps: 7,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const enemy = state.enemies[0];
|
|
256
|
+
if (!enemy)
|
|
257
|
+
{
|
|
258
|
+
return {move: {x: 0, y: 0}};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
262
|
+
|
|
263
|
+
// Analyze threats using simulation
|
|
264
|
+
const threats = getThreats(state);
|
|
265
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
266
|
+
|
|
267
|
+
// === DEFENSE: Handle channeling state ===
|
|
268
|
+
if (state.state === 'channeling')
|
|
269
|
+
{
|
|
270
|
+
// Cancel shield if no imminent danger
|
|
271
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
272
|
+
{
|
|
273
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
274
|
+
}
|
|
275
|
+
return {move: {x: 0, y: 0}};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// === DEFENSE: Cancel missile cast for threats arriving during vulnerability window ===
|
|
279
|
+
if (
|
|
280
|
+
state.state === 'casting' &&
|
|
281
|
+
state.castingSpell === 'missile' &&
|
|
282
|
+
urgentThreat
|
|
283
|
+
)
|
|
284
|
+
{
|
|
285
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
286
|
+
const incomingDamage = urgentThreat.projectile.damage;
|
|
287
|
+
const arrivesInGCD =
|
|
288
|
+
urgentThreat.ticksToImpact > remainingCast &&
|
|
289
|
+
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION;
|
|
290
|
+
|
|
291
|
+
// Cancel for undodgeable threats when trade isn't worth it
|
|
292
|
+
if (!urgentThreat.bestDodgeDirection)
|
|
293
|
+
{
|
|
294
|
+
const canSurviveHit =
|
|
295
|
+
state.health > incomingDamage * healthDamageRatio + 1;
|
|
296
|
+
const tradeWorthIt =
|
|
297
|
+
canSurviveHit && incomingDamage <= QUICK_CONFIG.damage;
|
|
298
|
+
if (
|
|
299
|
+
!tradeWorthIt &&
|
|
300
|
+
remainingCast > cancelMinRemainingCast &&
|
|
301
|
+
urgentThreat.ticksToImpact <=
|
|
302
|
+
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
|
|
303
|
+
)
|
|
304
|
+
{
|
|
305
|
+
const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
|
|
306
|
+
const cancelMove = combatMove(
|
|
307
|
+
state.position,
|
|
308
|
+
enemy,
|
|
309
|
+
distance,
|
|
310
|
+
targetDistance,
|
|
311
|
+
dodgeDir,
|
|
312
|
+
state.tick,
|
|
313
|
+
strafeCyclePeriod,
|
|
314
|
+
strafeIntensity,
|
|
315
|
+
approachSpeed,
|
|
316
|
+
distanceDeadZone,
|
|
317
|
+
);
|
|
318
|
+
return {move: cancelMove, cancel: true};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Cancel for ANY threat arriving during GCD when health is critical
|
|
323
|
+
// At low health, surviving is more important than finishing the attack.
|
|
324
|
+
if (
|
|
325
|
+
arrivesInGCD &&
|
|
326
|
+
state.health <= incomingDamage + 1 &&
|
|
327
|
+
remainingCast > 10
|
|
328
|
+
)
|
|
329
|
+
{
|
|
330
|
+
const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
|
|
331
|
+
const cancelMove = combatMove(
|
|
332
|
+
state.position,
|
|
333
|
+
enemy,
|
|
334
|
+
distance,
|
|
335
|
+
targetDistance,
|
|
336
|
+
dodgeDir,
|
|
337
|
+
state.tick,
|
|
338
|
+
strafeCyclePeriod,
|
|
339
|
+
strafeIntensity,
|
|
340
|
+
approachSpeed,
|
|
341
|
+
distanceDeadZone,
|
|
342
|
+
);
|
|
343
|
+
return {move: cancelMove, cancel: true};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// === BLINK: Dodge missile + reposition (first priority) ===
|
|
348
|
+
// Blink dodges the incoming missile — no GCD after, so can immediately fire back.
|
|
349
|
+
// Only shield when blink is on cooldown.
|
|
350
|
+
if (
|
|
351
|
+
state.state === 'idle' &&
|
|
352
|
+
urgentThreat &&
|
|
353
|
+
state.blinkCooldown === 0 &&
|
|
354
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
355
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
356
|
+
)
|
|
357
|
+
{
|
|
358
|
+
return {
|
|
359
|
+
move: {x: 0, y: 0},
|
|
360
|
+
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// === DEFENSE: Shield threats (when blink is on cooldown) ===
|
|
365
|
+
if (state.state === 'idle' && urgentThreat)
|
|
366
|
+
{
|
|
367
|
+
const forceShield = shouldForceShield(urgentThreat);
|
|
368
|
+
const undodgeable = !urgentThreat.bestDodgeDirection;
|
|
369
|
+
const healthLow = state.health < healthLowThreshold;
|
|
370
|
+
const healthCritical =
|
|
371
|
+
state.health <= urgentThreat.projectile.damage * healthDamageRatio + 1;
|
|
372
|
+
const shouldShield =
|
|
373
|
+
forceShield || (undodgeable && healthLow) || healthCritical;
|
|
374
|
+
|
|
375
|
+
if (shouldShield && urgentThreat.canBlockInTime)
|
|
376
|
+
{
|
|
377
|
+
if (urgentThreat.ticksToStartShield <= 3)
|
|
378
|
+
{
|
|
379
|
+
return {
|
|
380
|
+
move: {x: 0, y: 0},
|
|
381
|
+
startCast: {spell: 'shield'},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// === MOVEMENT: Smart combat movement ===
|
|
388
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
389
|
+
const move = combatMove(
|
|
390
|
+
state.position,
|
|
391
|
+
enemy,
|
|
392
|
+
distance,
|
|
393
|
+
targetDistance,
|
|
394
|
+
dodgeDirection,
|
|
395
|
+
state.tick,
|
|
396
|
+
strafeCyclePeriod,
|
|
397
|
+
strafeIntensity,
|
|
398
|
+
approachSpeed,
|
|
399
|
+
distanceDeadZone,
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// === OFFENSE: Attack aggressively while moving ===
|
|
403
|
+
if (state.state === 'idle' && distance < attackRange)
|
|
404
|
+
{
|
|
405
|
+
// Primary: standard homing missile
|
|
406
|
+
const standardCastTime = getMissileCastTime(STANDARD_CONFIG);
|
|
407
|
+
if (isSafeToAttack(threats, standardCastTime))
|
|
408
|
+
{
|
|
409
|
+
return {
|
|
410
|
+
move,
|
|
411
|
+
startCast: {
|
|
412
|
+
spell: 'missile',
|
|
413
|
+
config: STANDARD_CONFIG,
|
|
414
|
+
missileAI: StormchaserMissileAI,
|
|
415
|
+
direction: angleTo(state.position, enemy.position),
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Fallback: quick missile under pressure (shorter cast = smaller vulnerability window)
|
|
421
|
+
const quickCastTime = getMissileCastTime(QUICK_CONFIG);
|
|
422
|
+
if (isSafeToAttack(threats, quickCastTime))
|
|
423
|
+
{
|
|
424
|
+
return {
|
|
425
|
+
move,
|
|
426
|
+
startCast: {
|
|
427
|
+
spell: 'missile',
|
|
428
|
+
config: QUICK_CONFIG,
|
|
429
|
+
missileAI: StormchaserMissileAI,
|
|
430
|
+
direction: angleTo(state.position, enemy.position),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Not safe to attack — wait for threat to pass, then fire
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// === FALLBACK DEFENSE: Shield when no attack window exists ===
|
|
439
|
+
// If all offense checks failed and there's an undodgeable threat, shield it
|
|
440
|
+
// rather than sitting idle and taking the hit.
|
|
441
|
+
if (
|
|
442
|
+
state.state === 'idle' &&
|
|
443
|
+
urgentThreat &&
|
|
444
|
+
!urgentThreat.bestDodgeDirection &&
|
|
445
|
+
urgentThreat.canBlockInTime &&
|
|
446
|
+
urgentThreat.ticksToStartShield <= 3
|
|
447
|
+
)
|
|
448
|
+
{
|
|
449
|
+
return {
|
|
450
|
+
move: {x: 0, y: 0},
|
|
451
|
+
startCast: {spell: 'shield'},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Default: just move (dodging/positioning)
|
|
456
|
+
return {move};
|
|
457
|
+
};
|