@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,582 +1,582 @@
|
|
|
1
|
-
import {WizardFunction} from '../../types.js';
|
|
2
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
3
|
-
import {angleTo} from '../../utils/angles.js';
|
|
4
|
-
import {
|
|
5
|
-
ARENA_SIZE,
|
|
6
|
-
GCD_DURATION,
|
|
7
|
-
SHIELD_CAST_TIME,
|
|
8
|
-
BLINK_CAST_TIME,
|
|
9
|
-
} from '../../rules.js';
|
|
10
|
-
import {
|
|
11
|
-
getThreats,
|
|
12
|
-
getMostUrgentThreat,
|
|
13
|
-
getDodgeDirectionForMovement,
|
|
14
|
-
getBlinkToDecreaseDistance,
|
|
15
|
-
shouldForceShield,
|
|
16
|
-
fitMissileToBudget,
|
|
17
|
-
MIN_USEFUL_DAMAGE,
|
|
18
|
-
getEnemyVulnerabilityTicks,
|
|
19
|
-
} from '../shared.js';
|
|
20
|
-
import {getMissileCastTime} from '../../utils/combat.js';
|
|
21
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
22
|
-
|
|
23
|
-
const WALL_BUFFER = 50;
|
|
24
|
-
const SHIELD_BUFFER = 3;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Aggressive movement: dodge missiles, close distance, light strafe.
|
|
28
|
-
*/
|
|
29
|
-
function aggressiveMove(
|
|
30
|
-
position: {x: number; y: number},
|
|
31
|
-
enemy: {position: {x: number; y: number}},
|
|
32
|
-
currentDistance: number,
|
|
33
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
34
|
-
tick: number,
|
|
35
|
-
strafeCyclePeriod: number,
|
|
36
|
-
strafeIntensity: number,
|
|
37
|
-
approachSpeed: number,
|
|
38
|
-
): {x: number; y: number}
|
|
39
|
-
{
|
|
40
|
-
const deltaX = enemy.position.x - position.x;
|
|
41
|
-
const deltaY = enemy.position.y - position.y;
|
|
42
|
-
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
43
|
-
|
|
44
|
-
const strafeClockwise = {
|
|
45
|
-
x: -deltaY / normalizedDistance,
|
|
46
|
-
y: deltaX / normalizedDistance,
|
|
47
|
-
};
|
|
48
|
-
const strafeCounterClockwise = {
|
|
49
|
-
x: deltaY / normalizedDistance,
|
|
50
|
-
y: -deltaX / normalizedDistance,
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
let strafeDirection: {x: number; y: number};
|
|
54
|
-
|
|
55
|
-
if (dodgeDirection)
|
|
56
|
-
{
|
|
57
|
-
strafeDirection = dodgeDirection;
|
|
58
|
-
}
|
|
59
|
-
else if (
|
|
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
|
-
const futureClockwise = {
|
|
67
|
-
x: position.x + strafeClockwise.x * 100,
|
|
68
|
-
y: position.y + strafeClockwise.y * 100,
|
|
69
|
-
};
|
|
70
|
-
const futureCounterClockwise = {
|
|
71
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
72
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
73
|
-
};
|
|
74
|
-
const scoreClockwise = Math.min(
|
|
75
|
-
futureClockwise.x,
|
|
76
|
-
ARENA_SIZE - futureClockwise.x,
|
|
77
|
-
futureClockwise.y,
|
|
78
|
-
ARENA_SIZE - futureClockwise.y,
|
|
79
|
-
);
|
|
80
|
-
const scoreCounterClockwise = Math.min(
|
|
81
|
-
futureCounterClockwise.x,
|
|
82
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
83
|
-
futureCounterClockwise.y,
|
|
84
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
85
|
-
);
|
|
86
|
-
strafeDirection =
|
|
87
|
-
scoreClockwise > scoreCounterClockwise
|
|
88
|
-
? strafeClockwise
|
|
89
|
-
: strafeCounterClockwise;
|
|
90
|
-
}
|
|
91
|
-
else
|
|
92
|
-
{
|
|
93
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
94
|
-
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (dodgeDirection)
|
|
98
|
-
{
|
|
99
|
-
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
let moveX = strafeDirection.x * strafeIntensity;
|
|
103
|
-
let moveY = strafeDirection.y * strafeIntensity;
|
|
104
|
-
|
|
105
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
106
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
107
|
-
|
|
108
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
109
|
-
if (magnitude > 100)
|
|
110
|
-
{
|
|
111
|
-
moveX = (moveX / magnitude) * 100;
|
|
112
|
-
moveY = (moveY / magnitude) * 100;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return {x: moveX, y: moveY};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Bot: Voidblade
|
|
120
|
-
*
|
|
121
|
-
* BEHAVIOR: Reactive counter-puncher. Shields everything, then fires quick stabs
|
|
122
|
-
* during enemy vulnerability windows (GCD/casting) when they can't shield back.
|
|
123
|
-
* At melee range, the shield blocks ~90% of incoming damage while Voidblade's
|
|
124
|
-
* counter-stabs land at full damage — winning through attrition.
|
|
125
|
-
*
|
|
126
|
-
* CORE LOOP (melee range):
|
|
127
|
-
* 1. Enemy casts missile → Voidblade blink-dodges (100% avoid) or shields (90% block)
|
|
128
|
-
* 2. Enemy enters GCD → Voidblade fires quick stab (lands unblocked)
|
|
129
|
-
* 3. Voidblade enters GCD → enemy recovers → repeat
|
|
130
|
-
*
|
|
131
|
-
* KEY IMPROVEMENTS OVER NIGHTBLADE:
|
|
132
|
-
* - Blink-dodge priority: avoids 100% of damage when blink available, shields as fallback
|
|
133
|
-
* - Reads enemy vulnerability to time counter-stabs perfectly
|
|
134
|
-
* - Cancel-into-defense: aborts own cast if enemy missile incoming
|
|
135
|
-
* - Punish budget: sizes stabs to fit exactly in the vulnerability window
|
|
136
|
-
*
|
|
137
|
-
* PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
|
|
138
|
-
* TIER: 3 (elite Melee line)
|
|
139
|
-
*/
|
|
140
|
-
export const Voidblade: WizardFunction = ({state}) =>
|
|
141
|
-
{
|
|
142
|
-
const preemptiveShieldRange = useParam('preemptiveShieldRange', 348, {
|
|
143
|
-
range: 85,
|
|
144
|
-
min: 50,
|
|
145
|
-
steps: 7,
|
|
146
|
-
});
|
|
147
|
-
const shieldWindow = useParam('shieldWindow', 18, {
|
|
148
|
-
range: 10,
|
|
149
|
-
min: 5,
|
|
150
|
-
max: 30,
|
|
151
|
-
steps: 5,
|
|
152
|
-
});
|
|
153
|
-
const punishMinVulnerability = useParam('punishMinVulnerability', 10, {
|
|
154
|
-
range: 15,
|
|
155
|
-
min: 10,
|
|
156
|
-
steps: 5,
|
|
157
|
-
});
|
|
158
|
-
const preferBlinkDodge =
|
|
159
|
-
useParam('preferBlinkDodge', 0, {min: 0, max: 1, steps: 2}) >= 0.5;
|
|
160
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
|
|
161
|
-
range: 75,
|
|
162
|
-
min: 80,
|
|
163
|
-
max: 250,
|
|
164
|
-
});
|
|
165
|
-
const strafeIntensity = useParam('strafeIntensity', 45, {
|
|
166
|
-
range: 30,
|
|
167
|
-
min: 10,
|
|
168
|
-
max: 80,
|
|
169
|
-
steps: 7,
|
|
170
|
-
});
|
|
171
|
-
const approachSpeed = useParam('approachSpeed', 92, {
|
|
172
|
-
range: 20,
|
|
173
|
-
min: 50,
|
|
174
|
-
max: 100,
|
|
175
|
-
steps: 7,
|
|
176
|
-
});
|
|
177
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
178
|
-
range: 75,
|
|
179
|
-
min: 50,
|
|
180
|
-
max: 300,
|
|
181
|
-
});
|
|
182
|
-
const blinkWindowMax = useParam('blinkWindowMax', 40, {
|
|
183
|
-
range: 30,
|
|
184
|
-
min: 15,
|
|
185
|
-
max: 80,
|
|
186
|
-
steps: 7,
|
|
187
|
-
});
|
|
188
|
-
const blinkPastMargin = useParam('blinkPastMargin', 50, {
|
|
189
|
-
range: 30,
|
|
190
|
-
min: 20,
|
|
191
|
-
max: 100,
|
|
192
|
-
steps: 7,
|
|
193
|
-
});
|
|
194
|
-
const blinkPastDistance = useParam('blinkPastDistance', 200, {
|
|
195
|
-
range: 100,
|
|
196
|
-
min: 50,
|
|
197
|
-
max: 350,
|
|
198
|
-
steps: 7,
|
|
199
|
-
});
|
|
200
|
-
const engageBlinkRange = useParam('engageBlinkRange', 30, {
|
|
201
|
-
range: 75,
|
|
202
|
-
min: 30,
|
|
203
|
-
max: 200,
|
|
204
|
-
steps: 7,
|
|
205
|
-
});
|
|
206
|
-
const counterStabRange = useParam('counterStabRange', 27, {
|
|
207
|
-
range: 20,
|
|
208
|
-
min: 10,
|
|
209
|
-
max: 60,
|
|
210
|
-
steps: 7,
|
|
211
|
-
});
|
|
212
|
-
const flightTimeDivisor = useParam('flightTimeDivisor', 7.5, {
|
|
213
|
-
range: 4,
|
|
214
|
-
min: 3,
|
|
215
|
-
max: 12,
|
|
216
|
-
steps: 5,
|
|
217
|
-
});
|
|
218
|
-
const enemyFlightTimeDivisor = useParam('enemyFlightTimeDivisor', 10, {
|
|
219
|
-
range: 4,
|
|
220
|
-
min: 2,
|
|
221
|
-
max: 10,
|
|
222
|
-
steps: 5,
|
|
223
|
-
});
|
|
224
|
-
const idleProvokeBudget = useParam('idleProvokeBudget', 37, {
|
|
225
|
-
range: 40,
|
|
226
|
-
min: 20,
|
|
227
|
-
max: 120,
|
|
228
|
-
steps: 7,
|
|
229
|
-
});
|
|
230
|
-
const meleeAbortRange = useParam('meleeAbortRange', 50, {
|
|
231
|
-
range: 50,
|
|
232
|
-
min: 50,
|
|
233
|
-
max: 200,
|
|
234
|
-
steps: 7,
|
|
235
|
-
});
|
|
236
|
-
const enemyFiringSoonThreshold = useParam('enemyFiringSoonThreshold', 50, {
|
|
237
|
-
range: 30,
|
|
238
|
-
min: 10,
|
|
239
|
-
max: 100,
|
|
240
|
-
steps: 7,
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
const enemy = state.enemies[0];
|
|
244
|
-
if (!enemy)
|
|
245
|
-
{
|
|
246
|
-
return {move: {x: 0, y: 0}};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
250
|
-
const threats = getThreats(state);
|
|
251
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
252
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
253
|
-
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
254
|
-
|
|
255
|
-
const move = aggressiveMove(
|
|
256
|
-
state.position,
|
|
257
|
-
enemy,
|
|
258
|
-
distance,
|
|
259
|
-
dodgeDirection,
|
|
260
|
-
state.tick,
|
|
261
|
-
strafeCyclePeriod,
|
|
262
|
-
strafeIntensity,
|
|
263
|
-
approachSpeed,
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
// Precompute blink targets — used by blink-dodge and engage-blink.
|
|
267
|
-
// blinkCloseTarget: from getBlinkToDecreaseDistance (dodge + close gap). Null at melee range.
|
|
268
|
-
// blinkPastTarget: blink ~200u past enemy (dodge only, loses position). For in-flight missiles only.
|
|
269
|
-
let blinkCloseTarget: {x: number; y: number} | null = null;
|
|
270
|
-
let blinkPastTarget: {x: number; y: number} | null = null;
|
|
271
|
-
if (state.blinkCooldown === 0)
|
|
272
|
-
{
|
|
273
|
-
blinkCloseTarget = getBlinkToDecreaseDistance(
|
|
274
|
-
state.position,
|
|
275
|
-
enemy.position,
|
|
276
|
-
state.projectiles,
|
|
277
|
-
);
|
|
278
|
-
if (!blinkCloseTarget)
|
|
279
|
-
{
|
|
280
|
-
const dx = enemy.position.x - state.position.x;
|
|
281
|
-
const dy = enemy.position.y - state.position.y;
|
|
282
|
-
const norm = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
283
|
-
blinkPastTarget = {
|
|
284
|
-
x: Math.max(
|
|
285
|
-
blinkPastMargin,
|
|
286
|
-
Math.min(
|
|
287
|
-
ARENA_SIZE - blinkPastMargin,
|
|
288
|
-
state.position.x + (dx / norm) * blinkPastDistance,
|
|
289
|
-
),
|
|
290
|
-
),
|
|
291
|
-
y: Math.max(
|
|
292
|
-
blinkPastMargin,
|
|
293
|
-
Math.min(
|
|
294
|
-
ARENA_SIZE - blinkPastMargin,
|
|
295
|
-
state.position.y + (dy / norm) * blinkPastDistance,
|
|
296
|
-
),
|
|
297
|
-
),
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// === STEP 1: CHANNELING — hold shield until missile passes ===
|
|
303
|
-
if (state.state === 'channeling')
|
|
304
|
-
{
|
|
305
|
-
// Hold shield while there's an active threat OR enemy is about to fire
|
|
306
|
-
const enemyFiringSoon =
|
|
307
|
-
distance < preemptiveShieldRange &&
|
|
308
|
-
enemy.state === 'casting' &&
|
|
309
|
-
enemy.castingSpell === 'missile' &&
|
|
310
|
-
(enemy.castDuration ?? 0) -
|
|
311
|
-
(enemy.castProgress ?? 0) +
|
|
312
|
-
distance / enemyFlightTimeDivisor <
|
|
313
|
-
enemyFiringSoonThreshold;
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
(!urgentThreat && !enemyFiringSoon) ||
|
|
317
|
-
(urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
318
|
-
)
|
|
319
|
-
{
|
|
320
|
-
return {move, cancel: true};
|
|
321
|
-
}
|
|
322
|
-
return {move: {x: 0, y: 0}};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// === STEP 2: CAST-CANCEL INTO DEFENSE ===
|
|
326
|
-
// If we're casting a stab and a missile is incoming, cancel so we can
|
|
327
|
-
// blink-dodge (if available) or shield. Defense time depends on blink availability.
|
|
328
|
-
if (state.state === 'casting' && state.castingSpell === 'missile')
|
|
329
|
-
{
|
|
330
|
-
// Cancel if target escaped melee range
|
|
331
|
-
if (distance > meleeAbortRange)
|
|
332
|
-
{
|
|
333
|
-
return {move, cancel: true};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Defense time: blink (10t) is faster than shield (20t + 3t buffer)
|
|
337
|
-
const defenseTime =
|
|
338
|
-
state.blinkCooldown === 0
|
|
339
|
-
? BLINK_CAST_TIME
|
|
340
|
-
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
341
|
-
|
|
342
|
-
// Cancel for any undodgeable incoming threat we can't finish casting before
|
|
343
|
-
if (urgentThreat && !urgentThreat.bestDodgeDirection)
|
|
344
|
-
{
|
|
345
|
-
const remainingCast =
|
|
346
|
-
(state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
347
|
-
const willArriveInWindow =
|
|
348
|
-
urgentThreat.ticksToImpact <=
|
|
349
|
-
remainingCast + GCD_DURATION + defenseTime;
|
|
350
|
-
if (willArriveInWindow)
|
|
351
|
-
{
|
|
352
|
-
return {move, cancel: true};
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Cancel when enemy starts casting a missile at close range —
|
|
357
|
-
// at melee range their missile will arrive too fast to react after our cast finishes
|
|
358
|
-
if (
|
|
359
|
-
distance < preemptiveShieldRange &&
|
|
360
|
-
enemy.state === 'casting' &&
|
|
361
|
-
enemy.castingSpell === 'missile'
|
|
362
|
-
)
|
|
363
|
-
{
|
|
364
|
-
const remainingEnemyCast =
|
|
365
|
-
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
366
|
-
const remainingOurCast =
|
|
367
|
-
(state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
368
|
-
const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
|
|
369
|
-
const ticksUntilEnemyHit = remainingEnemyCast + estimatedFlightTicks;
|
|
370
|
-
const ticksAfterOurCast = remainingOurCast + GCD_DURATION + defenseTime;
|
|
371
|
-
|
|
372
|
-
// If enemy missile will arrive before we can defend after our cast, cancel now
|
|
373
|
-
if (ticksUntilEnemyHit < ticksAfterOurCast)
|
|
374
|
-
{
|
|
375
|
-
return {move, cancel: true};
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// === STEP 3: BLINK-DODGE — 100% damage avoidance (preferred over shield) ===
|
|
381
|
-
if (state.state === 'idle' && state.blinkCooldown === 0)
|
|
382
|
-
{
|
|
383
|
-
// Blink target for dodging: close-gap target if available, else blink-past for in-flight only
|
|
384
|
-
const dodgeBlinkTarget = blinkCloseTarget ?? blinkPastTarget;
|
|
385
|
-
|
|
386
|
-
// 3a: Blink-dodge an in-flight missile (missile already fired, can't be cancelled)
|
|
387
|
-
if (
|
|
388
|
-
preferBlinkDodge &&
|
|
389
|
-
dodgeBlinkTarget &&
|
|
390
|
-
urgentThreat &&
|
|
391
|
-
urgentThreat.ticksToImpact >= 10 &&
|
|
392
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
393
|
-
)
|
|
394
|
-
{
|
|
395
|
-
return {
|
|
396
|
-
move: {x: 0, y: 0},
|
|
397
|
-
startCast: {spell: 'blink', target: dodgeBlinkTarget},
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// 3b: Preemptive blink-dodge — only when we have a close-gap target.
|
|
402
|
-
// At melee range, preemptive blink loses positioning (200u walk back) and
|
|
403
|
-
// the enemy can cancel their cast. Shield is better for preemptive defense.
|
|
404
|
-
if (
|
|
405
|
-
preferBlinkDodge &&
|
|
406
|
-
blinkCloseTarget &&
|
|
407
|
-
distance < preemptiveShieldRange &&
|
|
408
|
-
enemy.state === 'casting' &&
|
|
409
|
-
enemy.castingSpell === 'missile'
|
|
410
|
-
)
|
|
411
|
-
{
|
|
412
|
-
const remainingCast =
|
|
413
|
-
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
414
|
-
const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
|
|
415
|
-
const ticksUntilHit = remainingCast + estimatedFlightTicks;
|
|
416
|
-
|
|
417
|
-
if (
|
|
418
|
-
ticksUntilHit >= BLINK_CAST_TIME &&
|
|
419
|
-
ticksUntilHit < BLINK_CAST_TIME + shieldWindow
|
|
420
|
-
)
|
|
421
|
-
{
|
|
422
|
-
return {
|
|
423
|
-
move: {x: 0, y: 0},
|
|
424
|
-
startCast: {spell: 'blink', target: blinkCloseTarget},
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// 3c: Engage blink — close distance during enemy vulnerability (they can't react)
|
|
430
|
-
if (
|
|
431
|
-
blinkCloseTarget &&
|
|
432
|
-
distance > engageBlinkRange &&
|
|
433
|
-
vulnerabilityTicks >= punishMinVulnerability &&
|
|
434
|
-
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
435
|
-
)
|
|
436
|
-
{
|
|
437
|
-
return {
|
|
438
|
-
move: {x: 0, y: 0},
|
|
439
|
-
startCast: {spell: 'blink', target: blinkCloseTarget},
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// === STEP 4: SHIELD — fallback when blink can't help (90% block) ===
|
|
445
|
-
|
|
446
|
-
// 4a: Shield in-flight missiles
|
|
447
|
-
// Shield when: no blink target at all, or missile too close for blink (<10 ticks)
|
|
448
|
-
if (
|
|
449
|
-
state.state === 'idle' &&
|
|
450
|
-
urgentThreat &&
|
|
451
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
452
|
-
urgentThreat.canBlockInTime &&
|
|
453
|
-
((!blinkCloseTarget && !blinkPastTarget) || urgentThreat.ticksToImpact < 10)
|
|
454
|
-
)
|
|
455
|
-
{
|
|
456
|
-
return {
|
|
457
|
-
move: {x: 0, y: 0},
|
|
458
|
-
startCast: {spell: 'shield'},
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// 4b: Preemptive shield — when blink can't close gap (melee range or cooldown)
|
|
463
|
-
if (
|
|
464
|
-
state.state === 'idle' &&
|
|
465
|
-
!blinkCloseTarget &&
|
|
466
|
-
distance < preemptiveShieldRange &&
|
|
467
|
-
enemy.state === 'casting' &&
|
|
468
|
-
enemy.castingSpell === 'missile'
|
|
469
|
-
)
|
|
470
|
-
{
|
|
471
|
-
const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
472
|
-
const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
|
|
473
|
-
const ticksUntilHit = remainingCast + estimatedFlightTicks;
|
|
474
|
-
|
|
475
|
-
if (
|
|
476
|
-
ticksUntilHit >= SHIELD_CAST_TIME &&
|
|
477
|
-
ticksUntilHit < SHIELD_CAST_TIME + shieldWindow
|
|
478
|
-
)
|
|
479
|
-
{
|
|
480
|
-
return {
|
|
481
|
-
move: {x: 0, y: 0},
|
|
482
|
-
startCast: {spell: 'shield'},
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// === STEP 5: COUNTER-STAB — fire during enemy vulnerability ===
|
|
488
|
-
// Only fire when enemy CAN'T shield (in GCD or mid-cast).
|
|
489
|
-
// Use small, fast stabs that fit within the vulnerability window.
|
|
490
|
-
if (state.state === 'idle' && distance < counterStabRange)
|
|
491
|
-
{
|
|
492
|
-
const enemyHP = Math.ceil(enemy.health);
|
|
493
|
-
const flightTime = Math.ceil(distance / flightTimeDivisor) + 1;
|
|
494
|
-
|
|
495
|
-
if (vulnerabilityTicks >= punishMinVulnerability)
|
|
496
|
-
{
|
|
497
|
-
// Budget: stab must land while enemy is still vulnerable
|
|
498
|
-
// castTime + flightTime < vulnerabilityTicks
|
|
499
|
-
const punishBudget = vulnerabilityTicks - flightTime;
|
|
500
|
-
|
|
501
|
-
// Also need time to shield if another missile is incoming
|
|
502
|
-
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
503
|
-
|
|
504
|
-
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
505
|
-
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
506
|
-
{
|
|
507
|
-
const remainingEnemyCast =
|
|
508
|
-
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
509
|
-
const estimatedFlightTime = distance / enemyFlightTimeDivisor;
|
|
510
|
-
nextThreatTime = Math.min(
|
|
511
|
-
nextThreatTime,
|
|
512
|
-
remainingEnemyCast + estimatedFlightTime,
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Defense time: blink (10t) is faster than shield (20t + 3t buffer)
|
|
517
|
-
const defenseTime =
|
|
518
|
-
state.blinkCooldown === 0
|
|
519
|
-
? BLINK_CAST_TIME
|
|
520
|
-
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
521
|
-
const safeBudget = nextThreatTime - GCD_DURATION - defenseTime;
|
|
522
|
-
const effectiveBudget = Math.min(punishBudget, safeBudget);
|
|
523
|
-
|
|
524
|
-
if (effectiveBudget > 0)
|
|
525
|
-
{
|
|
526
|
-
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
527
|
-
minTurnRate: 0,
|
|
528
|
-
maxDamage: enemyHP,
|
|
529
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
533
|
-
{
|
|
534
|
-
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
535
|
-
if (castTime + flightTime <= vulnerabilityTicks)
|
|
536
|
-
{
|
|
537
|
-
return {
|
|
538
|
-
move,
|
|
539
|
-
startCast: {
|
|
540
|
-
spell: 'missile',
|
|
541
|
-
config,
|
|
542
|
-
missileAI: () => ({}),
|
|
543
|
-
direction: angleTo(state.position, enemy.position),
|
|
544
|
-
},
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// Enemy is idle or channeling shield at close range — fire a quick stab to provoke.
|
|
552
|
-
// Idle: they must shield or eat damage.
|
|
553
|
-
// Channeling: shield blocks ~90% but 10% still lands. Stabs whittle HP so they
|
|
554
|
-
// can't just hold shield forever. Why: otherwise the bot waits for a
|
|
555
|
-
// vulnerability window that a channeling player never opens.
|
|
556
|
-
if ((enemy.state === 'idle' || enemy.state === 'channeling') && !urgentThreat)
|
|
557
|
-
{
|
|
558
|
-
// Use a small budget to keep stabs fast and stay reactive
|
|
559
|
-
const quickBudget = idleProvokeBudget;
|
|
560
|
-
const config = fitMissileToBudget(quickBudget, distance, {
|
|
561
|
-
minTurnRate: 0,
|
|
562
|
-
maxDamage: enemyHP,
|
|
563
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
564
|
-
});
|
|
565
|
-
|
|
566
|
-
if (config)
|
|
567
|
-
{
|
|
568
|
-
return {
|
|
569
|
-
move,
|
|
570
|
-
startCast: {
|
|
571
|
-
spell: 'missile',
|
|
572
|
-
config,
|
|
573
|
-
missileAI: () => ({}),
|
|
574
|
-
direction: angleTo(state.position, enemy.position),
|
|
575
|
-
},
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
return {move};
|
|
582
|
-
};
|
|
1
|
+
import {WizardFunction} from '../../types.js';
|
|
2
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
3
|
+
import {angleTo} from '../../utils/angles.js';
|
|
4
|
+
import {
|
|
5
|
+
ARENA_SIZE,
|
|
6
|
+
GCD_DURATION,
|
|
7
|
+
SHIELD_CAST_TIME,
|
|
8
|
+
BLINK_CAST_TIME,
|
|
9
|
+
} from '../../rules.js';
|
|
10
|
+
import {
|
|
11
|
+
getThreats,
|
|
12
|
+
getMostUrgentThreat,
|
|
13
|
+
getDodgeDirectionForMovement,
|
|
14
|
+
getBlinkToDecreaseDistance,
|
|
15
|
+
shouldForceShield,
|
|
16
|
+
fitMissileToBudget,
|
|
17
|
+
MIN_USEFUL_DAMAGE,
|
|
18
|
+
getEnemyVulnerabilityTicks,
|
|
19
|
+
} from '../shared.js';
|
|
20
|
+
import {getMissileCastTime} from '../../utils/combat.js';
|
|
21
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
22
|
+
|
|
23
|
+
const WALL_BUFFER = 50;
|
|
24
|
+
const SHIELD_BUFFER = 3;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Aggressive movement: dodge missiles, close distance, light strafe.
|
|
28
|
+
*/
|
|
29
|
+
function aggressiveMove(
|
|
30
|
+
position: {x: number; y: number},
|
|
31
|
+
enemy: {position: {x: number; y: number}},
|
|
32
|
+
currentDistance: number,
|
|
33
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
34
|
+
tick: number,
|
|
35
|
+
strafeCyclePeriod: number,
|
|
36
|
+
strafeIntensity: number,
|
|
37
|
+
approachSpeed: number,
|
|
38
|
+
): {x: number; y: number}
|
|
39
|
+
{
|
|
40
|
+
const deltaX = enemy.position.x - position.x;
|
|
41
|
+
const deltaY = enemy.position.y - position.y;
|
|
42
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
43
|
+
|
|
44
|
+
const strafeClockwise = {
|
|
45
|
+
x: -deltaY / normalizedDistance,
|
|
46
|
+
y: deltaX / normalizedDistance,
|
|
47
|
+
};
|
|
48
|
+
const strafeCounterClockwise = {
|
|
49
|
+
x: deltaY / normalizedDistance,
|
|
50
|
+
y: -deltaX / normalizedDistance,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let strafeDirection: {x: number; y: number};
|
|
54
|
+
|
|
55
|
+
if (dodgeDirection)
|
|
56
|
+
{
|
|
57
|
+
strafeDirection = dodgeDirection;
|
|
58
|
+
}
|
|
59
|
+
else if (
|
|
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
|
+
const futureClockwise = {
|
|
67
|
+
x: position.x + strafeClockwise.x * 100,
|
|
68
|
+
y: position.y + strafeClockwise.y * 100,
|
|
69
|
+
};
|
|
70
|
+
const futureCounterClockwise = {
|
|
71
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
72
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
73
|
+
};
|
|
74
|
+
const scoreClockwise = Math.min(
|
|
75
|
+
futureClockwise.x,
|
|
76
|
+
ARENA_SIZE - futureClockwise.x,
|
|
77
|
+
futureClockwise.y,
|
|
78
|
+
ARENA_SIZE - futureClockwise.y,
|
|
79
|
+
);
|
|
80
|
+
const scoreCounterClockwise = Math.min(
|
|
81
|
+
futureCounterClockwise.x,
|
|
82
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
83
|
+
futureCounterClockwise.y,
|
|
84
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
85
|
+
);
|
|
86
|
+
strafeDirection =
|
|
87
|
+
scoreClockwise > scoreCounterClockwise
|
|
88
|
+
? strafeClockwise
|
|
89
|
+
: strafeCounterClockwise;
|
|
90
|
+
}
|
|
91
|
+
else
|
|
92
|
+
{
|
|
93
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
94
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (dodgeDirection)
|
|
98
|
+
{
|
|
99
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
103
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
104
|
+
|
|
105
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
106
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
107
|
+
|
|
108
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
109
|
+
if (magnitude > 100)
|
|
110
|
+
{
|
|
111
|
+
moveX = (moveX / magnitude) * 100;
|
|
112
|
+
moveY = (moveY / magnitude) * 100;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {x: moveX, y: moveY};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Bot: Voidblade
|
|
120
|
+
*
|
|
121
|
+
* BEHAVIOR: Reactive counter-puncher. Shields everything, then fires quick stabs
|
|
122
|
+
* during enemy vulnerability windows (GCD/casting) when they can't shield back.
|
|
123
|
+
* At melee range, the shield blocks ~90% of incoming damage while Voidblade's
|
|
124
|
+
* counter-stabs land at full damage — winning through attrition.
|
|
125
|
+
*
|
|
126
|
+
* CORE LOOP (melee range):
|
|
127
|
+
* 1. Enemy casts missile → Voidblade blink-dodges (100% avoid) or shields (90% block)
|
|
128
|
+
* 2. Enemy enters GCD → Voidblade fires quick stab (lands unblocked)
|
|
129
|
+
* 3. Voidblade enters GCD → enemy recovers → repeat
|
|
130
|
+
*
|
|
131
|
+
* KEY IMPROVEMENTS OVER NIGHTBLADE:
|
|
132
|
+
* - Blink-dodge priority: avoids 100% of damage when blink available, shields as fallback
|
|
133
|
+
* - Reads enemy vulnerability to time counter-stabs perfectly
|
|
134
|
+
* - Cancel-into-defense: aborts own cast if enemy missile incoming
|
|
135
|
+
* - Punish budget: sizes stabs to fit exactly in the vulnerability window
|
|
136
|
+
*
|
|
137
|
+
* PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
|
|
138
|
+
* TIER: 3 (elite Melee line)
|
|
139
|
+
*/
|
|
140
|
+
export const Voidblade: WizardFunction = ({state}) =>
|
|
141
|
+
{
|
|
142
|
+
const preemptiveShieldRange = useParam('preemptiveShieldRange', 348, {
|
|
143
|
+
range: 85,
|
|
144
|
+
min: 50,
|
|
145
|
+
steps: 7,
|
|
146
|
+
});
|
|
147
|
+
const shieldWindow = useParam('shieldWindow', 18, {
|
|
148
|
+
range: 10,
|
|
149
|
+
min: 5,
|
|
150
|
+
max: 30,
|
|
151
|
+
steps: 5,
|
|
152
|
+
});
|
|
153
|
+
const punishMinVulnerability = useParam('punishMinVulnerability', 10, {
|
|
154
|
+
range: 15,
|
|
155
|
+
min: 10,
|
|
156
|
+
steps: 5,
|
|
157
|
+
});
|
|
158
|
+
const preferBlinkDodge =
|
|
159
|
+
useParam('preferBlinkDodge', 0, {min: 0, max: 1, steps: 2}) >= 0.5;
|
|
160
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
|
|
161
|
+
range: 75,
|
|
162
|
+
min: 80,
|
|
163
|
+
max: 250,
|
|
164
|
+
});
|
|
165
|
+
const strafeIntensity = useParam('strafeIntensity', 45, {
|
|
166
|
+
range: 30,
|
|
167
|
+
min: 10,
|
|
168
|
+
max: 80,
|
|
169
|
+
steps: 7,
|
|
170
|
+
});
|
|
171
|
+
const approachSpeed = useParam('approachSpeed', 92, {
|
|
172
|
+
range: 20,
|
|
173
|
+
min: 50,
|
|
174
|
+
max: 100,
|
|
175
|
+
steps: 7,
|
|
176
|
+
});
|
|
177
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
178
|
+
range: 75,
|
|
179
|
+
min: 50,
|
|
180
|
+
max: 300,
|
|
181
|
+
});
|
|
182
|
+
const blinkWindowMax = useParam('blinkWindowMax', 40, {
|
|
183
|
+
range: 30,
|
|
184
|
+
min: 15,
|
|
185
|
+
max: 80,
|
|
186
|
+
steps: 7,
|
|
187
|
+
});
|
|
188
|
+
const blinkPastMargin = useParam('blinkPastMargin', 50, {
|
|
189
|
+
range: 30,
|
|
190
|
+
min: 20,
|
|
191
|
+
max: 100,
|
|
192
|
+
steps: 7,
|
|
193
|
+
});
|
|
194
|
+
const blinkPastDistance = useParam('blinkPastDistance', 200, {
|
|
195
|
+
range: 100,
|
|
196
|
+
min: 50,
|
|
197
|
+
max: 350,
|
|
198
|
+
steps: 7,
|
|
199
|
+
});
|
|
200
|
+
const engageBlinkRange = useParam('engageBlinkRange', 30, {
|
|
201
|
+
range: 75,
|
|
202
|
+
min: 30,
|
|
203
|
+
max: 200,
|
|
204
|
+
steps: 7,
|
|
205
|
+
});
|
|
206
|
+
const counterStabRange = useParam('counterStabRange', 27, {
|
|
207
|
+
range: 20,
|
|
208
|
+
min: 10,
|
|
209
|
+
max: 60,
|
|
210
|
+
steps: 7,
|
|
211
|
+
});
|
|
212
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 7.5, {
|
|
213
|
+
range: 4,
|
|
214
|
+
min: 3,
|
|
215
|
+
max: 12,
|
|
216
|
+
steps: 5,
|
|
217
|
+
});
|
|
218
|
+
const enemyFlightTimeDivisor = useParam('enemyFlightTimeDivisor', 10, {
|
|
219
|
+
range: 4,
|
|
220
|
+
min: 2,
|
|
221
|
+
max: 10,
|
|
222
|
+
steps: 5,
|
|
223
|
+
});
|
|
224
|
+
const idleProvokeBudget = useParam('idleProvokeBudget', 37, {
|
|
225
|
+
range: 40,
|
|
226
|
+
min: 20,
|
|
227
|
+
max: 120,
|
|
228
|
+
steps: 7,
|
|
229
|
+
});
|
|
230
|
+
const meleeAbortRange = useParam('meleeAbortRange', 50, {
|
|
231
|
+
range: 50,
|
|
232
|
+
min: 50,
|
|
233
|
+
max: 200,
|
|
234
|
+
steps: 7,
|
|
235
|
+
});
|
|
236
|
+
const enemyFiringSoonThreshold = useParam('enemyFiringSoonThreshold', 50, {
|
|
237
|
+
range: 30,
|
|
238
|
+
min: 10,
|
|
239
|
+
max: 100,
|
|
240
|
+
steps: 7,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const enemy = state.enemies[0];
|
|
244
|
+
if (!enemy)
|
|
245
|
+
{
|
|
246
|
+
return {move: {x: 0, y: 0}};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
250
|
+
const threats = getThreats(state);
|
|
251
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
252
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
253
|
+
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
254
|
+
|
|
255
|
+
const move = aggressiveMove(
|
|
256
|
+
state.position,
|
|
257
|
+
enemy,
|
|
258
|
+
distance,
|
|
259
|
+
dodgeDirection,
|
|
260
|
+
state.tick,
|
|
261
|
+
strafeCyclePeriod,
|
|
262
|
+
strafeIntensity,
|
|
263
|
+
approachSpeed,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Precompute blink targets — used by blink-dodge and engage-blink.
|
|
267
|
+
// blinkCloseTarget: from getBlinkToDecreaseDistance (dodge + close gap). Null at melee range.
|
|
268
|
+
// blinkPastTarget: blink ~200u past enemy (dodge only, loses position). For in-flight missiles only.
|
|
269
|
+
let blinkCloseTarget: {x: number; y: number} | null = null;
|
|
270
|
+
let blinkPastTarget: {x: number; y: number} | null = null;
|
|
271
|
+
if (state.blinkCooldown === 0)
|
|
272
|
+
{
|
|
273
|
+
blinkCloseTarget = getBlinkToDecreaseDistance(
|
|
274
|
+
state.position,
|
|
275
|
+
enemy.position,
|
|
276
|
+
state.projectiles,
|
|
277
|
+
);
|
|
278
|
+
if (!blinkCloseTarget)
|
|
279
|
+
{
|
|
280
|
+
const dx = enemy.position.x - state.position.x;
|
|
281
|
+
const dy = enemy.position.y - state.position.y;
|
|
282
|
+
const norm = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
283
|
+
blinkPastTarget = {
|
|
284
|
+
x: Math.max(
|
|
285
|
+
blinkPastMargin,
|
|
286
|
+
Math.min(
|
|
287
|
+
ARENA_SIZE - blinkPastMargin,
|
|
288
|
+
state.position.x + (dx / norm) * blinkPastDistance,
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
y: Math.max(
|
|
292
|
+
blinkPastMargin,
|
|
293
|
+
Math.min(
|
|
294
|
+
ARENA_SIZE - blinkPastMargin,
|
|
295
|
+
state.position.y + (dy / norm) * blinkPastDistance,
|
|
296
|
+
),
|
|
297
|
+
),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// === STEP 1: CHANNELING — hold shield until missile passes ===
|
|
303
|
+
if (state.state === 'channeling')
|
|
304
|
+
{
|
|
305
|
+
// Hold shield while there's an active threat OR enemy is about to fire
|
|
306
|
+
const enemyFiringSoon =
|
|
307
|
+
distance < preemptiveShieldRange &&
|
|
308
|
+
enemy.state === 'casting' &&
|
|
309
|
+
enemy.castingSpell === 'missile' &&
|
|
310
|
+
(enemy.castDuration ?? 0) -
|
|
311
|
+
(enemy.castProgress ?? 0) +
|
|
312
|
+
distance / enemyFlightTimeDivisor <
|
|
313
|
+
enemyFiringSoonThreshold;
|
|
314
|
+
|
|
315
|
+
if (
|
|
316
|
+
(!urgentThreat && !enemyFiringSoon) ||
|
|
317
|
+
(urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
318
|
+
)
|
|
319
|
+
{
|
|
320
|
+
return {move, cancel: true};
|
|
321
|
+
}
|
|
322
|
+
return {move: {x: 0, y: 0}};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// === STEP 2: CAST-CANCEL INTO DEFENSE ===
|
|
326
|
+
// If we're casting a stab and a missile is incoming, cancel so we can
|
|
327
|
+
// blink-dodge (if available) or shield. Defense time depends on blink availability.
|
|
328
|
+
if (state.state === 'casting' && state.castingSpell === 'missile')
|
|
329
|
+
{
|
|
330
|
+
// Cancel if target escaped melee range
|
|
331
|
+
if (distance > meleeAbortRange)
|
|
332
|
+
{
|
|
333
|
+
return {move, cancel: true};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Defense time: blink (10t) is faster than shield (20t + 3t buffer)
|
|
337
|
+
const defenseTime =
|
|
338
|
+
state.blinkCooldown === 0
|
|
339
|
+
? BLINK_CAST_TIME
|
|
340
|
+
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
341
|
+
|
|
342
|
+
// Cancel for any undodgeable incoming threat we can't finish casting before
|
|
343
|
+
if (urgentThreat && !urgentThreat.bestDodgeDirection)
|
|
344
|
+
{
|
|
345
|
+
const remainingCast =
|
|
346
|
+
(state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
347
|
+
const willArriveInWindow =
|
|
348
|
+
urgentThreat.ticksToImpact <=
|
|
349
|
+
remainingCast + GCD_DURATION + defenseTime;
|
|
350
|
+
if (willArriveInWindow)
|
|
351
|
+
{
|
|
352
|
+
return {move, cancel: true};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Cancel when enemy starts casting a missile at close range —
|
|
357
|
+
// at melee range their missile will arrive too fast to react after our cast finishes
|
|
358
|
+
if (
|
|
359
|
+
distance < preemptiveShieldRange &&
|
|
360
|
+
enemy.state === 'casting' &&
|
|
361
|
+
enemy.castingSpell === 'missile'
|
|
362
|
+
)
|
|
363
|
+
{
|
|
364
|
+
const remainingEnemyCast =
|
|
365
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
366
|
+
const remainingOurCast =
|
|
367
|
+
(state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
368
|
+
const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
|
|
369
|
+
const ticksUntilEnemyHit = remainingEnemyCast + estimatedFlightTicks;
|
|
370
|
+
const ticksAfterOurCast = remainingOurCast + GCD_DURATION + defenseTime;
|
|
371
|
+
|
|
372
|
+
// If enemy missile will arrive before we can defend after our cast, cancel now
|
|
373
|
+
if (ticksUntilEnemyHit < ticksAfterOurCast)
|
|
374
|
+
{
|
|
375
|
+
return {move, cancel: true};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// === STEP 3: BLINK-DODGE — 100% damage avoidance (preferred over shield) ===
|
|
381
|
+
if (state.state === 'idle' && state.blinkCooldown === 0)
|
|
382
|
+
{
|
|
383
|
+
// Blink target for dodging: close-gap target if available, else blink-past for in-flight only
|
|
384
|
+
const dodgeBlinkTarget = blinkCloseTarget ?? blinkPastTarget;
|
|
385
|
+
|
|
386
|
+
// 3a: Blink-dodge an in-flight missile (missile already fired, can't be cancelled)
|
|
387
|
+
if (
|
|
388
|
+
preferBlinkDodge &&
|
|
389
|
+
dodgeBlinkTarget &&
|
|
390
|
+
urgentThreat &&
|
|
391
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
392
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
393
|
+
)
|
|
394
|
+
{
|
|
395
|
+
return {
|
|
396
|
+
move: {x: 0, y: 0},
|
|
397
|
+
startCast: {spell: 'blink', target: dodgeBlinkTarget},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 3b: Preemptive blink-dodge — only when we have a close-gap target.
|
|
402
|
+
// At melee range, preemptive blink loses positioning (200u walk back) and
|
|
403
|
+
// the enemy can cancel their cast. Shield is better for preemptive defense.
|
|
404
|
+
if (
|
|
405
|
+
preferBlinkDodge &&
|
|
406
|
+
blinkCloseTarget &&
|
|
407
|
+
distance < preemptiveShieldRange &&
|
|
408
|
+
enemy.state === 'casting' &&
|
|
409
|
+
enemy.castingSpell === 'missile'
|
|
410
|
+
)
|
|
411
|
+
{
|
|
412
|
+
const remainingCast =
|
|
413
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
414
|
+
const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
|
|
415
|
+
const ticksUntilHit = remainingCast + estimatedFlightTicks;
|
|
416
|
+
|
|
417
|
+
if (
|
|
418
|
+
ticksUntilHit >= BLINK_CAST_TIME &&
|
|
419
|
+
ticksUntilHit < BLINK_CAST_TIME + shieldWindow
|
|
420
|
+
)
|
|
421
|
+
{
|
|
422
|
+
return {
|
|
423
|
+
move: {x: 0, y: 0},
|
|
424
|
+
startCast: {spell: 'blink', target: blinkCloseTarget},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 3c: Engage blink — close distance during enemy vulnerability (they can't react)
|
|
430
|
+
if (
|
|
431
|
+
blinkCloseTarget &&
|
|
432
|
+
distance > engageBlinkRange &&
|
|
433
|
+
vulnerabilityTicks >= punishMinVulnerability &&
|
|
434
|
+
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
435
|
+
)
|
|
436
|
+
{
|
|
437
|
+
return {
|
|
438
|
+
move: {x: 0, y: 0},
|
|
439
|
+
startCast: {spell: 'blink', target: blinkCloseTarget},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// === STEP 4: SHIELD — fallback when blink can't help (90% block) ===
|
|
445
|
+
|
|
446
|
+
// 4a: Shield in-flight missiles
|
|
447
|
+
// Shield when: no blink target at all, or missile too close for blink (<10 ticks)
|
|
448
|
+
if (
|
|
449
|
+
state.state === 'idle' &&
|
|
450
|
+
urgentThreat &&
|
|
451
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
452
|
+
urgentThreat.canBlockInTime &&
|
|
453
|
+
((!blinkCloseTarget && !blinkPastTarget) || urgentThreat.ticksToImpact < 10)
|
|
454
|
+
)
|
|
455
|
+
{
|
|
456
|
+
return {
|
|
457
|
+
move: {x: 0, y: 0},
|
|
458
|
+
startCast: {spell: 'shield'},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 4b: Preemptive shield — when blink can't close gap (melee range or cooldown)
|
|
463
|
+
if (
|
|
464
|
+
state.state === 'idle' &&
|
|
465
|
+
!blinkCloseTarget &&
|
|
466
|
+
distance < preemptiveShieldRange &&
|
|
467
|
+
enemy.state === 'casting' &&
|
|
468
|
+
enemy.castingSpell === 'missile'
|
|
469
|
+
)
|
|
470
|
+
{
|
|
471
|
+
const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
472
|
+
const estimatedFlightTicks = distance / enemyFlightTimeDivisor;
|
|
473
|
+
const ticksUntilHit = remainingCast + estimatedFlightTicks;
|
|
474
|
+
|
|
475
|
+
if (
|
|
476
|
+
ticksUntilHit >= SHIELD_CAST_TIME &&
|
|
477
|
+
ticksUntilHit < SHIELD_CAST_TIME + shieldWindow
|
|
478
|
+
)
|
|
479
|
+
{
|
|
480
|
+
return {
|
|
481
|
+
move: {x: 0, y: 0},
|
|
482
|
+
startCast: {spell: 'shield'},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// === STEP 5: COUNTER-STAB — fire during enemy vulnerability ===
|
|
488
|
+
// Only fire when enemy CAN'T shield (in GCD or mid-cast).
|
|
489
|
+
// Use small, fast stabs that fit within the vulnerability window.
|
|
490
|
+
if (state.state === 'idle' && distance < counterStabRange)
|
|
491
|
+
{
|
|
492
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
493
|
+
const flightTime = Math.ceil(distance / flightTimeDivisor) + 1;
|
|
494
|
+
|
|
495
|
+
if (vulnerabilityTicks >= punishMinVulnerability)
|
|
496
|
+
{
|
|
497
|
+
// Budget: stab must land while enemy is still vulnerable
|
|
498
|
+
// castTime + flightTime < vulnerabilityTicks
|
|
499
|
+
const punishBudget = vulnerabilityTicks - flightTime;
|
|
500
|
+
|
|
501
|
+
// Also need time to shield if another missile is incoming
|
|
502
|
+
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
503
|
+
|
|
504
|
+
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
505
|
+
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
506
|
+
{
|
|
507
|
+
const remainingEnemyCast =
|
|
508
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
509
|
+
const estimatedFlightTime = distance / enemyFlightTimeDivisor;
|
|
510
|
+
nextThreatTime = Math.min(
|
|
511
|
+
nextThreatTime,
|
|
512
|
+
remainingEnemyCast + estimatedFlightTime,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Defense time: blink (10t) is faster than shield (20t + 3t buffer)
|
|
517
|
+
const defenseTime =
|
|
518
|
+
state.blinkCooldown === 0
|
|
519
|
+
? BLINK_CAST_TIME
|
|
520
|
+
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
521
|
+
const safeBudget = nextThreatTime - GCD_DURATION - defenseTime;
|
|
522
|
+
const effectiveBudget = Math.min(punishBudget, safeBudget);
|
|
523
|
+
|
|
524
|
+
if (effectiveBudget > 0)
|
|
525
|
+
{
|
|
526
|
+
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
527
|
+
minTurnRate: 0,
|
|
528
|
+
maxDamage: enemyHP,
|
|
529
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
533
|
+
{
|
|
534
|
+
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
535
|
+
if (castTime + flightTime <= vulnerabilityTicks)
|
|
536
|
+
{
|
|
537
|
+
return {
|
|
538
|
+
move,
|
|
539
|
+
startCast: {
|
|
540
|
+
spell: 'missile',
|
|
541
|
+
config,
|
|
542
|
+
missileAI: () => ({}),
|
|
543
|
+
direction: angleTo(state.position, enemy.position),
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Enemy is idle or channeling shield at close range — fire a quick stab to provoke.
|
|
552
|
+
// Idle: they must shield or eat damage.
|
|
553
|
+
// Channeling: shield blocks ~90% but 10% still lands. Stabs whittle HP so they
|
|
554
|
+
// can't just hold shield forever. Why: otherwise the bot waits for a
|
|
555
|
+
// vulnerability window that a channeling player never opens.
|
|
556
|
+
if ((enemy.state === 'idle' || enemy.state === 'channeling') && !urgentThreat)
|
|
557
|
+
{
|
|
558
|
+
// Use a small budget to keep stabs fast and stay reactive
|
|
559
|
+
const quickBudget = idleProvokeBudget;
|
|
560
|
+
const config = fitMissileToBudget(quickBudget, distance, {
|
|
561
|
+
minTurnRate: 0,
|
|
562
|
+
maxDamage: enemyHP,
|
|
563
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
if (config)
|
|
567
|
+
{
|
|
568
|
+
return {
|
|
569
|
+
move,
|
|
570
|
+
startCast: {
|
|
571
|
+
spell: 'missile',
|
|
572
|
+
config,
|
|
573
|
+
missileAI: () => ({}),
|
|
574
|
+
direction: angleTo(state.position, enemy.position),
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return {move};
|
|
582
|
+
};
|