@vibemancer/core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-OL7ETV6V.js} +3 -3
- package/dist/chunk-OL7ETV6V.js.map +1 -0
- package/dist/index-browser.d.ts +1 -1
- package/dist/index-browser.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +79 -78
- package/src/bots/berserker/01_Stormchaser.ts +457 -457
- package/src/bots/berserker/02_Stormcaller.ts +417 -417
- package/src/bots/berserker/03_Stormforger.ts +481 -481
- package/src/bots/caster/01_Flamecaller.ts +286 -286
- package/src/bots/caster/02_Pyromancer.ts +350 -350
- package/src/bots/caster/03_Infernalist.ts +492 -492
- package/src/bots/defensive/01_Turtle.ts +151 -151
- package/src/bots/defensive/02_Sentinel.ts +134 -134
- package/src/bots/defensive/03_Golem.ts +357 -357
- package/src/bots/duelist/01_Battlemage.ts +433 -433
- package/src/bots/duelist/02_Warmage.ts +438 -438
- package/src/bots/duelist/03_Archmage.ts +588 -588
- package/src/bots/homing/01_Bonemancer.ts +67 -67
- package/src/bots/homing/02_Lich.ts +356 -356
- package/src/bots/homing/03_Archlich.ts +220 -220
- package/src/bots/kiter/01_Spellspinner.ts +398 -398
- package/src/bots/kiter/02_Spellweaver.ts +378 -378
- package/src/bots/kiter/03_Spellbinder.ts +448 -448
- package/src/bots/melee/01_Shadowblade.ts +270 -270
- package/src/bots/melee/02_Nightblade.ts +437 -437
- package/src/bots/melee/03_Voidblade.ts +582 -582
- package/src/bots/sniper/01_Spellshot.ts +385 -385
- package/src/bots/sniper/02_Spelltracer.ts +441 -441
- package/src/bots/sniper/03_Spellseeker.ts +546 -546
- package/src/bots/standalone/Critter.ts +89 -89
- package/src/bots/standalone/Doombringer.ts +91 -91
- package/src/bots/standalone/Hogger.ts +228 -228
- package/src/bots/standalone/Rookie.ts +50 -50
- package/src/bots/standalone/TargetDummy.ts +21 -21
- package/src/bots/test/cheater.ts +405 -405
- package/src/bots/test/crasher.ts +81 -81
- package/src/engine/hooks-runtime.ts +394 -394
- package/src/engine/manual-match.ts +289 -289
- package/src/engine/missile-templates.ts +155 -155
- package/src/engine/physics.ts +143 -143
- package/src/engine/simulation.ts +828 -828
- package/src/engine/spells.ts +128 -128
- package/src/engine-version.ts +1 -1
- package/src/index.ts +23 -23
- package/src/rules.ts +254 -254
- package/src/types.ts +193 -193
- package/src/utils/index.ts +6 -6
- package/dist/chunk-L7Z7OFXD.js.map +0 -1
|
@@ -1,448 +1,448 @@
|
|
|
1
|
-
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
-
import {angleTo} from '../../utils/angles.js';
|
|
3
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
-
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
5
|
-
import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
|
|
6
|
-
import {
|
|
7
|
-
getThreats,
|
|
8
|
-
getMostUrgentThreat,
|
|
9
|
-
getDodgeDirectionForMovement,
|
|
10
|
-
getBlinkToCenter,
|
|
11
|
-
getBlinkToIncreaseDistance,
|
|
12
|
-
fitMissileToBudget,
|
|
13
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
14
|
-
shouldForceShield,
|
|
15
|
-
MAX_CAST_BUDGET,
|
|
16
|
-
MIN_USEFUL_DAMAGE,
|
|
17
|
-
getEnemyVulnerabilityTicks,
|
|
18
|
-
} from '../shared.js';
|
|
19
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
20
|
-
const WALL_BUFFER = 60;
|
|
21
|
-
|
|
22
|
-
const SHIELD_BUFFER = 3;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Standard homing missile AI.
|
|
26
|
-
*/
|
|
27
|
-
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
28
|
-
{
|
|
29
|
-
const enemy = worldState.enemies[0];
|
|
30
|
-
return {
|
|
31
|
-
turnToward: enemy?.position,
|
|
32
|
-
};
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Straight missile AI — no homing, flies in initial direction.
|
|
37
|
-
*/
|
|
38
|
-
const StraightAI: MissileAIFunction = () => ({});
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Check if near wall.
|
|
42
|
-
*/
|
|
43
|
-
function isNearWall(position: {x: number; y: number}): boolean
|
|
44
|
-
{
|
|
45
|
-
return (
|
|
46
|
-
position.x < WALL_BUFFER ||
|
|
47
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
48
|
-
position.y < WALL_BUFFER ||
|
|
49
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Smart strafe with threat-aware dodging and wall avoidance.
|
|
55
|
-
*/
|
|
56
|
-
function smartStrafe(
|
|
57
|
-
position: {x: number; y: number},
|
|
58
|
-
enemy: {position: {x: number; y: number}},
|
|
59
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
60
|
-
tick: number,
|
|
61
|
-
strafeCyclePeriod: number,
|
|
62
|
-
strafeIntensity: number,
|
|
63
|
-
): {x: number; y: number}
|
|
64
|
-
{
|
|
65
|
-
const deltaX = enemy.position.x - position.x;
|
|
66
|
-
const deltaY = enemy.position.y - position.y;
|
|
67
|
-
const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
68
|
-
if (normalizedDistance === 0) return {x: 0, y: 0};
|
|
69
|
-
|
|
70
|
-
const strafeClockwise = {
|
|
71
|
-
x: -deltaY / normalizedDistance,
|
|
72
|
-
y: deltaX / normalizedDistance,
|
|
73
|
-
};
|
|
74
|
-
const strafeCounterClockwise = {
|
|
75
|
-
x: deltaY / normalizedDistance,
|
|
76
|
-
y: -deltaX / normalizedDistance,
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
let strafeDir: {x: number; y: number};
|
|
80
|
-
|
|
81
|
-
if (dodgeDirection)
|
|
82
|
-
{
|
|
83
|
-
strafeDir = dodgeDirection;
|
|
84
|
-
}
|
|
85
|
-
else if (isNearWall(position))
|
|
86
|
-
{
|
|
87
|
-
const futureClockwise = {
|
|
88
|
-
x: position.x + strafeClockwise.x * 100,
|
|
89
|
-
y: position.y + strafeClockwise.y * 100,
|
|
90
|
-
};
|
|
91
|
-
const futureCounterClockwise = {
|
|
92
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
93
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
94
|
-
};
|
|
95
|
-
const scoreClockwise = Math.min(
|
|
96
|
-
futureClockwise.x,
|
|
97
|
-
ARENA_SIZE - futureClockwise.x,
|
|
98
|
-
futureClockwise.y,
|
|
99
|
-
ARENA_SIZE - futureClockwise.y,
|
|
100
|
-
);
|
|
101
|
-
const scoreCounterClockwise = Math.min(
|
|
102
|
-
futureCounterClockwise.x,
|
|
103
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
104
|
-
futureCounterClockwise.y,
|
|
105
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
106
|
-
);
|
|
107
|
-
strafeDir =
|
|
108
|
-
scoreClockwise > scoreCounterClockwise
|
|
109
|
-
? strafeClockwise
|
|
110
|
-
: strafeCounterClockwise;
|
|
111
|
-
}
|
|
112
|
-
else
|
|
113
|
-
{
|
|
114
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
115
|
-
strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const intensity = dodgeDirection ? 100 : strafeIntensity;
|
|
119
|
-
return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Bot: Spellbinder
|
|
124
|
-
*
|
|
125
|
-
* BEHAVIOR: Enhanced Spellweaver with vulnerability exploitation. Same medium-range
|
|
126
|
-
* kiting playstyle — maintains distance, strafes heavily, uses fitMissileToBudget
|
|
127
|
-
* for adaptive homing missiles. The T3 upgrade adds a punish mode that fires fast
|
|
128
|
-
* straight missiles timed to land while the enemy is locked in a cast or GCD,
|
|
129
|
-
* when they cannot shield. Defense, movement, and standard offense are identical
|
|
130
|
-
* to Spellweaver.
|
|
131
|
-
*
|
|
132
|
-
* NAMING RATIONALE: A binder constrains and locks down opponents. Where the
|
|
133
|
-
* Spellweaver optimizes missile patterns (adaptive fitting), the Spellbinder
|
|
134
|
-
* reads the enemy's state and punishes vulnerability windows — binding them
|
|
135
|
-
* to their commitments with unavoidable damage.
|
|
136
|
-
*
|
|
137
|
-
* PROGRESSION LINE: Spellspinner → Spellweaver → Spellbinder
|
|
138
|
-
* - Spellspinner (tier 1): Fixed homing missiles, constant strafe, basic defense
|
|
139
|
-
* - Spellweaver (tier 2): + adaptive missile fitting, more sophisticated patterns
|
|
140
|
-
* - Spellbinder (tier 3): + vulnerability punish mode with fast straight missiles
|
|
141
|
-
*
|
|
142
|
-
* TIER: 3 (elite Spellspinner line)
|
|
143
|
-
*/
|
|
144
|
-
export const Spellbinder: WizardFunction = ({state}) =>
|
|
145
|
-
{
|
|
146
|
-
const targetDistance = useParam('targetDistance', 134, {
|
|
147
|
-
range: 150,
|
|
148
|
-
min: 0,
|
|
149
|
-
});
|
|
150
|
-
const dangerDistance = useParam('dangerDistance', 389, {
|
|
151
|
-
range: 125,
|
|
152
|
-
min: 0,
|
|
153
|
-
});
|
|
154
|
-
const minTurnRate = useParam('minTurnRate', 0.2, {range: 1.5, steps: 5});
|
|
155
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 123, {
|
|
156
|
-
range: 75,
|
|
157
|
-
min: 80,
|
|
158
|
-
max: 300,
|
|
159
|
-
});
|
|
160
|
-
const strafeIntensity = useParam('strafeIntensity', 30, {
|
|
161
|
-
range: 40,
|
|
162
|
-
min: 30,
|
|
163
|
-
max: 100,
|
|
164
|
-
steps: 7,
|
|
165
|
-
});
|
|
166
|
-
const approachSpeed = useParam('approachSpeed', 50, {
|
|
167
|
-
range: 30,
|
|
168
|
-
min: 10,
|
|
169
|
-
max: 70,
|
|
170
|
-
steps: 7,
|
|
171
|
-
});
|
|
172
|
-
const distanceDeadZone = useParam('distanceDeadZone', 7, {
|
|
173
|
-
range: 30,
|
|
174
|
-
min: 5,
|
|
175
|
-
max: 80,
|
|
176
|
-
steps: 7,
|
|
177
|
-
});
|
|
178
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
179
|
-
range: 75,
|
|
180
|
-
min: 50,
|
|
181
|
-
max: 300,
|
|
182
|
-
});
|
|
183
|
-
const flightTimeDivisor = useParam('flightTimeDivisor', 10.8, {
|
|
184
|
-
range: 4,
|
|
185
|
-
min: 2,
|
|
186
|
-
max: 12,
|
|
187
|
-
steps: 5,
|
|
188
|
-
});
|
|
189
|
-
const punishMinVulnerability = useParam('punishMinVulnerability', 30, {
|
|
190
|
-
range: 80,
|
|
191
|
-
min: 30,
|
|
192
|
-
steps: 7,
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
const enemy = state.enemies[0];
|
|
196
|
-
if (!enemy)
|
|
197
|
-
{
|
|
198
|
-
return {move: {x: 0, y: 0}};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
202
|
-
|
|
203
|
-
// Analyze threats using simulation
|
|
204
|
-
const threats = getThreats(state);
|
|
205
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
206
|
-
|
|
207
|
-
// Smart strafe
|
|
208
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
209
|
-
const strafe = smartStrafe(
|
|
210
|
-
state.position,
|
|
211
|
-
enemy,
|
|
212
|
-
dodgeDirection,
|
|
213
|
-
state.tick,
|
|
214
|
-
strafeCyclePeriod,
|
|
215
|
-
strafeIntensity,
|
|
216
|
-
);
|
|
217
|
-
|
|
218
|
-
// Distance management (only when not dodging)
|
|
219
|
-
let moveX = strafe.x;
|
|
220
|
-
let moveY = strafe.y;
|
|
221
|
-
|
|
222
|
-
if (!dodgeDirection)
|
|
223
|
-
{
|
|
224
|
-
const deltaX = enemy.position.x - state.position.x;
|
|
225
|
-
const deltaY = enemy.position.y - state.position.y;
|
|
226
|
-
const normalizedDistance = distance > 0 ? distance : 1;
|
|
227
|
-
|
|
228
|
-
if (distance > targetDistance + distanceDeadZone)
|
|
229
|
-
{
|
|
230
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
231
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
232
|
-
}
|
|
233
|
-
else if (distance < targetDistance - distanceDeadZone)
|
|
234
|
-
{
|
|
235
|
-
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
236
|
-
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
237
|
-
if (state.position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
238
|
-
if (state.position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0)
|
|
239
|
-
retreatX = 0;
|
|
240
|
-
if (state.position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
241
|
-
if (state.position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0)
|
|
242
|
-
retreatY = 0;
|
|
243
|
-
moveX += retreatX;
|
|
244
|
-
moveY += retreatY;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
249
|
-
if (magnitude > 100)
|
|
250
|
-
{
|
|
251
|
-
moveX = (moveX / magnitude) * 100;
|
|
252
|
-
moveY = (moveY / magnitude) * 100;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const move = {x: moveX, y: moveY};
|
|
256
|
-
|
|
257
|
-
// === DEFENSE: Handle channeling ===
|
|
258
|
-
if (state.state === 'channeling')
|
|
259
|
-
{
|
|
260
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
261
|
-
{
|
|
262
|
-
return {move, cancel: true};
|
|
263
|
-
}
|
|
264
|
-
return {move: {x: 0, y: 0}};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
|
|
268
|
-
// DPS trade: we accepted the hit when we started casting. Only cancel to survive.
|
|
269
|
-
if (
|
|
270
|
-
state.state === 'casting' &&
|
|
271
|
-
state.castingSpell === 'missile' &&
|
|
272
|
-
urgentThreat &&
|
|
273
|
-
urgentThreat.projectile.damage >= state.health
|
|
274
|
-
)
|
|
275
|
-
{
|
|
276
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
277
|
-
if (
|
|
278
|
-
urgentThreat.ticksToImpact <=
|
|
279
|
-
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
|
|
280
|
-
)
|
|
281
|
-
{
|
|
282
|
-
return {move, cancel: true};
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// === DEFENSE: Shield undodgeable or high-damage homing threats ===
|
|
287
|
-
if (
|
|
288
|
-
state.state === 'idle' &&
|
|
289
|
-
urgentThreat &&
|
|
290
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
291
|
-
)
|
|
292
|
-
{
|
|
293
|
-
if (urgentThreat.canBlockInTime)
|
|
294
|
-
{
|
|
295
|
-
if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
|
|
296
|
-
{
|
|
297
|
-
return {
|
|
298
|
-
move: {x: 0, y: 0},
|
|
299
|
-
startCast: {spell: 'shield'},
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
else if (state.blinkCooldown === 0)
|
|
304
|
-
{
|
|
305
|
-
return {
|
|
306
|
-
move: {x: 0, y: 0},
|
|
307
|
-
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
|
|
313
|
-
if (
|
|
314
|
-
state.state === 'idle' &&
|
|
315
|
-
distance < dangerDistance &&
|
|
316
|
-
state.blinkCooldown === 0 &&
|
|
317
|
-
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
318
|
-
)
|
|
319
|
-
{
|
|
320
|
-
const blinkTarget = getBlinkToIncreaseDistance(
|
|
321
|
-
state.position,
|
|
322
|
-
enemy.position,
|
|
323
|
-
state.projectiles,
|
|
324
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
325
|
-
);
|
|
326
|
-
if (blinkTarget)
|
|
327
|
-
{
|
|
328
|
-
return {
|
|
329
|
-
move: {x: 0, y: 0},
|
|
330
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// === OFFENSE: Punish mode — fast straight missiles during enemy vulnerability ===
|
|
336
|
-
if (
|
|
337
|
-
state.state === 'idle' &&
|
|
338
|
-
getEnemyVulnerabilityTicks(enemy) >= punishMinVulnerability &&
|
|
339
|
-
distance < 500
|
|
340
|
-
)
|
|
341
|
-
{
|
|
342
|
-
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
343
|
-
const flightTimeEstimate = distance / flightTimeDivisor;
|
|
344
|
-
const punishBudget = vulnerabilityTicks - flightTimeEstimate;
|
|
345
|
-
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
346
|
-
const safeBudget =
|
|
347
|
-
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
348
|
-
const effectiveBudget = Math.min(
|
|
349
|
-
punishBudget,
|
|
350
|
-
safeBudget,
|
|
351
|
-
MAX_CAST_BUDGET,
|
|
352
|
-
);
|
|
353
|
-
|
|
354
|
-
if (effectiveBudget > 0)
|
|
355
|
-
{
|
|
356
|
-
const enemyHP = Math.ceil(enemy.health);
|
|
357
|
-
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
358
|
-
minTurnRate: 0,
|
|
359
|
-
maxDamage: enemyHP,
|
|
360
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
364
|
-
{
|
|
365
|
-
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
366
|
-
const actualFlight = distance / config.speed;
|
|
367
|
-
|
|
368
|
-
if (castTime + actualFlight < vulnerabilityTicks)
|
|
369
|
-
{
|
|
370
|
-
const aimTarget =
|
|
371
|
-
config.turnRate > 0
|
|
372
|
-
? enemy.position
|
|
373
|
-
: getLeadPosition(
|
|
374
|
-
enemy.position,
|
|
375
|
-
enemy.velocity,
|
|
376
|
-
config.speed,
|
|
377
|
-
state.position,
|
|
378
|
-
);
|
|
379
|
-
return {
|
|
380
|
-
move,
|
|
381
|
-
startCast: {
|
|
382
|
-
spell: 'missile',
|
|
383
|
-
config,
|
|
384
|
-
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
385
|
-
direction: angleTo(state.position, aimTarget),
|
|
386
|
-
},
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// === OFFENSE: Adaptive missile fitting (identical to Spellweaver) ===
|
|
394
|
-
// Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
|
|
395
|
-
const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
|
|
396
|
-
const hasNearbyLethalMissile = state.projectiles.some(
|
|
397
|
-
(p) =>
|
|
398
|
-
!myProjectileIds.has(p.id) &&
|
|
399
|
-
p.damage >= state.health &&
|
|
400
|
-
distanceTo(state.position, p.position) / p.speed <
|
|
401
|
-
MAX_CAST_BUDGET + GCD_DURATION,
|
|
402
|
-
);
|
|
403
|
-
if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
|
|
404
|
-
{
|
|
405
|
-
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
406
|
-
|
|
407
|
-
// Account for enemy's active cast
|
|
408
|
-
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
409
|
-
{
|
|
410
|
-
const remainingEnemyCast =
|
|
411
|
-
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
412
|
-
const estimatedFlightTime = distance / flightTimeDivisor;
|
|
413
|
-
nextThreatTime = Math.min(
|
|
414
|
-
nextThreatTime,
|
|
415
|
-
remainingEnemyCast + estimatedFlightTime,
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
const safeBudget =
|
|
419
|
-
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
420
|
-
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
421
|
-
|
|
422
|
-
// Spellbinder always uses homing in standard mode (kiting means enemies are always moving)
|
|
423
|
-
const enemyHP = Math.ceil(enemy.health);
|
|
424
|
-
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
425
|
-
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
426
|
-
minTurnRate,
|
|
427
|
-
maxDamage: enemyHP,
|
|
428
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
if (config && config.damage >= minDmg)
|
|
432
|
-
{
|
|
433
|
-
return {
|
|
434
|
-
move,
|
|
435
|
-
startCast: {
|
|
436
|
-
spell: 'missile',
|
|
437
|
-
config,
|
|
438
|
-
missileAI: HomingAI,
|
|
439
|
-
direction: angleTo(state.position, enemy.position),
|
|
440
|
-
},
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Budget too small for useful missile — wait for threat to pass, then fire
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
return {move};
|
|
448
|
-
};
|
|
1
|
+
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
+
import {angleTo} from '../../utils/angles.js';
|
|
3
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
+
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
5
|
+
import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
|
|
6
|
+
import {
|
|
7
|
+
getThreats,
|
|
8
|
+
getMostUrgentThreat,
|
|
9
|
+
getDodgeDirectionForMovement,
|
|
10
|
+
getBlinkToCenter,
|
|
11
|
+
getBlinkToIncreaseDistance,
|
|
12
|
+
fitMissileToBudget,
|
|
13
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
14
|
+
shouldForceShield,
|
|
15
|
+
MAX_CAST_BUDGET,
|
|
16
|
+
MIN_USEFUL_DAMAGE,
|
|
17
|
+
getEnemyVulnerabilityTicks,
|
|
18
|
+
} from '../shared.js';
|
|
19
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
20
|
+
const WALL_BUFFER = 60;
|
|
21
|
+
|
|
22
|
+
const SHIELD_BUFFER = 3;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Standard homing missile AI.
|
|
26
|
+
*/
|
|
27
|
+
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
28
|
+
{
|
|
29
|
+
const enemy = worldState.enemies[0];
|
|
30
|
+
return {
|
|
31
|
+
turnToward: enemy?.position,
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Straight missile AI — no homing, flies in initial direction.
|
|
37
|
+
*/
|
|
38
|
+
const StraightAI: MissileAIFunction = () => ({});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if near wall.
|
|
42
|
+
*/
|
|
43
|
+
function isNearWall(position: {x: number; y: number}): boolean
|
|
44
|
+
{
|
|
45
|
+
return (
|
|
46
|
+
position.x < WALL_BUFFER ||
|
|
47
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
48
|
+
position.y < WALL_BUFFER ||
|
|
49
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Smart strafe with threat-aware dodging and wall avoidance.
|
|
55
|
+
*/
|
|
56
|
+
function smartStrafe(
|
|
57
|
+
position: {x: number; y: number},
|
|
58
|
+
enemy: {position: {x: number; y: number}},
|
|
59
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
60
|
+
tick: number,
|
|
61
|
+
strafeCyclePeriod: number,
|
|
62
|
+
strafeIntensity: number,
|
|
63
|
+
): {x: number; y: number}
|
|
64
|
+
{
|
|
65
|
+
const deltaX = enemy.position.x - position.x;
|
|
66
|
+
const deltaY = enemy.position.y - position.y;
|
|
67
|
+
const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
68
|
+
if (normalizedDistance === 0) return {x: 0, y: 0};
|
|
69
|
+
|
|
70
|
+
const strafeClockwise = {
|
|
71
|
+
x: -deltaY / normalizedDistance,
|
|
72
|
+
y: deltaX / normalizedDistance,
|
|
73
|
+
};
|
|
74
|
+
const strafeCounterClockwise = {
|
|
75
|
+
x: deltaY / normalizedDistance,
|
|
76
|
+
y: -deltaX / normalizedDistance,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
let strafeDir: {x: number; y: number};
|
|
80
|
+
|
|
81
|
+
if (dodgeDirection)
|
|
82
|
+
{
|
|
83
|
+
strafeDir = dodgeDirection;
|
|
84
|
+
}
|
|
85
|
+
else if (isNearWall(position))
|
|
86
|
+
{
|
|
87
|
+
const futureClockwise = {
|
|
88
|
+
x: position.x + strafeClockwise.x * 100,
|
|
89
|
+
y: position.y + strafeClockwise.y * 100,
|
|
90
|
+
};
|
|
91
|
+
const futureCounterClockwise = {
|
|
92
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
93
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
94
|
+
};
|
|
95
|
+
const scoreClockwise = Math.min(
|
|
96
|
+
futureClockwise.x,
|
|
97
|
+
ARENA_SIZE - futureClockwise.x,
|
|
98
|
+
futureClockwise.y,
|
|
99
|
+
ARENA_SIZE - futureClockwise.y,
|
|
100
|
+
);
|
|
101
|
+
const scoreCounterClockwise = Math.min(
|
|
102
|
+
futureCounterClockwise.x,
|
|
103
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
104
|
+
futureCounterClockwise.y,
|
|
105
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
106
|
+
);
|
|
107
|
+
strafeDir =
|
|
108
|
+
scoreClockwise > scoreCounterClockwise
|
|
109
|
+
? strafeClockwise
|
|
110
|
+
: strafeCounterClockwise;
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
{
|
|
114
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
115
|
+
strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const intensity = dodgeDirection ? 100 : strafeIntensity;
|
|
119
|
+
return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Bot: Spellbinder
|
|
124
|
+
*
|
|
125
|
+
* BEHAVIOR: Enhanced Spellweaver with vulnerability exploitation. Same medium-range
|
|
126
|
+
* kiting playstyle — maintains distance, strafes heavily, uses fitMissileToBudget
|
|
127
|
+
* for adaptive homing missiles. The T3 upgrade adds a punish mode that fires fast
|
|
128
|
+
* straight missiles timed to land while the enemy is locked in a cast or GCD,
|
|
129
|
+
* when they cannot shield. Defense, movement, and standard offense are identical
|
|
130
|
+
* to Spellweaver.
|
|
131
|
+
*
|
|
132
|
+
* NAMING RATIONALE: A binder constrains and locks down opponents. Where the
|
|
133
|
+
* Spellweaver optimizes missile patterns (adaptive fitting), the Spellbinder
|
|
134
|
+
* reads the enemy's state and punishes vulnerability windows — binding them
|
|
135
|
+
* to their commitments with unavoidable damage.
|
|
136
|
+
*
|
|
137
|
+
* PROGRESSION LINE: Spellspinner → Spellweaver → Spellbinder
|
|
138
|
+
* - Spellspinner (tier 1): Fixed homing missiles, constant strafe, basic defense
|
|
139
|
+
* - Spellweaver (tier 2): + adaptive missile fitting, more sophisticated patterns
|
|
140
|
+
* - Spellbinder (tier 3): + vulnerability punish mode with fast straight missiles
|
|
141
|
+
*
|
|
142
|
+
* TIER: 3 (elite Spellspinner line)
|
|
143
|
+
*/
|
|
144
|
+
export const Spellbinder: WizardFunction = ({state}) =>
|
|
145
|
+
{
|
|
146
|
+
const targetDistance = useParam('targetDistance', 134, {
|
|
147
|
+
range: 150,
|
|
148
|
+
min: 0,
|
|
149
|
+
});
|
|
150
|
+
const dangerDistance = useParam('dangerDistance', 389, {
|
|
151
|
+
range: 125,
|
|
152
|
+
min: 0,
|
|
153
|
+
});
|
|
154
|
+
const minTurnRate = useParam('minTurnRate', 0.2, {range: 1.5, steps: 5});
|
|
155
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 123, {
|
|
156
|
+
range: 75,
|
|
157
|
+
min: 80,
|
|
158
|
+
max: 300,
|
|
159
|
+
});
|
|
160
|
+
const strafeIntensity = useParam('strafeIntensity', 30, {
|
|
161
|
+
range: 40,
|
|
162
|
+
min: 30,
|
|
163
|
+
max: 100,
|
|
164
|
+
steps: 7,
|
|
165
|
+
});
|
|
166
|
+
const approachSpeed = useParam('approachSpeed', 50, {
|
|
167
|
+
range: 30,
|
|
168
|
+
min: 10,
|
|
169
|
+
max: 70,
|
|
170
|
+
steps: 7,
|
|
171
|
+
});
|
|
172
|
+
const distanceDeadZone = useParam('distanceDeadZone', 7, {
|
|
173
|
+
range: 30,
|
|
174
|
+
min: 5,
|
|
175
|
+
max: 80,
|
|
176
|
+
steps: 7,
|
|
177
|
+
});
|
|
178
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
179
|
+
range: 75,
|
|
180
|
+
min: 50,
|
|
181
|
+
max: 300,
|
|
182
|
+
});
|
|
183
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 10.8, {
|
|
184
|
+
range: 4,
|
|
185
|
+
min: 2,
|
|
186
|
+
max: 12,
|
|
187
|
+
steps: 5,
|
|
188
|
+
});
|
|
189
|
+
const punishMinVulnerability = useParam('punishMinVulnerability', 30, {
|
|
190
|
+
range: 80,
|
|
191
|
+
min: 30,
|
|
192
|
+
steps: 7,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const enemy = state.enemies[0];
|
|
196
|
+
if (!enemy)
|
|
197
|
+
{
|
|
198
|
+
return {move: {x: 0, y: 0}};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
202
|
+
|
|
203
|
+
// Analyze threats using simulation
|
|
204
|
+
const threats = getThreats(state);
|
|
205
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
206
|
+
|
|
207
|
+
// Smart strafe
|
|
208
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
209
|
+
const strafe = smartStrafe(
|
|
210
|
+
state.position,
|
|
211
|
+
enemy,
|
|
212
|
+
dodgeDirection,
|
|
213
|
+
state.tick,
|
|
214
|
+
strafeCyclePeriod,
|
|
215
|
+
strafeIntensity,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Distance management (only when not dodging)
|
|
219
|
+
let moveX = strafe.x;
|
|
220
|
+
let moveY = strafe.y;
|
|
221
|
+
|
|
222
|
+
if (!dodgeDirection)
|
|
223
|
+
{
|
|
224
|
+
const deltaX = enemy.position.x - state.position.x;
|
|
225
|
+
const deltaY = enemy.position.y - state.position.y;
|
|
226
|
+
const normalizedDistance = distance > 0 ? distance : 1;
|
|
227
|
+
|
|
228
|
+
if (distance > targetDistance + distanceDeadZone)
|
|
229
|
+
{
|
|
230
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
231
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
232
|
+
}
|
|
233
|
+
else if (distance < targetDistance - distanceDeadZone)
|
|
234
|
+
{
|
|
235
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
236
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
237
|
+
if (state.position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
238
|
+
if (state.position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0)
|
|
239
|
+
retreatX = 0;
|
|
240
|
+
if (state.position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
241
|
+
if (state.position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0)
|
|
242
|
+
retreatY = 0;
|
|
243
|
+
moveX += retreatX;
|
|
244
|
+
moveY += retreatY;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
249
|
+
if (magnitude > 100)
|
|
250
|
+
{
|
|
251
|
+
moveX = (moveX / magnitude) * 100;
|
|
252
|
+
moveY = (moveY / magnitude) * 100;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const move = {x: moveX, y: moveY};
|
|
256
|
+
|
|
257
|
+
// === DEFENSE: Handle channeling ===
|
|
258
|
+
if (state.state === 'channeling')
|
|
259
|
+
{
|
|
260
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
261
|
+
{
|
|
262
|
+
return {move, cancel: true};
|
|
263
|
+
}
|
|
264
|
+
return {move: {x: 0, y: 0}};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
|
|
268
|
+
// DPS trade: we accepted the hit when we started casting. Only cancel to survive.
|
|
269
|
+
if (
|
|
270
|
+
state.state === 'casting' &&
|
|
271
|
+
state.castingSpell === 'missile' &&
|
|
272
|
+
urgentThreat &&
|
|
273
|
+
urgentThreat.projectile.damage >= state.health
|
|
274
|
+
)
|
|
275
|
+
{
|
|
276
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
277
|
+
if (
|
|
278
|
+
urgentThreat.ticksToImpact <=
|
|
279
|
+
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
|
|
280
|
+
)
|
|
281
|
+
{
|
|
282
|
+
return {move, cancel: true};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// === DEFENSE: Shield undodgeable or high-damage homing threats ===
|
|
287
|
+
if (
|
|
288
|
+
state.state === 'idle' &&
|
|
289
|
+
urgentThreat &&
|
|
290
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
291
|
+
)
|
|
292
|
+
{
|
|
293
|
+
if (urgentThreat.canBlockInTime)
|
|
294
|
+
{
|
|
295
|
+
if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
|
|
296
|
+
{
|
|
297
|
+
return {
|
|
298
|
+
move: {x: 0, y: 0},
|
|
299
|
+
startCast: {spell: 'shield'},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else if (state.blinkCooldown === 0)
|
|
304
|
+
{
|
|
305
|
+
return {
|
|
306
|
+
move: {x: 0, y: 0},
|
|
307
|
+
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
|
|
313
|
+
if (
|
|
314
|
+
state.state === 'idle' &&
|
|
315
|
+
distance < dangerDistance &&
|
|
316
|
+
state.blinkCooldown === 0 &&
|
|
317
|
+
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
318
|
+
)
|
|
319
|
+
{
|
|
320
|
+
const blinkTarget = getBlinkToIncreaseDistance(
|
|
321
|
+
state.position,
|
|
322
|
+
enemy.position,
|
|
323
|
+
state.projectiles,
|
|
324
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
325
|
+
);
|
|
326
|
+
if (blinkTarget)
|
|
327
|
+
{
|
|
328
|
+
return {
|
|
329
|
+
move: {x: 0, y: 0},
|
|
330
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// === OFFENSE: Punish mode — fast straight missiles during enemy vulnerability ===
|
|
336
|
+
if (
|
|
337
|
+
state.state === 'idle' &&
|
|
338
|
+
getEnemyVulnerabilityTicks(enemy) >= punishMinVulnerability &&
|
|
339
|
+
distance < 500
|
|
340
|
+
)
|
|
341
|
+
{
|
|
342
|
+
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
343
|
+
const flightTimeEstimate = distance / flightTimeDivisor;
|
|
344
|
+
const punishBudget = vulnerabilityTicks - flightTimeEstimate;
|
|
345
|
+
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
346
|
+
const safeBudget =
|
|
347
|
+
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
348
|
+
const effectiveBudget = Math.min(
|
|
349
|
+
punishBudget,
|
|
350
|
+
safeBudget,
|
|
351
|
+
MAX_CAST_BUDGET,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
if (effectiveBudget > 0)
|
|
355
|
+
{
|
|
356
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
357
|
+
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
358
|
+
minTurnRate: 0,
|
|
359
|
+
maxDamage: enemyHP,
|
|
360
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
364
|
+
{
|
|
365
|
+
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
366
|
+
const actualFlight = distance / config.speed;
|
|
367
|
+
|
|
368
|
+
if (castTime + actualFlight < vulnerabilityTicks)
|
|
369
|
+
{
|
|
370
|
+
const aimTarget =
|
|
371
|
+
config.turnRate > 0
|
|
372
|
+
? enemy.position
|
|
373
|
+
: getLeadPosition(
|
|
374
|
+
enemy.position,
|
|
375
|
+
enemy.velocity,
|
|
376
|
+
config.speed,
|
|
377
|
+
state.position,
|
|
378
|
+
);
|
|
379
|
+
return {
|
|
380
|
+
move,
|
|
381
|
+
startCast: {
|
|
382
|
+
spell: 'missile',
|
|
383
|
+
config,
|
|
384
|
+
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
385
|
+
direction: angleTo(state.position, aimTarget),
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// === OFFENSE: Adaptive missile fitting (identical to Spellweaver) ===
|
|
394
|
+
// Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
|
|
395
|
+
const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
|
|
396
|
+
const hasNearbyLethalMissile = state.projectiles.some(
|
|
397
|
+
(p) =>
|
|
398
|
+
!myProjectileIds.has(p.id) &&
|
|
399
|
+
p.damage >= state.health &&
|
|
400
|
+
distanceTo(state.position, p.position) / p.speed <
|
|
401
|
+
MAX_CAST_BUDGET + GCD_DURATION,
|
|
402
|
+
);
|
|
403
|
+
if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
|
|
404
|
+
{
|
|
405
|
+
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
406
|
+
|
|
407
|
+
// Account for enemy's active cast
|
|
408
|
+
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
409
|
+
{
|
|
410
|
+
const remainingEnemyCast =
|
|
411
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
412
|
+
const estimatedFlightTime = distance / flightTimeDivisor;
|
|
413
|
+
nextThreatTime = Math.min(
|
|
414
|
+
nextThreatTime,
|
|
415
|
+
remainingEnemyCast + estimatedFlightTime,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const safeBudget =
|
|
419
|
+
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
420
|
+
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
421
|
+
|
|
422
|
+
// Spellbinder always uses homing in standard mode (kiting means enemies are always moving)
|
|
423
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
424
|
+
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
425
|
+
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
426
|
+
minTurnRate,
|
|
427
|
+
maxDamage: enemyHP,
|
|
428
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (config && config.damage >= minDmg)
|
|
432
|
+
{
|
|
433
|
+
return {
|
|
434
|
+
move,
|
|
435
|
+
startCast: {
|
|
436
|
+
spell: 'missile',
|
|
437
|
+
config,
|
|
438
|
+
missileAI: HomingAI,
|
|
439
|
+
direction: angleTo(state.position, enemy.position),
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Budget too small for useful missile — wait for threat to pass, then fire
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {move};
|
|
448
|
+
};
|