@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,588 +1,588 @@
|
|
|
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
|
-
fitMissileToBudget,
|
|
16
|
-
getBlinkToDecreaseDistance,
|
|
17
|
-
getBlinkToIncreaseDistance,
|
|
18
|
-
shouldForceShield,
|
|
19
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
20
|
-
MAX_CAST_BUDGET,
|
|
21
|
-
MIN_USEFUL_DAMAGE,
|
|
22
|
-
getEnemyVulnerabilityTicks,
|
|
23
|
-
isSafeToAttack,
|
|
24
|
-
} from '../shared.js';
|
|
25
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
26
|
-
|
|
27
|
-
const WALL_BUFFER = 60;
|
|
28
|
-
const SHIELD_BUFFER = 3;
|
|
29
|
-
|
|
30
|
-
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
31
|
-
{
|
|
32
|
-
const enemy = worldState.enemies[0];
|
|
33
|
-
return {turnToward: enemy?.position};
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const StraightAI: MissileAIFunction = () => ({});
|
|
37
|
-
|
|
38
|
-
function isNearWall(position: {x: number; y: number}): boolean
|
|
39
|
-
{
|
|
40
|
-
return (
|
|
41
|
-
position.x < WALL_BUFFER ||
|
|
42
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
43
|
-
position.y < WALL_BUFFER ||
|
|
44
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Smart combat movement with threat-aware dodging.
|
|
50
|
-
*/
|
|
51
|
-
function combatMove(
|
|
52
|
-
position: {x: number; y: number},
|
|
53
|
-
enemy: {position: {x: number; y: number}},
|
|
54
|
-
currentDistance: number,
|
|
55
|
-
targetDistance: number,
|
|
56
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
57
|
-
tick: number,
|
|
58
|
-
strafeCyclePeriod: number,
|
|
59
|
-
strafeIntensity: number,
|
|
60
|
-
approachSpeed: number,
|
|
61
|
-
distanceDeadZone: number,
|
|
62
|
-
): {x: number; y: number}
|
|
63
|
-
{
|
|
64
|
-
const deltaX = enemy.position.x - position.x;
|
|
65
|
-
const deltaY = enemy.position.y - position.y;
|
|
66
|
-
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
67
|
-
|
|
68
|
-
const strafeClockwise = {
|
|
69
|
-
x: -deltaY / normalizedDistance,
|
|
70
|
-
y: deltaX / normalizedDistance,
|
|
71
|
-
};
|
|
72
|
-
const strafeCounterClockwise = {
|
|
73
|
-
x: deltaY / normalizedDistance,
|
|
74
|
-
y: -deltaX / normalizedDistance,
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
let strafeDirection: {x: number; y: number};
|
|
78
|
-
|
|
79
|
-
if (dodgeDirection)
|
|
80
|
-
{
|
|
81
|
-
strafeDirection = dodgeDirection;
|
|
82
|
-
}
|
|
83
|
-
else if (isNearWall(position))
|
|
84
|
-
{
|
|
85
|
-
const futureClockwise = {
|
|
86
|
-
x: position.x + strafeClockwise.x * 100,
|
|
87
|
-
y: position.y + strafeClockwise.y * 100,
|
|
88
|
-
};
|
|
89
|
-
const futureCounterClockwise = {
|
|
90
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
91
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
92
|
-
};
|
|
93
|
-
const scoreClockwise = Math.min(
|
|
94
|
-
futureClockwise.x,
|
|
95
|
-
ARENA_SIZE - futureClockwise.x,
|
|
96
|
-
futureClockwise.y,
|
|
97
|
-
ARENA_SIZE - futureClockwise.y,
|
|
98
|
-
);
|
|
99
|
-
const scoreCounterClockwise = Math.min(
|
|
100
|
-
futureCounterClockwise.x,
|
|
101
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
102
|
-
futureCounterClockwise.y,
|
|
103
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
104
|
-
);
|
|
105
|
-
strafeDirection =
|
|
106
|
-
scoreClockwise > scoreCounterClockwise
|
|
107
|
-
? strafeClockwise
|
|
108
|
-
: strafeCounterClockwise;
|
|
109
|
-
}
|
|
110
|
-
else
|
|
111
|
-
{
|
|
112
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
113
|
-
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (dodgeDirection)
|
|
117
|
-
{
|
|
118
|
-
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
let moveX = strafeDirection.x * strafeIntensity;
|
|
122
|
-
let moveY = strafeDirection.y * strafeIntensity;
|
|
123
|
-
|
|
124
|
-
if (currentDistance > targetDistance + distanceDeadZone)
|
|
125
|
-
{
|
|
126
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
127
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
128
|
-
}
|
|
129
|
-
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
130
|
-
{
|
|
131
|
-
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
132
|
-
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
133
|
-
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
134
|
-
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
135
|
-
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
136
|
-
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
137
|
-
moveX += retreatX;
|
|
138
|
-
moveY += retreatY;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
142
|
-
if (magnitude > 100)
|
|
143
|
-
{
|
|
144
|
-
moveX = (moveX / magnitude) * 100;
|
|
145
|
-
moveY = (moveY / magnitude) * 100;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return {x: moveX, y: moveY};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Bot: Archmage
|
|
153
|
-
*
|
|
154
|
-
* BEHAVIOR: Versatile duelist that adapts missile choice based on distance and HP.
|
|
155
|
-
* Close range → straight missiles (no turn cost = more damage). Mid/far range → homing.
|
|
156
|
-
* Uses dual-blink aggressively (gap-close during vulnerability, escape when trade is bad).
|
|
157
|
-
* HP-aware: ahead → aggressive close range; behind → defensive ranged kiting.
|
|
158
|
-
*
|
|
159
|
-
* KEY IMPROVEMENTS OVER WARMAGE:
|
|
160
|
-
* - Range-adaptive missiles: straight close, homing far
|
|
161
|
-
* - Vulnerability-timed blinks: gap-close during enemy cast/GCD
|
|
162
|
-
* - HP-aware aggression: adjusts distance + risk tolerance based on HP differential
|
|
163
|
-
* - Progressive cast-cancel: graduated thresholds
|
|
164
|
-
*
|
|
165
|
-
* PROGRESSION LINE: Battlemage → Warmage → Archmage
|
|
166
|
-
* TIER: 3 (elite Duelist line)
|
|
167
|
-
*/
|
|
168
|
-
export const Archmage: WizardFunction = ({state}) =>
|
|
169
|
-
{
|
|
170
|
-
const targetDistance = useParam('targetDistance', 214, {
|
|
171
|
-
range: 150,
|
|
172
|
-
min: 0,
|
|
173
|
-
});
|
|
174
|
-
const minTurnRate = useParam('minTurnRate', -14.6, {range: 1.5, steps: 5});
|
|
175
|
-
const blinkEscapeDistance = useParam('blinkEscapeDistance', 442, {
|
|
176
|
-
range: 190,
|
|
177
|
-
min: 0,
|
|
178
|
-
steps: 7,
|
|
179
|
-
});
|
|
180
|
-
const punishMinVulnerability = useParam('punishMinVulnerability', 85, {
|
|
181
|
-
range: 30,
|
|
182
|
-
min: 10,
|
|
183
|
-
steps: 5,
|
|
184
|
-
});
|
|
185
|
-
const aggressiveHPThreshold = useParam('aggressiveHPThreshold', 4, {
|
|
186
|
-
range: 20,
|
|
187
|
-
min: 0,
|
|
188
|
-
steps: 5,
|
|
189
|
-
});
|
|
190
|
-
const preferBlinkDodge =
|
|
191
|
-
useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
|
|
192
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 248, {
|
|
193
|
-
range: 75,
|
|
194
|
-
min: 80,
|
|
195
|
-
max: 250,
|
|
196
|
-
});
|
|
197
|
-
const strafeIntensity = useParam('strafeIntensity', 100, {
|
|
198
|
-
range: 40,
|
|
199
|
-
min: 20,
|
|
200
|
-
max: 100,
|
|
201
|
-
steps: 7,
|
|
202
|
-
});
|
|
203
|
-
const approachSpeed = useParam('approachSpeed', 57, {
|
|
204
|
-
range: 30,
|
|
205
|
-
min: 10,
|
|
206
|
-
max: 80,
|
|
207
|
-
steps: 7,
|
|
208
|
-
});
|
|
209
|
-
const distanceDeadZone = useParam('distanceDeadZone', 37, {
|
|
210
|
-
range: 30,
|
|
211
|
-
min: 10,
|
|
212
|
-
max: 80,
|
|
213
|
-
steps: 7,
|
|
214
|
-
});
|
|
215
|
-
const aggressiveDistanceOffset = useParam('aggressiveDistanceOffset', 99, {
|
|
216
|
-
range: 50,
|
|
217
|
-
min: 0,
|
|
218
|
-
max: 100,
|
|
219
|
-
steps: 7,
|
|
220
|
-
});
|
|
221
|
-
const cautiousDistanceOffset = useParam('cautiousDistanceOffset', 42, {
|
|
222
|
-
range: 50,
|
|
223
|
-
min: 0,
|
|
224
|
-
max: 120,
|
|
225
|
-
steps: 7,
|
|
226
|
-
});
|
|
227
|
-
const cancelHeavyDmg = useParam('cancelHeavyDmg', 40, {
|
|
228
|
-
range: 15,
|
|
229
|
-
min: 5,
|
|
230
|
-
max: 40,
|
|
231
|
-
steps: 5,
|
|
232
|
-
});
|
|
233
|
-
const cancelMediumDmg = useParam('cancelMediumDmg', 20, {
|
|
234
|
-
range: 10,
|
|
235
|
-
min: 3,
|
|
236
|
-
max: 25,
|
|
237
|
-
steps: 5,
|
|
238
|
-
});
|
|
239
|
-
const cancelCastRatio = useParam('cancelCastRatio', 0.74, {
|
|
240
|
-
range: 0.4,
|
|
241
|
-
min: 0.3,
|
|
242
|
-
max: 1.0,
|
|
243
|
-
steps: 5,
|
|
244
|
-
});
|
|
245
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
|
|
246
|
-
range: 75,
|
|
247
|
-
min: 50,
|
|
248
|
-
max: 300,
|
|
249
|
-
});
|
|
250
|
-
const blinkWindowMax = useParam('blinkWindowMax', 29, {
|
|
251
|
-
range: 40,
|
|
252
|
-
min: 20,
|
|
253
|
-
max: 120,
|
|
254
|
-
steps: 7,
|
|
255
|
-
});
|
|
256
|
-
const blinkGapThreshold = useParam('blinkGapThreshold', 80, {
|
|
257
|
-
range: 75,
|
|
258
|
-
min: 20,
|
|
259
|
-
max: 200,
|
|
260
|
-
steps: 7,
|
|
261
|
-
});
|
|
262
|
-
const flightTimeDivisor = useParam('flightTimeDivisor', 4.5, {
|
|
263
|
-
range: 4,
|
|
264
|
-
min: 2,
|
|
265
|
-
max: 12,
|
|
266
|
-
steps: 5,
|
|
267
|
-
});
|
|
268
|
-
const closeRangeStraightThreshold = useParam(
|
|
269
|
-
'closeRangeStraightThreshold',
|
|
270
|
-
50,
|
|
271
|
-
{range: 100, min: 50, max: 300, steps: 7},
|
|
272
|
-
);
|
|
273
|
-
const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
|
|
274
|
-
range: 25,
|
|
275
|
-
min: 10,
|
|
276
|
-
max: 80,
|
|
277
|
-
steps: 5,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const enemy = state.enemies[0];
|
|
281
|
-
if (!enemy)
|
|
282
|
-
{
|
|
283
|
-
return {move: {x: 0, y: 0}};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
287
|
-
const threats = getThreats(state);
|
|
288
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
289
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
290
|
-
|
|
291
|
-
// HP-aware distance: ahead → fight closer, behind → kite at range
|
|
292
|
-
const hpDiff = state.health - enemy.health;
|
|
293
|
-
const isAggressive = hpDiff >= -aggressiveHPThreshold;
|
|
294
|
-
const effectiveTargetDistance = isAggressive
|
|
295
|
-
? targetDistance - aggressiveDistanceOffset
|
|
296
|
-
: targetDistance + cautiousDistanceOffset;
|
|
297
|
-
|
|
298
|
-
const move = combatMove(
|
|
299
|
-
state.position,
|
|
300
|
-
enemy,
|
|
301
|
-
distance,
|
|
302
|
-
effectiveTargetDistance,
|
|
303
|
-
dodgeDirection,
|
|
304
|
-
state.tick,
|
|
305
|
-
strafeCyclePeriod,
|
|
306
|
-
strafeIntensity,
|
|
307
|
-
approachSpeed,
|
|
308
|
-
distanceDeadZone,
|
|
309
|
-
);
|
|
310
|
-
|
|
311
|
-
// === STEP 1: CHANNELING ===
|
|
312
|
-
if (state.state === 'channeling')
|
|
313
|
-
{
|
|
314
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
315
|
-
{
|
|
316
|
-
return {move, cancel: true};
|
|
317
|
-
}
|
|
318
|
-
return {move: {x: 0, y: 0}};
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// === STEP 2: PROGRESSIVE CAST-CANCEL ===
|
|
322
|
-
if (
|
|
323
|
-
state.state === 'casting' &&
|
|
324
|
-
state.castingSpell === 'missile' &&
|
|
325
|
-
urgentThreat &&
|
|
326
|
-
!urgentThreat.bestDodgeDirection
|
|
327
|
-
)
|
|
328
|
-
{
|
|
329
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
330
|
-
const defenseTime =
|
|
331
|
-
state.blinkCooldown === 0
|
|
332
|
-
? BLINK_CAST_TIME
|
|
333
|
-
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
334
|
-
const willArriveInWindow =
|
|
335
|
-
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
|
|
336
|
-
|
|
337
|
-
if (willArriveInWindow)
|
|
338
|
-
{
|
|
339
|
-
const incomingDamage = urgentThreat.projectile.damage;
|
|
340
|
-
const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
|
|
341
|
-
|
|
342
|
-
if (incomingDamage >= state.health || incomingDamage >= cancelHeavyDmg)
|
|
343
|
-
{
|
|
344
|
-
return {move, cancel: true};
|
|
345
|
-
}
|
|
346
|
-
if (incomingDamage >= cancelMediumDmg && castRatio < cancelCastRatio)
|
|
347
|
-
{
|
|
348
|
-
return {move, cancel: true};
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
|
|
354
|
-
if (
|
|
355
|
-
preferBlinkDodge &&
|
|
356
|
-
state.state === 'idle' &&
|
|
357
|
-
urgentThreat &&
|
|
358
|
-
state.blinkCooldown === 0 &&
|
|
359
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
360
|
-
urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
|
|
361
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
362
|
-
)
|
|
363
|
-
{
|
|
364
|
-
// Aggressive: blink toward enemy to dodge + close gap
|
|
365
|
-
// Defensive: blink away to dodge + increase distance
|
|
366
|
-
if (isAggressive && distance > effectiveTargetDistance)
|
|
367
|
-
{
|
|
368
|
-
const blinkTarget = getBlinkToDecreaseDistance(
|
|
369
|
-
state.position,
|
|
370
|
-
enemy.position,
|
|
371
|
-
state.projectiles,
|
|
372
|
-
);
|
|
373
|
-
if (blinkTarget)
|
|
374
|
-
{
|
|
375
|
-
return {
|
|
376
|
-
move: {x: 0, y: 0},
|
|
377
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
const blinkTarget = getBlinkToIncreaseDistance(
|
|
382
|
-
state.position,
|
|
383
|
-
enemy.position,
|
|
384
|
-
state.projectiles,
|
|
385
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
386
|
-
);
|
|
387
|
-
if (blinkTarget)
|
|
388
|
-
{
|
|
389
|
-
return {
|
|
390
|
-
move: {x: 0, y: 0},
|
|
391
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// === STEP 4: BLINK ESCAPE — enemy too close ===
|
|
397
|
-
if (
|
|
398
|
-
state.state === 'idle' &&
|
|
399
|
-
distance < blinkEscapeDistance &&
|
|
400
|
-
state.blinkCooldown === 0 &&
|
|
401
|
-
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
402
|
-
)
|
|
403
|
-
{
|
|
404
|
-
const escapeTarget = getBlinkToIncreaseDistance(
|
|
405
|
-
state.position,
|
|
406
|
-
enemy.position,
|
|
407
|
-
state.projectiles,
|
|
408
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
409
|
-
);
|
|
410
|
-
if (escapeTarget)
|
|
411
|
-
{
|
|
412
|
-
return {
|
|
413
|
-
move: {x: 0, y: 0},
|
|
414
|
-
startCast: {spell: 'blink', target: escapeTarget},
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// === STEP 5: SHIELD ===
|
|
420
|
-
if (
|
|
421
|
-
state.state === 'idle' &&
|
|
422
|
-
urgentThreat &&
|
|
423
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
424
|
-
)
|
|
425
|
-
{
|
|
426
|
-
if (
|
|
427
|
-
urgentThreat.canBlockInTime &&
|
|
428
|
-
urgentThreat.ticksToStartShield <= SHIELD_BUFFER &&
|
|
429
|
-
(state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
|
|
430
|
-
)
|
|
431
|
-
{
|
|
432
|
-
return {
|
|
433
|
-
move: {x: 0, y: 0},
|
|
434
|
-
startCast: {spell: 'shield'},
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// === STEP 6: VULNERABILITY BLINK — gap-close during enemy cast/GCD ===
|
|
440
|
-
if (
|
|
441
|
-
state.state === 'idle' &&
|
|
442
|
-
state.blinkCooldown === 0 &&
|
|
443
|
-
isAggressive &&
|
|
444
|
-
distance > effectiveTargetDistance + blinkGapThreshold &&
|
|
445
|
-
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
446
|
-
)
|
|
447
|
-
{
|
|
448
|
-
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
449
|
-
if (vulnerabilityTicks >= punishMinVulnerability)
|
|
450
|
-
{
|
|
451
|
-
const blinkTarget = getBlinkToDecreaseDistance(
|
|
452
|
-
state.position,
|
|
453
|
-
enemy.position,
|
|
454
|
-
state.projectiles,
|
|
455
|
-
);
|
|
456
|
-
if (blinkTarget)
|
|
457
|
-
{
|
|
458
|
-
return {
|
|
459
|
-
move: {x: 0, y: 0},
|
|
460
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// === STEP 7: OFFENSE ===
|
|
467
|
-
if (state.state === 'idle' && distance < 500)
|
|
468
|
-
{
|
|
469
|
-
const offenseDefenseTime =
|
|
470
|
-
state.blinkCooldown === 0
|
|
471
|
-
? BLINK_CAST_TIME
|
|
472
|
-
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
473
|
-
const enemyHP = Math.ceil(enemy.health);
|
|
474
|
-
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
475
|
-
|
|
476
|
-
// --- PUNISH MODE: range-adaptive missiles during vulnerability ---
|
|
477
|
-
if (vulnerabilityTicks >= punishMinVulnerability)
|
|
478
|
-
{
|
|
479
|
-
const flightTimeEstimate = distance / flightTimeDivisor;
|
|
480
|
-
const punishBudget = vulnerabilityTicks - flightTimeEstimate;
|
|
481
|
-
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
482
|
-
const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
|
|
483
|
-
const effectiveBudget = Math.min(
|
|
484
|
-
punishBudget,
|
|
485
|
-
safeBudget,
|
|
486
|
-
MAX_CAST_BUDGET,
|
|
487
|
-
);
|
|
488
|
-
|
|
489
|
-
if (effectiveBudget > 0)
|
|
490
|
-
{
|
|
491
|
-
// Close range → straight for max damage, far → light homing
|
|
492
|
-
const punishTurnRate = distance < closeRangeStraightThreshold ? 0 : 0.2;
|
|
493
|
-
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
494
|
-
minTurnRate: punishTurnRate,
|
|
495
|
-
maxDamage: enemyHP,
|
|
496
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
500
|
-
{
|
|
501
|
-
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
502
|
-
const actualFlight = distance / config.speed;
|
|
503
|
-
|
|
504
|
-
if (
|
|
505
|
-
castTime + actualFlight < vulnerabilityTicks &&
|
|
506
|
-
isSafeToAttack(threats, castTime)
|
|
507
|
-
)
|
|
508
|
-
{
|
|
509
|
-
const aimTarget =
|
|
510
|
-
config.turnRate > 0
|
|
511
|
-
? enemy.position
|
|
512
|
-
: getLeadPosition(
|
|
513
|
-
enemy.position,
|
|
514
|
-
enemy.velocity,
|
|
515
|
-
config.speed,
|
|
516
|
-
state.position,
|
|
517
|
-
);
|
|
518
|
-
return {
|
|
519
|
-
move,
|
|
520
|
-
startCast: {
|
|
521
|
-
spell: 'missile',
|
|
522
|
-
config,
|
|
523
|
-
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
524
|
-
direction: angleTo(state.position, aimTarget),
|
|
525
|
-
},
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// --- STANDARD MODE: range-adaptive missiles ---
|
|
533
|
-
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
534
|
-
const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
|
|
535
|
-
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
536
|
-
|
|
537
|
-
// Adapt turn rate to distance: close → straight, far → homing
|
|
538
|
-
const effectiveMinTurnRate =
|
|
539
|
-
distance < closeRangeStraightThreshold ? 0 : minTurnRate;
|
|
540
|
-
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
541
|
-
let config = fitMissileToBudget(budgetTicks, distance, {
|
|
542
|
-
minTurnRate: effectiveMinTurnRate,
|
|
543
|
-
maxDamage: enemyHP,
|
|
544
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
if (
|
|
548
|
-
config &&
|
|
549
|
-
config.damage >= minDmg &&
|
|
550
|
-
isSafeToAttack(threats, safeAttackCastEstimate)
|
|
551
|
-
)
|
|
552
|
-
{
|
|
553
|
-
return {
|
|
554
|
-
move,
|
|
555
|
-
startCast: {
|
|
556
|
-
spell: 'missile',
|
|
557
|
-
config,
|
|
558
|
-
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
559
|
-
direction: angleTo(state.position, enemy.position),
|
|
560
|
-
},
|
|
561
|
-
};
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Fallback: fire bigger missile, accept incoming hit
|
|
565
|
-
if (!config || config.damage < minDmg)
|
|
566
|
-
{
|
|
567
|
-
config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
|
|
568
|
-
minTurnRate: effectiveMinTurnRate,
|
|
569
|
-
maxDamage: enemyHP,
|
|
570
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
571
|
-
});
|
|
572
|
-
if (config)
|
|
573
|
-
{
|
|
574
|
-
return {
|
|
575
|
-
move,
|
|
576
|
-
startCast: {
|
|
577
|
-
spell: 'missile',
|
|
578
|
-
config,
|
|
579
|
-
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
580
|
-
direction: angleTo(state.position, enemy.position),
|
|
581
|
-
},
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
return {move};
|
|
588
|
-
};
|
|
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
|
+
fitMissileToBudget,
|
|
16
|
+
getBlinkToDecreaseDistance,
|
|
17
|
+
getBlinkToIncreaseDistance,
|
|
18
|
+
shouldForceShield,
|
|
19
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
20
|
+
MAX_CAST_BUDGET,
|
|
21
|
+
MIN_USEFUL_DAMAGE,
|
|
22
|
+
getEnemyVulnerabilityTicks,
|
|
23
|
+
isSafeToAttack,
|
|
24
|
+
} from '../shared.js';
|
|
25
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
26
|
+
|
|
27
|
+
const WALL_BUFFER = 60;
|
|
28
|
+
const SHIELD_BUFFER = 3;
|
|
29
|
+
|
|
30
|
+
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
31
|
+
{
|
|
32
|
+
const enemy = worldState.enemies[0];
|
|
33
|
+
return {turnToward: enemy?.position};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const StraightAI: MissileAIFunction = () => ({});
|
|
37
|
+
|
|
38
|
+
function isNearWall(position: {x: number; y: number}): boolean
|
|
39
|
+
{
|
|
40
|
+
return (
|
|
41
|
+
position.x < WALL_BUFFER ||
|
|
42
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
43
|
+
position.y < WALL_BUFFER ||
|
|
44
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Smart combat movement with threat-aware dodging.
|
|
50
|
+
*/
|
|
51
|
+
function combatMove(
|
|
52
|
+
position: {x: number; y: number},
|
|
53
|
+
enemy: {position: {x: number; y: number}},
|
|
54
|
+
currentDistance: number,
|
|
55
|
+
targetDistance: number,
|
|
56
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
57
|
+
tick: number,
|
|
58
|
+
strafeCyclePeriod: number,
|
|
59
|
+
strafeIntensity: number,
|
|
60
|
+
approachSpeed: number,
|
|
61
|
+
distanceDeadZone: number,
|
|
62
|
+
): {x: number; y: number}
|
|
63
|
+
{
|
|
64
|
+
const deltaX = enemy.position.x - position.x;
|
|
65
|
+
const deltaY = enemy.position.y - position.y;
|
|
66
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
67
|
+
|
|
68
|
+
const strafeClockwise = {
|
|
69
|
+
x: -deltaY / normalizedDistance,
|
|
70
|
+
y: deltaX / normalizedDistance,
|
|
71
|
+
};
|
|
72
|
+
const strafeCounterClockwise = {
|
|
73
|
+
x: deltaY / normalizedDistance,
|
|
74
|
+
y: -deltaX / normalizedDistance,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let strafeDirection: {x: number; y: number};
|
|
78
|
+
|
|
79
|
+
if (dodgeDirection)
|
|
80
|
+
{
|
|
81
|
+
strafeDirection = dodgeDirection;
|
|
82
|
+
}
|
|
83
|
+
else if (isNearWall(position))
|
|
84
|
+
{
|
|
85
|
+
const futureClockwise = {
|
|
86
|
+
x: position.x + strafeClockwise.x * 100,
|
|
87
|
+
y: position.y + strafeClockwise.y * 100,
|
|
88
|
+
};
|
|
89
|
+
const futureCounterClockwise = {
|
|
90
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
91
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
92
|
+
};
|
|
93
|
+
const scoreClockwise = Math.min(
|
|
94
|
+
futureClockwise.x,
|
|
95
|
+
ARENA_SIZE - futureClockwise.x,
|
|
96
|
+
futureClockwise.y,
|
|
97
|
+
ARENA_SIZE - futureClockwise.y,
|
|
98
|
+
);
|
|
99
|
+
const scoreCounterClockwise = Math.min(
|
|
100
|
+
futureCounterClockwise.x,
|
|
101
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
102
|
+
futureCounterClockwise.y,
|
|
103
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
104
|
+
);
|
|
105
|
+
strafeDirection =
|
|
106
|
+
scoreClockwise > scoreCounterClockwise
|
|
107
|
+
? strafeClockwise
|
|
108
|
+
: strafeCounterClockwise;
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
{
|
|
112
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
113
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (dodgeDirection)
|
|
117
|
+
{
|
|
118
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
122
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
123
|
+
|
|
124
|
+
if (currentDistance > targetDistance + distanceDeadZone)
|
|
125
|
+
{
|
|
126
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
127
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
128
|
+
}
|
|
129
|
+
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
130
|
+
{
|
|
131
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
132
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
133
|
+
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
134
|
+
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
135
|
+
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
136
|
+
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
137
|
+
moveX += retreatX;
|
|
138
|
+
moveY += retreatY;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
142
|
+
if (magnitude > 100)
|
|
143
|
+
{
|
|
144
|
+
moveX = (moveX / magnitude) * 100;
|
|
145
|
+
moveY = (moveY / magnitude) * 100;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {x: moveX, y: moveY};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bot: Archmage
|
|
153
|
+
*
|
|
154
|
+
* BEHAVIOR: Versatile duelist that adapts missile choice based on distance and HP.
|
|
155
|
+
* Close range → straight missiles (no turn cost = more damage). Mid/far range → homing.
|
|
156
|
+
* Uses dual-blink aggressively (gap-close during vulnerability, escape when trade is bad).
|
|
157
|
+
* HP-aware: ahead → aggressive close range; behind → defensive ranged kiting.
|
|
158
|
+
*
|
|
159
|
+
* KEY IMPROVEMENTS OVER WARMAGE:
|
|
160
|
+
* - Range-adaptive missiles: straight close, homing far
|
|
161
|
+
* - Vulnerability-timed blinks: gap-close during enemy cast/GCD
|
|
162
|
+
* - HP-aware aggression: adjusts distance + risk tolerance based on HP differential
|
|
163
|
+
* - Progressive cast-cancel: graduated thresholds
|
|
164
|
+
*
|
|
165
|
+
* PROGRESSION LINE: Battlemage → Warmage → Archmage
|
|
166
|
+
* TIER: 3 (elite Duelist line)
|
|
167
|
+
*/
|
|
168
|
+
export const Archmage: WizardFunction = ({state}) =>
|
|
169
|
+
{
|
|
170
|
+
const targetDistance = useParam('targetDistance', 214, {
|
|
171
|
+
range: 150,
|
|
172
|
+
min: 0,
|
|
173
|
+
});
|
|
174
|
+
const minTurnRate = useParam('minTurnRate', -14.6, {range: 1.5, steps: 5});
|
|
175
|
+
const blinkEscapeDistance = useParam('blinkEscapeDistance', 442, {
|
|
176
|
+
range: 190,
|
|
177
|
+
min: 0,
|
|
178
|
+
steps: 7,
|
|
179
|
+
});
|
|
180
|
+
const punishMinVulnerability = useParam('punishMinVulnerability', 85, {
|
|
181
|
+
range: 30,
|
|
182
|
+
min: 10,
|
|
183
|
+
steps: 5,
|
|
184
|
+
});
|
|
185
|
+
const aggressiveHPThreshold = useParam('aggressiveHPThreshold', 4, {
|
|
186
|
+
range: 20,
|
|
187
|
+
min: 0,
|
|
188
|
+
steps: 5,
|
|
189
|
+
});
|
|
190
|
+
const preferBlinkDodge =
|
|
191
|
+
useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
|
|
192
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 248, {
|
|
193
|
+
range: 75,
|
|
194
|
+
min: 80,
|
|
195
|
+
max: 250,
|
|
196
|
+
});
|
|
197
|
+
const strafeIntensity = useParam('strafeIntensity', 100, {
|
|
198
|
+
range: 40,
|
|
199
|
+
min: 20,
|
|
200
|
+
max: 100,
|
|
201
|
+
steps: 7,
|
|
202
|
+
});
|
|
203
|
+
const approachSpeed = useParam('approachSpeed', 57, {
|
|
204
|
+
range: 30,
|
|
205
|
+
min: 10,
|
|
206
|
+
max: 80,
|
|
207
|
+
steps: 7,
|
|
208
|
+
});
|
|
209
|
+
const distanceDeadZone = useParam('distanceDeadZone', 37, {
|
|
210
|
+
range: 30,
|
|
211
|
+
min: 10,
|
|
212
|
+
max: 80,
|
|
213
|
+
steps: 7,
|
|
214
|
+
});
|
|
215
|
+
const aggressiveDistanceOffset = useParam('aggressiveDistanceOffset', 99, {
|
|
216
|
+
range: 50,
|
|
217
|
+
min: 0,
|
|
218
|
+
max: 100,
|
|
219
|
+
steps: 7,
|
|
220
|
+
});
|
|
221
|
+
const cautiousDistanceOffset = useParam('cautiousDistanceOffset', 42, {
|
|
222
|
+
range: 50,
|
|
223
|
+
min: 0,
|
|
224
|
+
max: 120,
|
|
225
|
+
steps: 7,
|
|
226
|
+
});
|
|
227
|
+
const cancelHeavyDmg = useParam('cancelHeavyDmg', 40, {
|
|
228
|
+
range: 15,
|
|
229
|
+
min: 5,
|
|
230
|
+
max: 40,
|
|
231
|
+
steps: 5,
|
|
232
|
+
});
|
|
233
|
+
const cancelMediumDmg = useParam('cancelMediumDmg', 20, {
|
|
234
|
+
range: 10,
|
|
235
|
+
min: 3,
|
|
236
|
+
max: 25,
|
|
237
|
+
steps: 5,
|
|
238
|
+
});
|
|
239
|
+
const cancelCastRatio = useParam('cancelCastRatio', 0.74, {
|
|
240
|
+
range: 0.4,
|
|
241
|
+
min: 0.3,
|
|
242
|
+
max: 1.0,
|
|
243
|
+
steps: 5,
|
|
244
|
+
});
|
|
245
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
|
|
246
|
+
range: 75,
|
|
247
|
+
min: 50,
|
|
248
|
+
max: 300,
|
|
249
|
+
});
|
|
250
|
+
const blinkWindowMax = useParam('blinkWindowMax', 29, {
|
|
251
|
+
range: 40,
|
|
252
|
+
min: 20,
|
|
253
|
+
max: 120,
|
|
254
|
+
steps: 7,
|
|
255
|
+
});
|
|
256
|
+
const blinkGapThreshold = useParam('blinkGapThreshold', 80, {
|
|
257
|
+
range: 75,
|
|
258
|
+
min: 20,
|
|
259
|
+
max: 200,
|
|
260
|
+
steps: 7,
|
|
261
|
+
});
|
|
262
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 4.5, {
|
|
263
|
+
range: 4,
|
|
264
|
+
min: 2,
|
|
265
|
+
max: 12,
|
|
266
|
+
steps: 5,
|
|
267
|
+
});
|
|
268
|
+
const closeRangeStraightThreshold = useParam(
|
|
269
|
+
'closeRangeStraightThreshold',
|
|
270
|
+
50,
|
|
271
|
+
{range: 100, min: 50, max: 300, steps: 7},
|
|
272
|
+
);
|
|
273
|
+
const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
|
|
274
|
+
range: 25,
|
|
275
|
+
min: 10,
|
|
276
|
+
max: 80,
|
|
277
|
+
steps: 5,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const enemy = state.enemies[0];
|
|
281
|
+
if (!enemy)
|
|
282
|
+
{
|
|
283
|
+
return {move: {x: 0, y: 0}};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
287
|
+
const threats = getThreats(state);
|
|
288
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
289
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
290
|
+
|
|
291
|
+
// HP-aware distance: ahead → fight closer, behind → kite at range
|
|
292
|
+
const hpDiff = state.health - enemy.health;
|
|
293
|
+
const isAggressive = hpDiff >= -aggressiveHPThreshold;
|
|
294
|
+
const effectiveTargetDistance = isAggressive
|
|
295
|
+
? targetDistance - aggressiveDistanceOffset
|
|
296
|
+
: targetDistance + cautiousDistanceOffset;
|
|
297
|
+
|
|
298
|
+
const move = combatMove(
|
|
299
|
+
state.position,
|
|
300
|
+
enemy,
|
|
301
|
+
distance,
|
|
302
|
+
effectiveTargetDistance,
|
|
303
|
+
dodgeDirection,
|
|
304
|
+
state.tick,
|
|
305
|
+
strafeCyclePeriod,
|
|
306
|
+
strafeIntensity,
|
|
307
|
+
approachSpeed,
|
|
308
|
+
distanceDeadZone,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// === STEP 1: CHANNELING ===
|
|
312
|
+
if (state.state === 'channeling')
|
|
313
|
+
{
|
|
314
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
315
|
+
{
|
|
316
|
+
return {move, cancel: true};
|
|
317
|
+
}
|
|
318
|
+
return {move: {x: 0, y: 0}};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// === STEP 2: PROGRESSIVE CAST-CANCEL ===
|
|
322
|
+
if (
|
|
323
|
+
state.state === 'casting' &&
|
|
324
|
+
state.castingSpell === 'missile' &&
|
|
325
|
+
urgentThreat &&
|
|
326
|
+
!urgentThreat.bestDodgeDirection
|
|
327
|
+
)
|
|
328
|
+
{
|
|
329
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
330
|
+
const defenseTime =
|
|
331
|
+
state.blinkCooldown === 0
|
|
332
|
+
? BLINK_CAST_TIME
|
|
333
|
+
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
334
|
+
const willArriveInWindow =
|
|
335
|
+
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
|
|
336
|
+
|
|
337
|
+
if (willArriveInWindow)
|
|
338
|
+
{
|
|
339
|
+
const incomingDamage = urgentThreat.projectile.damage;
|
|
340
|
+
const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
|
|
341
|
+
|
|
342
|
+
if (incomingDamage >= state.health || incomingDamage >= cancelHeavyDmg)
|
|
343
|
+
{
|
|
344
|
+
return {move, cancel: true};
|
|
345
|
+
}
|
|
346
|
+
if (incomingDamage >= cancelMediumDmg && castRatio < cancelCastRatio)
|
|
347
|
+
{
|
|
348
|
+
return {move, cancel: true};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
|
|
354
|
+
if (
|
|
355
|
+
preferBlinkDodge &&
|
|
356
|
+
state.state === 'idle' &&
|
|
357
|
+
urgentThreat &&
|
|
358
|
+
state.blinkCooldown === 0 &&
|
|
359
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
360
|
+
urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
|
|
361
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
362
|
+
)
|
|
363
|
+
{
|
|
364
|
+
// Aggressive: blink toward enemy to dodge + close gap
|
|
365
|
+
// Defensive: blink away to dodge + increase distance
|
|
366
|
+
if (isAggressive && distance > effectiveTargetDistance)
|
|
367
|
+
{
|
|
368
|
+
const blinkTarget = getBlinkToDecreaseDistance(
|
|
369
|
+
state.position,
|
|
370
|
+
enemy.position,
|
|
371
|
+
state.projectiles,
|
|
372
|
+
);
|
|
373
|
+
if (blinkTarget)
|
|
374
|
+
{
|
|
375
|
+
return {
|
|
376
|
+
move: {x: 0, y: 0},
|
|
377
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const blinkTarget = getBlinkToIncreaseDistance(
|
|
382
|
+
state.position,
|
|
383
|
+
enemy.position,
|
|
384
|
+
state.projectiles,
|
|
385
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
386
|
+
);
|
|
387
|
+
if (blinkTarget)
|
|
388
|
+
{
|
|
389
|
+
return {
|
|
390
|
+
move: {x: 0, y: 0},
|
|
391
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// === STEP 4: BLINK ESCAPE — enemy too close ===
|
|
397
|
+
if (
|
|
398
|
+
state.state === 'idle' &&
|
|
399
|
+
distance < blinkEscapeDistance &&
|
|
400
|
+
state.blinkCooldown === 0 &&
|
|
401
|
+
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
402
|
+
)
|
|
403
|
+
{
|
|
404
|
+
const escapeTarget = getBlinkToIncreaseDistance(
|
|
405
|
+
state.position,
|
|
406
|
+
enemy.position,
|
|
407
|
+
state.projectiles,
|
|
408
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
409
|
+
);
|
|
410
|
+
if (escapeTarget)
|
|
411
|
+
{
|
|
412
|
+
return {
|
|
413
|
+
move: {x: 0, y: 0},
|
|
414
|
+
startCast: {spell: 'blink', target: escapeTarget},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// === STEP 5: SHIELD ===
|
|
420
|
+
if (
|
|
421
|
+
state.state === 'idle' &&
|
|
422
|
+
urgentThreat &&
|
|
423
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
424
|
+
)
|
|
425
|
+
{
|
|
426
|
+
if (
|
|
427
|
+
urgentThreat.canBlockInTime &&
|
|
428
|
+
urgentThreat.ticksToStartShield <= SHIELD_BUFFER &&
|
|
429
|
+
(state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
|
|
430
|
+
)
|
|
431
|
+
{
|
|
432
|
+
return {
|
|
433
|
+
move: {x: 0, y: 0},
|
|
434
|
+
startCast: {spell: 'shield'},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// === STEP 6: VULNERABILITY BLINK — gap-close during enemy cast/GCD ===
|
|
440
|
+
if (
|
|
441
|
+
state.state === 'idle' &&
|
|
442
|
+
state.blinkCooldown === 0 &&
|
|
443
|
+
isAggressive &&
|
|
444
|
+
distance > effectiveTargetDistance + blinkGapThreshold &&
|
|
445
|
+
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
446
|
+
)
|
|
447
|
+
{
|
|
448
|
+
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
449
|
+
if (vulnerabilityTicks >= punishMinVulnerability)
|
|
450
|
+
{
|
|
451
|
+
const blinkTarget = getBlinkToDecreaseDistance(
|
|
452
|
+
state.position,
|
|
453
|
+
enemy.position,
|
|
454
|
+
state.projectiles,
|
|
455
|
+
);
|
|
456
|
+
if (blinkTarget)
|
|
457
|
+
{
|
|
458
|
+
return {
|
|
459
|
+
move: {x: 0, y: 0},
|
|
460
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// === STEP 7: OFFENSE ===
|
|
467
|
+
if (state.state === 'idle' && distance < 500)
|
|
468
|
+
{
|
|
469
|
+
const offenseDefenseTime =
|
|
470
|
+
state.blinkCooldown === 0
|
|
471
|
+
? BLINK_CAST_TIME
|
|
472
|
+
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
473
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
474
|
+
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
475
|
+
|
|
476
|
+
// --- PUNISH MODE: range-adaptive missiles during vulnerability ---
|
|
477
|
+
if (vulnerabilityTicks >= punishMinVulnerability)
|
|
478
|
+
{
|
|
479
|
+
const flightTimeEstimate = distance / flightTimeDivisor;
|
|
480
|
+
const punishBudget = vulnerabilityTicks - flightTimeEstimate;
|
|
481
|
+
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
482
|
+
const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
|
|
483
|
+
const effectiveBudget = Math.min(
|
|
484
|
+
punishBudget,
|
|
485
|
+
safeBudget,
|
|
486
|
+
MAX_CAST_BUDGET,
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
if (effectiveBudget > 0)
|
|
490
|
+
{
|
|
491
|
+
// Close range → straight for max damage, far → light homing
|
|
492
|
+
const punishTurnRate = distance < closeRangeStraightThreshold ? 0 : 0.2;
|
|
493
|
+
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
494
|
+
minTurnRate: punishTurnRate,
|
|
495
|
+
maxDamage: enemyHP,
|
|
496
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
500
|
+
{
|
|
501
|
+
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
502
|
+
const actualFlight = distance / config.speed;
|
|
503
|
+
|
|
504
|
+
if (
|
|
505
|
+
castTime + actualFlight < vulnerabilityTicks &&
|
|
506
|
+
isSafeToAttack(threats, castTime)
|
|
507
|
+
)
|
|
508
|
+
{
|
|
509
|
+
const aimTarget =
|
|
510
|
+
config.turnRate > 0
|
|
511
|
+
? enemy.position
|
|
512
|
+
: getLeadPosition(
|
|
513
|
+
enemy.position,
|
|
514
|
+
enemy.velocity,
|
|
515
|
+
config.speed,
|
|
516
|
+
state.position,
|
|
517
|
+
);
|
|
518
|
+
return {
|
|
519
|
+
move,
|
|
520
|
+
startCast: {
|
|
521
|
+
spell: 'missile',
|
|
522
|
+
config,
|
|
523
|
+
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
524
|
+
direction: angleTo(state.position, aimTarget),
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// --- STANDARD MODE: range-adaptive missiles ---
|
|
533
|
+
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
534
|
+
const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
|
|
535
|
+
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
536
|
+
|
|
537
|
+
// Adapt turn rate to distance: close → straight, far → homing
|
|
538
|
+
const effectiveMinTurnRate =
|
|
539
|
+
distance < closeRangeStraightThreshold ? 0 : minTurnRate;
|
|
540
|
+
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
541
|
+
let config = fitMissileToBudget(budgetTicks, distance, {
|
|
542
|
+
minTurnRate: effectiveMinTurnRate,
|
|
543
|
+
maxDamage: enemyHP,
|
|
544
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
if (
|
|
548
|
+
config &&
|
|
549
|
+
config.damage >= minDmg &&
|
|
550
|
+
isSafeToAttack(threats, safeAttackCastEstimate)
|
|
551
|
+
)
|
|
552
|
+
{
|
|
553
|
+
return {
|
|
554
|
+
move,
|
|
555
|
+
startCast: {
|
|
556
|
+
spell: 'missile',
|
|
557
|
+
config,
|
|
558
|
+
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
559
|
+
direction: angleTo(state.position, enemy.position),
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Fallback: fire bigger missile, accept incoming hit
|
|
565
|
+
if (!config || config.damage < minDmg)
|
|
566
|
+
{
|
|
567
|
+
config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
|
|
568
|
+
minTurnRate: effectiveMinTurnRate,
|
|
569
|
+
maxDamage: enemyHP,
|
|
570
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
571
|
+
});
|
|
572
|
+
if (config)
|
|
573
|
+
{
|
|
574
|
+
return {
|
|
575
|
+
move,
|
|
576
|
+
startCast: {
|
|
577
|
+
spell: 'missile',
|
|
578
|
+
config,
|
|
579
|
+
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
580
|
+
direction: angleTo(state.position, enemy.position),
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return {move};
|
|
588
|
+
};
|