@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,546 +1,546 @@
|
|
|
1
|
-
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
-
import {angleTo} from '../../utils/angles.js';
|
|
3
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
-
import {
|
|
5
|
-
ARENA_SIZE,
|
|
6
|
-
GCD_DURATION,
|
|
7
|
-
SHIELD_CAST_TIME,
|
|
8
|
-
BLINK_CAST_TIME,
|
|
9
|
-
} from '../../rules.js';
|
|
10
|
-
import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
|
|
11
|
-
import {
|
|
12
|
-
getThreats,
|
|
13
|
-
getMostUrgentThreat,
|
|
14
|
-
getDodgeDirectionForMovement,
|
|
15
|
-
getBlinkToCenter,
|
|
16
|
-
getBlinkToIncreaseDistance,
|
|
17
|
-
fitMissileToBudget,
|
|
18
|
-
shouldForceShield,
|
|
19
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
20
|
-
MAX_CAST_BUDGET,
|
|
21
|
-
MIN_USEFUL_DAMAGE,
|
|
22
|
-
getClosingRate,
|
|
23
|
-
getEnemyVulnerabilityTicks,
|
|
24
|
-
isSafeToAttack,
|
|
25
|
-
} from '../shared.js';
|
|
26
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
27
|
-
|
|
28
|
-
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
29
|
-
{
|
|
30
|
-
const enemy = worldState.enemies[0];
|
|
31
|
-
return {turnToward: enemy?.position};
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const StraightAI: MissileAIFunction = () => ({});
|
|
35
|
-
|
|
36
|
-
const WALL_BUFFER = 50;
|
|
37
|
-
const SHIELD_BUFFER = 3;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Smart combat movement: dodge, avoid walls, maintain sniper range.
|
|
41
|
-
*/
|
|
42
|
-
function combatMove(
|
|
43
|
-
position: {x: number; y: number},
|
|
44
|
-
enemy: {position: {x: number; y: number}},
|
|
45
|
-
currentDistance: number,
|
|
46
|
-
minDistance: number,
|
|
47
|
-
maxDistance: number,
|
|
48
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
49
|
-
tick: number,
|
|
50
|
-
strafeCyclePeriod: number,
|
|
51
|
-
strafeIntensity: number,
|
|
52
|
-
approachSpeed: number,
|
|
53
|
-
): {x: number; y: number}
|
|
54
|
-
{
|
|
55
|
-
const deltaX = enemy.position.x - position.x;
|
|
56
|
-
const deltaY = enemy.position.y - position.y;
|
|
57
|
-
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
58
|
-
|
|
59
|
-
const strafeClockwise = {
|
|
60
|
-
x: -deltaY / normalizedDistance,
|
|
61
|
-
y: deltaX / normalizedDistance,
|
|
62
|
-
};
|
|
63
|
-
const strafeCounterClockwise = {
|
|
64
|
-
x: deltaY / normalizedDistance,
|
|
65
|
-
y: -deltaX / normalizedDistance,
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
let strafeDirection: {x: number; y: number};
|
|
69
|
-
|
|
70
|
-
if (dodgeDirection)
|
|
71
|
-
{
|
|
72
|
-
strafeDirection = dodgeDirection;
|
|
73
|
-
}
|
|
74
|
-
else if (
|
|
75
|
-
position.x < WALL_BUFFER ||
|
|
76
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
77
|
-
position.y < WALL_BUFFER ||
|
|
78
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
79
|
-
)
|
|
80
|
-
{
|
|
81
|
-
const futureClockwise = {
|
|
82
|
-
x: position.x + strafeClockwise.x * 100,
|
|
83
|
-
y: position.y + strafeClockwise.y * 100,
|
|
84
|
-
};
|
|
85
|
-
const futureCounterClockwise = {
|
|
86
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
87
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
88
|
-
};
|
|
89
|
-
const scoreClockwise = Math.min(
|
|
90
|
-
futureClockwise.x,
|
|
91
|
-
ARENA_SIZE - futureClockwise.x,
|
|
92
|
-
futureClockwise.y,
|
|
93
|
-
ARENA_SIZE - futureClockwise.y,
|
|
94
|
-
);
|
|
95
|
-
const scoreCounterClockwise = Math.min(
|
|
96
|
-
futureCounterClockwise.x,
|
|
97
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
98
|
-
futureCounterClockwise.y,
|
|
99
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
100
|
-
);
|
|
101
|
-
strafeDirection =
|
|
102
|
-
scoreClockwise > scoreCounterClockwise
|
|
103
|
-
? strafeClockwise
|
|
104
|
-
: strafeCounterClockwise;
|
|
105
|
-
}
|
|
106
|
-
else
|
|
107
|
-
{
|
|
108
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
109
|
-
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (dodgeDirection)
|
|
113
|
-
{
|
|
114
|
-
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
let moveX = strafeDirection.x * strafeIntensity;
|
|
118
|
-
let moveY = strafeDirection.y * strafeIntensity;
|
|
119
|
-
|
|
120
|
-
if (currentDistance > maxDistance)
|
|
121
|
-
{
|
|
122
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
123
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
124
|
-
}
|
|
125
|
-
else if (currentDistance < minDistance)
|
|
126
|
-
{
|
|
127
|
-
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
128
|
-
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
129
|
-
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
130
|
-
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
131
|
-
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
132
|
-
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
133
|
-
moveX += retreatX;
|
|
134
|
-
moveY += retreatY;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
138
|
-
if (magnitude > 100)
|
|
139
|
-
{
|
|
140
|
-
moveX = (moveX / magnitude) * 100;
|
|
141
|
-
moveY = (moveY / magnitude) * 100;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return {x: moveX, y: moveY};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Bot: Spellseeker
|
|
149
|
-
*
|
|
150
|
-
* BEHAVIOR: Elite sniper that uses intercept-aimed straight missiles during vulnerability
|
|
151
|
-
* windows. Combines Spelltracer's adaptive fitting with precise lead-position aiming
|
|
152
|
-
* and vulnerability exploitation. Straight punish missiles at sniper range are nearly
|
|
153
|
-
* unavoidable. Proactive distance control via closing rate detection.
|
|
154
|
-
*
|
|
155
|
-
* KEY IMPROVEMENTS OVER SPELLTRACER:
|
|
156
|
-
* - Intercept-aimed punish: getLeadPosition + straight missiles during vulnerability
|
|
157
|
-
* - Proactive distance blink: monitors closing rate, blinks before danger zone
|
|
158
|
-
* - Progressive cast-cancel: graduated damage thresholds
|
|
159
|
-
* - Warmup exploitation: always passes lastMissileConfig
|
|
160
|
-
*
|
|
161
|
-
* PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
|
|
162
|
-
* TIER: 3 (elite Sniper line)
|
|
163
|
-
*/
|
|
164
|
-
export const Spellseeker: WizardFunction = ({state}) =>
|
|
165
|
-
{
|
|
166
|
-
const dangerDistance = useParam('dangerDistance', 65, {
|
|
167
|
-
range: 175,
|
|
168
|
-
min: 0,
|
|
169
|
-
});
|
|
170
|
-
const targetDistance = useParam('targetDistance', 289, {
|
|
171
|
-
range: 200,
|
|
172
|
-
min: 0,
|
|
173
|
-
});
|
|
174
|
-
const distanceDeadZone = useParam('distanceDeadZone', 22, {
|
|
175
|
-
range: 50,
|
|
176
|
-
min: 0,
|
|
177
|
-
});
|
|
178
|
-
const rangeMin = targetDistance - distanceDeadZone;
|
|
179
|
-
const rangeMax = targetDistance + distanceDeadZone;
|
|
180
|
-
const minTurnRate = useParam('minTurnRate', 3.5, {range: 1.5, steps: 5});
|
|
181
|
-
const closingRateThreshold = useParam('closingRateThreshold', 1.79, {
|
|
182
|
-
range: 0.65,
|
|
183
|
-
min: 0,
|
|
184
|
-
steps: 5,
|
|
185
|
-
});
|
|
186
|
-
const punishMinVulnerability = useParam('punishMinVulnerability', 279, {
|
|
187
|
-
range: 30,
|
|
188
|
-
min: 10,
|
|
189
|
-
steps: 5,
|
|
190
|
-
});
|
|
191
|
-
const preferBlinkDodge =
|
|
192
|
-
useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
|
|
193
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
|
|
194
|
-
range: 75,
|
|
195
|
-
min: 80,
|
|
196
|
-
max: 250,
|
|
197
|
-
});
|
|
198
|
-
const strafeIntensity = useParam('strafeIntensity', 87, {
|
|
199
|
-
range: 40,
|
|
200
|
-
min: 20,
|
|
201
|
-
max: 100,
|
|
202
|
-
steps: 7,
|
|
203
|
-
});
|
|
204
|
-
const approachSpeed = useParam('approachSpeed', 80, {
|
|
205
|
-
range: 30,
|
|
206
|
-
min: 10,
|
|
207
|
-
max: 80,
|
|
208
|
-
steps: 7,
|
|
209
|
-
});
|
|
210
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
|
|
211
|
-
range: 75,
|
|
212
|
-
min: 50,
|
|
213
|
-
max: 300,
|
|
214
|
-
});
|
|
215
|
-
const blinkWindowMax = useParam('blinkWindowMax', 88, {
|
|
216
|
-
range: 40,
|
|
217
|
-
min: 20,
|
|
218
|
-
max: 120,
|
|
219
|
-
steps: 7,
|
|
220
|
-
});
|
|
221
|
-
const proactiveBlinkRange = useParam('proactiveBlinkRange', 433, {
|
|
222
|
-
range: 150,
|
|
223
|
-
min: 100,
|
|
224
|
-
max: 500,
|
|
225
|
-
steps: 7,
|
|
226
|
-
});
|
|
227
|
-
const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
|
|
228
|
-
range: 4,
|
|
229
|
-
min: 2,
|
|
230
|
-
max: 12,
|
|
231
|
-
steps: 5,
|
|
232
|
-
});
|
|
233
|
-
const castingFlightTimeDivisor = useParam('castingFlightTimeDivisor', 12, {
|
|
234
|
-
range: 4,
|
|
235
|
-
min: 3,
|
|
236
|
-
max: 12,
|
|
237
|
-
steps: 5,
|
|
238
|
-
});
|
|
239
|
-
const enemyResponseEstimate = useParam('enemyResponseEstimate', 168, {
|
|
240
|
-
range: 100,
|
|
241
|
-
min: 50,
|
|
242
|
-
max: 300,
|
|
243
|
-
steps: 7,
|
|
244
|
-
});
|
|
245
|
-
const threatFallbackThreshold = useParam('threatFallbackThreshold', 250, {
|
|
246
|
-
range: 100,
|
|
247
|
-
min: 100,
|
|
248
|
-
max: 400,
|
|
249
|
-
steps: 7,
|
|
250
|
-
});
|
|
251
|
-
const punishAttackRange = useParam('punishAttackRange', 213, {
|
|
252
|
-
range: 150,
|
|
253
|
-
min: 200,
|
|
254
|
-
max: 700,
|
|
255
|
-
steps: 7,
|
|
256
|
-
});
|
|
257
|
-
const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
|
|
258
|
-
range: 25,
|
|
259
|
-
min: 10,
|
|
260
|
-
max: 80,
|
|
261
|
-
steps: 5,
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
const enemy = state.enemies[0];
|
|
265
|
-
if (!enemy)
|
|
266
|
-
{
|
|
267
|
-
return {move: {x: 0, y: 0}};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
271
|
-
const threats = getThreats(state);
|
|
272
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
273
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
274
|
-
const move = combatMove(
|
|
275
|
-
state.position,
|
|
276
|
-
enemy,
|
|
277
|
-
distance,
|
|
278
|
-
rangeMin,
|
|
279
|
-
rangeMax,
|
|
280
|
-
dodgeDirection,
|
|
281
|
-
state.tick,
|
|
282
|
-
strafeCyclePeriod,
|
|
283
|
-
strafeIntensity,
|
|
284
|
-
approachSpeed,
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
// === STEP 1: CHANNELING — cancel shield when safe ===
|
|
288
|
-
if (state.state === 'channeling')
|
|
289
|
-
{
|
|
290
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
291
|
-
{
|
|
292
|
-
return {move, cancel: true};
|
|
293
|
-
}
|
|
294
|
-
return {move: {x: 0, y: 0}};
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// === STEP 2: PROGRESSIVE CAST-CANCEL ===
|
|
298
|
-
if (
|
|
299
|
-
state.state === 'casting' &&
|
|
300
|
-
state.castingSpell === 'missile' &&
|
|
301
|
-
urgentThreat &&
|
|
302
|
-
!urgentThreat.bestDodgeDirection
|
|
303
|
-
)
|
|
304
|
-
{
|
|
305
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
306
|
-
const defenseTime =
|
|
307
|
-
state.blinkCooldown === 0
|
|
308
|
-
? BLINK_CAST_TIME
|
|
309
|
-
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
310
|
-
const willArriveInWindow =
|
|
311
|
-
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
|
|
312
|
-
|
|
313
|
-
if (willArriveInWindow)
|
|
314
|
-
{
|
|
315
|
-
const incomingDamage = urgentThreat.projectile.damage;
|
|
316
|
-
const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
|
|
317
|
-
|
|
318
|
-
if (incomingDamage >= state.health || incomingDamage >= 20)
|
|
319
|
-
{
|
|
320
|
-
return {move, cancel: true};
|
|
321
|
-
}
|
|
322
|
-
if (incomingDamage >= 10 && castRatio < 0.8)
|
|
323
|
-
{
|
|
324
|
-
return {move, cancel: true};
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
|
|
330
|
-
if (
|
|
331
|
-
preferBlinkDodge &&
|
|
332
|
-
state.state === 'idle' &&
|
|
333
|
-
urgentThreat &&
|
|
334
|
-
state.blinkCooldown === 0 &&
|
|
335
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
336
|
-
urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
|
|
337
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
338
|
-
)
|
|
339
|
-
{
|
|
340
|
-
const midRange = targetDistance;
|
|
341
|
-
const target =
|
|
342
|
-
distance < midRange
|
|
343
|
-
? (getBlinkToIncreaseDistance(
|
|
344
|
-
state.position,
|
|
345
|
-
enemy.position,
|
|
346
|
-
state.projectiles,
|
|
347
|
-
) ?? getBlinkToCenter(state.position))
|
|
348
|
-
: getBlinkToCenter(state.position);
|
|
349
|
-
return {
|
|
350
|
-
move: {x: 0, y: 0},
|
|
351
|
-
startCast: {spell: 'blink', target},
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// === STEP 4: SHIELD — undodgeable or critical threats ===
|
|
356
|
-
if (
|
|
357
|
-
state.state === 'idle' &&
|
|
358
|
-
urgentThreat &&
|
|
359
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
360
|
-
)
|
|
361
|
-
{
|
|
362
|
-
if (
|
|
363
|
-
urgentThreat.canBlockInTime &&
|
|
364
|
-
urgentThreat.ticksToStartShield <= SHIELD_BUFFER
|
|
365
|
-
)
|
|
366
|
-
{
|
|
367
|
-
return {
|
|
368
|
-
move: {x: 0, y: 0},
|
|
369
|
-
startCast: {spell: 'shield'},
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// === STEP 5: PROACTIVE DISTANCE BLINK ===
|
|
375
|
-
if (
|
|
376
|
-
state.state === 'idle' &&
|
|
377
|
-
state.blinkCooldown === 0 &&
|
|
378
|
-
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
379
|
-
)
|
|
380
|
-
{
|
|
381
|
-
const closingRate = getClosingRate(
|
|
382
|
-
state.position,
|
|
383
|
-
enemy.position,
|
|
384
|
-
enemy.velocity,
|
|
385
|
-
);
|
|
386
|
-
if (
|
|
387
|
-
(closingRate > closingRateThreshold && distance < proactiveBlinkRange) ||
|
|
388
|
-
distance < dangerDistance
|
|
389
|
-
)
|
|
390
|
-
{
|
|
391
|
-
const blinkTarget = getBlinkToIncreaseDistance(
|
|
392
|
-
state.position,
|
|
393
|
-
enemy.position,
|
|
394
|
-
state.projectiles,
|
|
395
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
396
|
-
);
|
|
397
|
-
if (blinkTarget)
|
|
398
|
-
{
|
|
399
|
-
return {
|
|
400
|
-
move: {x: 0, y: 0},
|
|
401
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// === STEP 6: OFFENSE ===
|
|
408
|
-
if (state.state === 'idle' && distance < 600)
|
|
409
|
-
{
|
|
410
|
-
const offenseDefenseTime =
|
|
411
|
-
state.blinkCooldown === 0
|
|
412
|
-
? BLINK_CAST_TIME
|
|
413
|
-
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
414
|
-
const enemyHP = Math.ceil(enemy.health);
|
|
415
|
-
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
416
|
-
|
|
417
|
-
// --- PUNISH MODE: intercept-aimed straight missiles during vulnerability ---
|
|
418
|
-
if (
|
|
419
|
-
vulnerabilityTicks >= punishMinVulnerability &&
|
|
420
|
-
distance < punishAttackRange
|
|
421
|
-
)
|
|
422
|
-
{
|
|
423
|
-
const flightTimeEstimate = distance / flightTimeDivisor;
|
|
424
|
-
const punishBudget = vulnerabilityTicks - flightTimeEstimate;
|
|
425
|
-
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
426
|
-
const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
|
|
427
|
-
const effectiveBudget = Math.min(
|
|
428
|
-
punishBudget,
|
|
429
|
-
safeBudget,
|
|
430
|
-
MAX_CAST_BUDGET,
|
|
431
|
-
);
|
|
432
|
-
|
|
433
|
-
if (effectiveBudget > 0)
|
|
434
|
-
{
|
|
435
|
-
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
436
|
-
minTurnRate: 0,
|
|
437
|
-
maxDamage: enemyHP,
|
|
438
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
442
|
-
{
|
|
443
|
-
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
444
|
-
const actualFlight = distance / config.speed;
|
|
445
|
-
|
|
446
|
-
if (
|
|
447
|
-
castTime + actualFlight < vulnerabilityTicks &&
|
|
448
|
-
isSafeToAttack(threats, castTime)
|
|
449
|
-
)
|
|
450
|
-
{
|
|
451
|
-
const aimTarget =
|
|
452
|
-
config.turnRate > 0
|
|
453
|
-
? enemy.position
|
|
454
|
-
: getLeadPosition(
|
|
455
|
-
enemy.position,
|
|
456
|
-
enemy.velocity,
|
|
457
|
-
config.speed,
|
|
458
|
-
state.position,
|
|
459
|
-
);
|
|
460
|
-
return {
|
|
461
|
-
move,
|
|
462
|
-
startCast: {
|
|
463
|
-
spell: 'missile',
|
|
464
|
-
config,
|
|
465
|
-
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
466
|
-
direction: angleTo(state.position, aimTarget),
|
|
467
|
-
},
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// --- STANDARD MODE: adaptive homing missiles ---
|
|
475
|
-
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
476
|
-
|
|
477
|
-
// Anticipate enemy's next missile based on their current state
|
|
478
|
-
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
479
|
-
{
|
|
480
|
-
const remainingEnemyCast =
|
|
481
|
-
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
482
|
-
nextThreatTime = Math.min(
|
|
483
|
-
nextThreatTime,
|
|
484
|
-
remainingEnemyCast + distance / castingFlightTimeDivisor,
|
|
485
|
-
);
|
|
486
|
-
}
|
|
487
|
-
// No imminent threat — estimate enemy's likely response time
|
|
488
|
-
if (nextThreatTime > threatFallbackThreshold)
|
|
489
|
-
{
|
|
490
|
-
const enemyLockTime = getEnemyVulnerabilityTicks(enemy);
|
|
491
|
-
nextThreatTime = Math.min(
|
|
492
|
-
nextThreatTime,
|
|
493
|
-
enemyLockTime +
|
|
494
|
-
enemyResponseEstimate +
|
|
495
|
-
distance / castingFlightTimeDivisor,
|
|
496
|
-
);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const safeBudget =
|
|
500
|
-
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
501
|
-
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
502
|
-
|
|
503
|
-
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
504
|
-
minTurnRate,
|
|
505
|
-
maxDamage: enemyHP,
|
|
506
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
// HIGH-DAMAGE PRIORITY: Sniper missiles need meaningful shield chip.
|
|
510
|
-
// Fixed big-shot fallback ensures Spellseeker always does significant damage.
|
|
511
|
-
const BIG_SHOT = {damage: 28, speed: 10, turnRate: 0.5, duration: 80};
|
|
512
|
-
|
|
513
|
-
if (
|
|
514
|
-
config &&
|
|
515
|
-
config.damage >= 15 &&
|
|
516
|
-
isSafeToAttack(threats, safeAttackCastEstimate)
|
|
517
|
-
)
|
|
518
|
-
{
|
|
519
|
-
return {
|
|
520
|
-
move,
|
|
521
|
-
startCast: {
|
|
522
|
-
spell: 'missile',
|
|
523
|
-
config,
|
|
524
|
-
missileAI: HomingAI,
|
|
525
|
-
direction: angleTo(state.position, enemy.position),
|
|
526
|
-
},
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// Adaptive fitting weak or failed — fire big shot for meaningful damage
|
|
531
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > 50)
|
|
532
|
-
{
|
|
533
|
-
return {
|
|
534
|
-
move,
|
|
535
|
-
startCast: {
|
|
536
|
-
spell: 'missile',
|
|
537
|
-
config: BIG_SHOT,
|
|
538
|
-
missileAI: HomingAI,
|
|
539
|
-
direction: angleTo(state.position, enemy.position),
|
|
540
|
-
},
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
return {move};
|
|
546
|
-
};
|
|
1
|
+
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
+
import {angleTo} from '../../utils/angles.js';
|
|
3
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
+
import {
|
|
5
|
+
ARENA_SIZE,
|
|
6
|
+
GCD_DURATION,
|
|
7
|
+
SHIELD_CAST_TIME,
|
|
8
|
+
BLINK_CAST_TIME,
|
|
9
|
+
} from '../../rules.js';
|
|
10
|
+
import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
|
|
11
|
+
import {
|
|
12
|
+
getThreats,
|
|
13
|
+
getMostUrgentThreat,
|
|
14
|
+
getDodgeDirectionForMovement,
|
|
15
|
+
getBlinkToCenter,
|
|
16
|
+
getBlinkToIncreaseDistance,
|
|
17
|
+
fitMissileToBudget,
|
|
18
|
+
shouldForceShield,
|
|
19
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
20
|
+
MAX_CAST_BUDGET,
|
|
21
|
+
MIN_USEFUL_DAMAGE,
|
|
22
|
+
getClosingRate,
|
|
23
|
+
getEnemyVulnerabilityTicks,
|
|
24
|
+
isSafeToAttack,
|
|
25
|
+
} from '../shared.js';
|
|
26
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
27
|
+
|
|
28
|
+
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
29
|
+
{
|
|
30
|
+
const enemy = worldState.enemies[0];
|
|
31
|
+
return {turnToward: enemy?.position};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const StraightAI: MissileAIFunction = () => ({});
|
|
35
|
+
|
|
36
|
+
const WALL_BUFFER = 50;
|
|
37
|
+
const SHIELD_BUFFER = 3;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Smart combat movement: dodge, avoid walls, maintain sniper range.
|
|
41
|
+
*/
|
|
42
|
+
function combatMove(
|
|
43
|
+
position: {x: number; y: number},
|
|
44
|
+
enemy: {position: {x: number; y: number}},
|
|
45
|
+
currentDistance: number,
|
|
46
|
+
minDistance: number,
|
|
47
|
+
maxDistance: number,
|
|
48
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
49
|
+
tick: number,
|
|
50
|
+
strafeCyclePeriod: number,
|
|
51
|
+
strafeIntensity: number,
|
|
52
|
+
approachSpeed: number,
|
|
53
|
+
): {x: number; y: number}
|
|
54
|
+
{
|
|
55
|
+
const deltaX = enemy.position.x - position.x;
|
|
56
|
+
const deltaY = enemy.position.y - position.y;
|
|
57
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
58
|
+
|
|
59
|
+
const strafeClockwise = {
|
|
60
|
+
x: -deltaY / normalizedDistance,
|
|
61
|
+
y: deltaX / normalizedDistance,
|
|
62
|
+
};
|
|
63
|
+
const strafeCounterClockwise = {
|
|
64
|
+
x: deltaY / normalizedDistance,
|
|
65
|
+
y: -deltaX / normalizedDistance,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let strafeDirection: {x: number; y: number};
|
|
69
|
+
|
|
70
|
+
if (dodgeDirection)
|
|
71
|
+
{
|
|
72
|
+
strafeDirection = dodgeDirection;
|
|
73
|
+
}
|
|
74
|
+
else if (
|
|
75
|
+
position.x < WALL_BUFFER ||
|
|
76
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
77
|
+
position.y < WALL_BUFFER ||
|
|
78
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
79
|
+
)
|
|
80
|
+
{
|
|
81
|
+
const futureClockwise = {
|
|
82
|
+
x: position.x + strafeClockwise.x * 100,
|
|
83
|
+
y: position.y + strafeClockwise.y * 100,
|
|
84
|
+
};
|
|
85
|
+
const futureCounterClockwise = {
|
|
86
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
87
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
88
|
+
};
|
|
89
|
+
const scoreClockwise = Math.min(
|
|
90
|
+
futureClockwise.x,
|
|
91
|
+
ARENA_SIZE - futureClockwise.x,
|
|
92
|
+
futureClockwise.y,
|
|
93
|
+
ARENA_SIZE - futureClockwise.y,
|
|
94
|
+
);
|
|
95
|
+
const scoreCounterClockwise = Math.min(
|
|
96
|
+
futureCounterClockwise.x,
|
|
97
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
98
|
+
futureCounterClockwise.y,
|
|
99
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
100
|
+
);
|
|
101
|
+
strafeDirection =
|
|
102
|
+
scoreClockwise > scoreCounterClockwise
|
|
103
|
+
? strafeClockwise
|
|
104
|
+
: strafeCounterClockwise;
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
{
|
|
108
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
109
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (dodgeDirection)
|
|
113
|
+
{
|
|
114
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
118
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
119
|
+
|
|
120
|
+
if (currentDistance > maxDistance)
|
|
121
|
+
{
|
|
122
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
123
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
124
|
+
}
|
|
125
|
+
else if (currentDistance < minDistance)
|
|
126
|
+
{
|
|
127
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
128
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
129
|
+
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
130
|
+
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
131
|
+
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
132
|
+
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
133
|
+
moveX += retreatX;
|
|
134
|
+
moveY += retreatY;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
138
|
+
if (magnitude > 100)
|
|
139
|
+
{
|
|
140
|
+
moveX = (moveX / magnitude) * 100;
|
|
141
|
+
moveY = (moveY / magnitude) * 100;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {x: moveX, y: moveY};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Bot: Spellseeker
|
|
149
|
+
*
|
|
150
|
+
* BEHAVIOR: Elite sniper that uses intercept-aimed straight missiles during vulnerability
|
|
151
|
+
* windows. Combines Spelltracer's adaptive fitting with precise lead-position aiming
|
|
152
|
+
* and vulnerability exploitation. Straight punish missiles at sniper range are nearly
|
|
153
|
+
* unavoidable. Proactive distance control via closing rate detection.
|
|
154
|
+
*
|
|
155
|
+
* KEY IMPROVEMENTS OVER SPELLTRACER:
|
|
156
|
+
* - Intercept-aimed punish: getLeadPosition + straight missiles during vulnerability
|
|
157
|
+
* - Proactive distance blink: monitors closing rate, blinks before danger zone
|
|
158
|
+
* - Progressive cast-cancel: graduated damage thresholds
|
|
159
|
+
* - Warmup exploitation: always passes lastMissileConfig
|
|
160
|
+
*
|
|
161
|
+
* PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
|
|
162
|
+
* TIER: 3 (elite Sniper line)
|
|
163
|
+
*/
|
|
164
|
+
export const Spellseeker: WizardFunction = ({state}) =>
|
|
165
|
+
{
|
|
166
|
+
const dangerDistance = useParam('dangerDistance', 65, {
|
|
167
|
+
range: 175,
|
|
168
|
+
min: 0,
|
|
169
|
+
});
|
|
170
|
+
const targetDistance = useParam('targetDistance', 289, {
|
|
171
|
+
range: 200,
|
|
172
|
+
min: 0,
|
|
173
|
+
});
|
|
174
|
+
const distanceDeadZone = useParam('distanceDeadZone', 22, {
|
|
175
|
+
range: 50,
|
|
176
|
+
min: 0,
|
|
177
|
+
});
|
|
178
|
+
const rangeMin = targetDistance - distanceDeadZone;
|
|
179
|
+
const rangeMax = targetDistance + distanceDeadZone;
|
|
180
|
+
const minTurnRate = useParam('minTurnRate', 3.5, {range: 1.5, steps: 5});
|
|
181
|
+
const closingRateThreshold = useParam('closingRateThreshold', 1.79, {
|
|
182
|
+
range: 0.65,
|
|
183
|
+
min: 0,
|
|
184
|
+
steps: 5,
|
|
185
|
+
});
|
|
186
|
+
const punishMinVulnerability = useParam('punishMinVulnerability', 279, {
|
|
187
|
+
range: 30,
|
|
188
|
+
min: 10,
|
|
189
|
+
steps: 5,
|
|
190
|
+
});
|
|
191
|
+
const preferBlinkDodge =
|
|
192
|
+
useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
|
|
193
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
|
|
194
|
+
range: 75,
|
|
195
|
+
min: 80,
|
|
196
|
+
max: 250,
|
|
197
|
+
});
|
|
198
|
+
const strafeIntensity = useParam('strafeIntensity', 87, {
|
|
199
|
+
range: 40,
|
|
200
|
+
min: 20,
|
|
201
|
+
max: 100,
|
|
202
|
+
steps: 7,
|
|
203
|
+
});
|
|
204
|
+
const approachSpeed = useParam('approachSpeed', 80, {
|
|
205
|
+
range: 30,
|
|
206
|
+
min: 10,
|
|
207
|
+
max: 80,
|
|
208
|
+
steps: 7,
|
|
209
|
+
});
|
|
210
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
|
|
211
|
+
range: 75,
|
|
212
|
+
min: 50,
|
|
213
|
+
max: 300,
|
|
214
|
+
});
|
|
215
|
+
const blinkWindowMax = useParam('blinkWindowMax', 88, {
|
|
216
|
+
range: 40,
|
|
217
|
+
min: 20,
|
|
218
|
+
max: 120,
|
|
219
|
+
steps: 7,
|
|
220
|
+
});
|
|
221
|
+
const proactiveBlinkRange = useParam('proactiveBlinkRange', 433, {
|
|
222
|
+
range: 150,
|
|
223
|
+
min: 100,
|
|
224
|
+
max: 500,
|
|
225
|
+
steps: 7,
|
|
226
|
+
});
|
|
227
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
|
|
228
|
+
range: 4,
|
|
229
|
+
min: 2,
|
|
230
|
+
max: 12,
|
|
231
|
+
steps: 5,
|
|
232
|
+
});
|
|
233
|
+
const castingFlightTimeDivisor = useParam('castingFlightTimeDivisor', 12, {
|
|
234
|
+
range: 4,
|
|
235
|
+
min: 3,
|
|
236
|
+
max: 12,
|
|
237
|
+
steps: 5,
|
|
238
|
+
});
|
|
239
|
+
const enemyResponseEstimate = useParam('enemyResponseEstimate', 168, {
|
|
240
|
+
range: 100,
|
|
241
|
+
min: 50,
|
|
242
|
+
max: 300,
|
|
243
|
+
steps: 7,
|
|
244
|
+
});
|
|
245
|
+
const threatFallbackThreshold = useParam('threatFallbackThreshold', 250, {
|
|
246
|
+
range: 100,
|
|
247
|
+
min: 100,
|
|
248
|
+
max: 400,
|
|
249
|
+
steps: 7,
|
|
250
|
+
});
|
|
251
|
+
const punishAttackRange = useParam('punishAttackRange', 213, {
|
|
252
|
+
range: 150,
|
|
253
|
+
min: 200,
|
|
254
|
+
max: 700,
|
|
255
|
+
steps: 7,
|
|
256
|
+
});
|
|
257
|
+
const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
|
|
258
|
+
range: 25,
|
|
259
|
+
min: 10,
|
|
260
|
+
max: 80,
|
|
261
|
+
steps: 5,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const enemy = state.enemies[0];
|
|
265
|
+
if (!enemy)
|
|
266
|
+
{
|
|
267
|
+
return {move: {x: 0, y: 0}};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
271
|
+
const threats = getThreats(state);
|
|
272
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
273
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
274
|
+
const move = combatMove(
|
|
275
|
+
state.position,
|
|
276
|
+
enemy,
|
|
277
|
+
distance,
|
|
278
|
+
rangeMin,
|
|
279
|
+
rangeMax,
|
|
280
|
+
dodgeDirection,
|
|
281
|
+
state.tick,
|
|
282
|
+
strafeCyclePeriod,
|
|
283
|
+
strafeIntensity,
|
|
284
|
+
approachSpeed,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// === STEP 1: CHANNELING — cancel shield when safe ===
|
|
288
|
+
if (state.state === 'channeling')
|
|
289
|
+
{
|
|
290
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
291
|
+
{
|
|
292
|
+
return {move, cancel: true};
|
|
293
|
+
}
|
|
294
|
+
return {move: {x: 0, y: 0}};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// === STEP 2: PROGRESSIVE CAST-CANCEL ===
|
|
298
|
+
if (
|
|
299
|
+
state.state === 'casting' &&
|
|
300
|
+
state.castingSpell === 'missile' &&
|
|
301
|
+
urgentThreat &&
|
|
302
|
+
!urgentThreat.bestDodgeDirection
|
|
303
|
+
)
|
|
304
|
+
{
|
|
305
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
306
|
+
const defenseTime =
|
|
307
|
+
state.blinkCooldown === 0
|
|
308
|
+
? BLINK_CAST_TIME
|
|
309
|
+
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
310
|
+
const willArriveInWindow =
|
|
311
|
+
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
|
|
312
|
+
|
|
313
|
+
if (willArriveInWindow)
|
|
314
|
+
{
|
|
315
|
+
const incomingDamage = urgentThreat.projectile.damage;
|
|
316
|
+
const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
|
|
317
|
+
|
|
318
|
+
if (incomingDamage >= state.health || incomingDamage >= 20)
|
|
319
|
+
{
|
|
320
|
+
return {move, cancel: true};
|
|
321
|
+
}
|
|
322
|
+
if (incomingDamage >= 10 && castRatio < 0.8)
|
|
323
|
+
{
|
|
324
|
+
return {move, cancel: true};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
|
|
330
|
+
if (
|
|
331
|
+
preferBlinkDodge &&
|
|
332
|
+
state.state === 'idle' &&
|
|
333
|
+
urgentThreat &&
|
|
334
|
+
state.blinkCooldown === 0 &&
|
|
335
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
336
|
+
urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
|
|
337
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
338
|
+
)
|
|
339
|
+
{
|
|
340
|
+
const midRange = targetDistance;
|
|
341
|
+
const target =
|
|
342
|
+
distance < midRange
|
|
343
|
+
? (getBlinkToIncreaseDistance(
|
|
344
|
+
state.position,
|
|
345
|
+
enemy.position,
|
|
346
|
+
state.projectiles,
|
|
347
|
+
) ?? getBlinkToCenter(state.position))
|
|
348
|
+
: getBlinkToCenter(state.position);
|
|
349
|
+
return {
|
|
350
|
+
move: {x: 0, y: 0},
|
|
351
|
+
startCast: {spell: 'blink', target},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// === STEP 4: SHIELD — undodgeable or critical threats ===
|
|
356
|
+
if (
|
|
357
|
+
state.state === 'idle' &&
|
|
358
|
+
urgentThreat &&
|
|
359
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
360
|
+
)
|
|
361
|
+
{
|
|
362
|
+
if (
|
|
363
|
+
urgentThreat.canBlockInTime &&
|
|
364
|
+
urgentThreat.ticksToStartShield <= SHIELD_BUFFER
|
|
365
|
+
)
|
|
366
|
+
{
|
|
367
|
+
return {
|
|
368
|
+
move: {x: 0, y: 0},
|
|
369
|
+
startCast: {spell: 'shield'},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// === STEP 5: PROACTIVE DISTANCE BLINK ===
|
|
375
|
+
if (
|
|
376
|
+
state.state === 'idle' &&
|
|
377
|
+
state.blinkCooldown === 0 &&
|
|
378
|
+
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
379
|
+
)
|
|
380
|
+
{
|
|
381
|
+
const closingRate = getClosingRate(
|
|
382
|
+
state.position,
|
|
383
|
+
enemy.position,
|
|
384
|
+
enemy.velocity,
|
|
385
|
+
);
|
|
386
|
+
if (
|
|
387
|
+
(closingRate > closingRateThreshold && distance < proactiveBlinkRange) ||
|
|
388
|
+
distance < dangerDistance
|
|
389
|
+
)
|
|
390
|
+
{
|
|
391
|
+
const blinkTarget = getBlinkToIncreaseDistance(
|
|
392
|
+
state.position,
|
|
393
|
+
enemy.position,
|
|
394
|
+
state.projectiles,
|
|
395
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
396
|
+
);
|
|
397
|
+
if (blinkTarget)
|
|
398
|
+
{
|
|
399
|
+
return {
|
|
400
|
+
move: {x: 0, y: 0},
|
|
401
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// === STEP 6: OFFENSE ===
|
|
408
|
+
if (state.state === 'idle' && distance < 600)
|
|
409
|
+
{
|
|
410
|
+
const offenseDefenseTime =
|
|
411
|
+
state.blinkCooldown === 0
|
|
412
|
+
? BLINK_CAST_TIME
|
|
413
|
+
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
414
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
415
|
+
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
416
|
+
|
|
417
|
+
// --- PUNISH MODE: intercept-aimed straight missiles during vulnerability ---
|
|
418
|
+
if (
|
|
419
|
+
vulnerabilityTicks >= punishMinVulnerability &&
|
|
420
|
+
distance < punishAttackRange
|
|
421
|
+
)
|
|
422
|
+
{
|
|
423
|
+
const flightTimeEstimate = distance / flightTimeDivisor;
|
|
424
|
+
const punishBudget = vulnerabilityTicks - flightTimeEstimate;
|
|
425
|
+
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
426
|
+
const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
|
|
427
|
+
const effectiveBudget = Math.min(
|
|
428
|
+
punishBudget,
|
|
429
|
+
safeBudget,
|
|
430
|
+
MAX_CAST_BUDGET,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
if (effectiveBudget > 0)
|
|
434
|
+
{
|
|
435
|
+
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
436
|
+
minTurnRate: 0,
|
|
437
|
+
maxDamage: enemyHP,
|
|
438
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
442
|
+
{
|
|
443
|
+
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
444
|
+
const actualFlight = distance / config.speed;
|
|
445
|
+
|
|
446
|
+
if (
|
|
447
|
+
castTime + actualFlight < vulnerabilityTicks &&
|
|
448
|
+
isSafeToAttack(threats, castTime)
|
|
449
|
+
)
|
|
450
|
+
{
|
|
451
|
+
const aimTarget =
|
|
452
|
+
config.turnRate > 0
|
|
453
|
+
? enemy.position
|
|
454
|
+
: getLeadPosition(
|
|
455
|
+
enemy.position,
|
|
456
|
+
enemy.velocity,
|
|
457
|
+
config.speed,
|
|
458
|
+
state.position,
|
|
459
|
+
);
|
|
460
|
+
return {
|
|
461
|
+
move,
|
|
462
|
+
startCast: {
|
|
463
|
+
spell: 'missile',
|
|
464
|
+
config,
|
|
465
|
+
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
466
|
+
direction: angleTo(state.position, aimTarget),
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --- STANDARD MODE: adaptive homing missiles ---
|
|
475
|
+
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
476
|
+
|
|
477
|
+
// Anticipate enemy's next missile based on their current state
|
|
478
|
+
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
479
|
+
{
|
|
480
|
+
const remainingEnemyCast =
|
|
481
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
482
|
+
nextThreatTime = Math.min(
|
|
483
|
+
nextThreatTime,
|
|
484
|
+
remainingEnemyCast + distance / castingFlightTimeDivisor,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
// No imminent threat — estimate enemy's likely response time
|
|
488
|
+
if (nextThreatTime > threatFallbackThreshold)
|
|
489
|
+
{
|
|
490
|
+
const enemyLockTime = getEnemyVulnerabilityTicks(enemy);
|
|
491
|
+
nextThreatTime = Math.min(
|
|
492
|
+
nextThreatTime,
|
|
493
|
+
enemyLockTime +
|
|
494
|
+
enemyResponseEstimate +
|
|
495
|
+
distance / castingFlightTimeDivisor,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const safeBudget =
|
|
500
|
+
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
501
|
+
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
502
|
+
|
|
503
|
+
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
504
|
+
minTurnRate,
|
|
505
|
+
maxDamage: enemyHP,
|
|
506
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// HIGH-DAMAGE PRIORITY: Sniper missiles need meaningful shield chip.
|
|
510
|
+
// Fixed big-shot fallback ensures Spellseeker always does significant damage.
|
|
511
|
+
const BIG_SHOT = {damage: 28, speed: 10, turnRate: 0.5, duration: 80};
|
|
512
|
+
|
|
513
|
+
if (
|
|
514
|
+
config &&
|
|
515
|
+
config.damage >= 15 &&
|
|
516
|
+
isSafeToAttack(threats, safeAttackCastEstimate)
|
|
517
|
+
)
|
|
518
|
+
{
|
|
519
|
+
return {
|
|
520
|
+
move,
|
|
521
|
+
startCast: {
|
|
522
|
+
spell: 'missile',
|
|
523
|
+
config,
|
|
524
|
+
missileAI: HomingAI,
|
|
525
|
+
direction: angleTo(state.position, enemy.position),
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Adaptive fitting weak or failed — fire big shot for meaningful damage
|
|
531
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > 50)
|
|
532
|
+
{
|
|
533
|
+
return {
|
|
534
|
+
move,
|
|
535
|
+
startCast: {
|
|
536
|
+
spell: 'missile',
|
|
537
|
+
config: BIG_SHOT,
|
|
538
|
+
missileAI: HomingAI,
|
|
539
|
+
direction: angleTo(state.position, enemy.position),
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return {move};
|
|
546
|
+
};
|