@vibemancer/core 0.1.4 → 0.1.6
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 +2 -2
- package/dist/chunk-G5T65F3W.js +10617 -0
- package/dist/chunk-G5T65F3W.js.map +1 -0
- package/dist/index-browser.d.ts +1328 -1051
- package/dist/index-browser.js +59 -17
- package/dist/index.d.ts +53 -3
- package/dist/index.js +204 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -15
- package/src/banned-bot-names.ts +53 -0
- package/src/bots/Hero.ts +867 -0
- package/src/bots/berserker/01_Stormchaser.ts +162 -120
- package/src/bots/berserker/02_Stormcaller.ts +160 -116
- package/src/bots/berserker/03_Stormforger.ts +162 -129
- package/src/bots/caster/01_Flamecaller.ts +126 -84
- package/src/bots/caster/02_Pyromancer.ts +148 -101
- package/src/bots/caster/03_Infernalist.ts +180 -160
- package/src/bots/defensive/01_Turtle.ts +48 -44
- package/src/bots/defensive/02_Sentinel.ts +57 -53
- package/src/bots/defensive/03_Golem.ts +85 -97
- package/src/bots/duelist/01_Battlemage.ts +157 -122
- package/src/bots/duelist/02_Warmage.ts +166 -121
- package/src/bots/duelist/03_Archmage.ts +198 -178
- package/src/bots/homing/01_Bonemancer.ts +20 -32
- package/src/bots/homing/02_Lich.ts +145 -93
- package/src/bots/homing/03_Archlich.ts +129 -60
- package/src/bots/kiter/01_Spellspinner.ts +145 -107
- package/src/bots/kiter/02_Spellweaver.ts +153 -105
- package/src/bots/kiter/03_Spellbinder.ts +168 -123
- package/src/bots/melee/01_Shadowblade.ts +131 -75
- package/src/bots/melee/02_Nightblade.ts +174 -130
- package/src/bots/melee/03_Voidblade.ts +266 -168
- package/src/bots/registry.ts +44 -37
- package/src/bots/shared.ts +185 -25
- package/src/bots/sniper/01_Spellshot.ts +151 -98
- package/src/bots/sniper/02_Spelltracer.ts +173 -128
- package/src/bots/sniper/03_Spellseeker.ts +177 -152
- package/src/bots/standalone/Critter.ts +46 -52
- package/src/bots/standalone/Doombringer.ts +36 -40
- package/src/bots/standalone/Hogger.ts +120 -89
- package/src/bots/standalone/Rookie.ts +21 -23
- package/src/bots/standalone/TargetDummy.ts +5 -6
- package/src/bots/test/cheater.ts +91 -85
- package/src/bots/test/crasher.ts +26 -28
- package/src/engine/bundle-fight.ts +242 -0
- package/src/engine/hooks-runtime.ts +58 -18
- package/src/engine/manual-match.ts +33 -25
- package/src/engine/missile-templates.ts +25 -43
- package/src/engine/optimizer.ts +7 -7
- package/src/engine/params-runtime.ts +3 -3
- package/src/engine/physics.ts +33 -23
- package/src/engine/sandbox-browser.ts +8 -7
- package/src/engine/sandbox-compile.ts +1 -1
- package/src/engine/sandbox-harness.ts +299 -367
- package/src/engine/sandbox.ts +3 -3
- package/src/engine/simulation.ts +291 -76
- package/src/engine/spells.ts +9 -12
- package/src/engine-version.ts +1 -1
- package/src/hooks/action-builders.ts +76 -21
- package/src/hooks/index.ts +17 -9
- package/src/hooks/state-hooks.ts +61 -25
- package/src/hooks/threat-analysis.ts +44 -20
- package/src/hooks/types.ts +62 -5
- package/src/index-browser.ts +1 -0
- package/src/index.ts +3 -0
- package/src/rules.ts +209 -63
- package/src/stats.ts +2 -2
- package/src/testing.ts +6 -30
- package/src/trace.ts +63 -3
- package/src/types.ts +127 -14
- package/src/utils/angles.ts +1 -0
- package/src/utils/combat.ts +98 -6
- package/src/utils/spatial.ts +3 -3
- package/dist/chunk-74FFJCFF.js +0 -9140
- package/dist/chunk-74FFJCFF.js.map +0 -1
- package/src/hooks/bot-wrapper.ts +0 -84
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
MissileAIFunction,
|
|
4
|
-
MissileActions,
|
|
5
|
-
} from '../../types.js';
|
|
1
|
+
import type {FinalAction, MissileFunction} from '../../hooks/index.js';
|
|
2
|
+
import {usePosition, useEnemy, useStatus, useBlinkCooldown, useHealth, useTick, useThreats, useLastMissileConfig, useCastingSpell, useCastProgress, idle, move as moveAction, missile, shield, blink, cancel, getMissileContext, turnToward, flyStraight} from '../../hooks/index.js';
|
|
6
3
|
import {angleTo} from '../../utils/angles.js';
|
|
7
4
|
import {distanceTo} from '../../utils/distance.js';
|
|
8
5
|
import {interceptAngle, moveInDirection} from '../../utils/movement.js';
|
|
9
|
-
import {
|
|
6
|
+
import {RULES, ARENA_MIN, ARENA_MAX} from '../../rules.js';
|
|
10
7
|
import {
|
|
11
|
-
getThreats,
|
|
12
8
|
getMostUrgentThreat,
|
|
13
9
|
getDodgeDirectionForMovement,
|
|
14
10
|
getBlinkToCenter,
|
|
@@ -16,6 +12,8 @@ import {
|
|
|
16
12
|
shouldForceShield,
|
|
17
13
|
MAX_CAST_BUDGET,
|
|
18
14
|
MIN_USEFUL_DAMAGE,
|
|
15
|
+
keepOffWalls,
|
|
16
|
+
getPreCastReposition,
|
|
19
17
|
} from '../shared.js';
|
|
20
18
|
import {useParam} from '../../engine/params-runtime.js';
|
|
21
19
|
const WALL_BUFFER = 80;
|
|
@@ -25,37 +23,38 @@ const SHIELD_BUFFER = 3;
|
|
|
25
23
|
/**
|
|
26
24
|
* Predictive homing missile AI - leads the target.
|
|
27
25
|
*/
|
|
28
|
-
const StormcallerMissileAI:
|
|
29
|
-
worldState,
|
|
30
|
-
missileState,
|
|
31
|
-
}) =>
|
|
26
|
+
const StormcallerMissileAI: MissileFunction = () =>
|
|
32
27
|
{
|
|
33
|
-
const
|
|
34
|
-
|
|
28
|
+
const ctx = getMissileContext();
|
|
29
|
+
const enemy = ctx.worldState.enemies[0];
|
|
30
|
+
if (enemy.health <= 0) return flyStraight();
|
|
35
31
|
|
|
36
32
|
const angle = interceptAngle(
|
|
37
|
-
|
|
33
|
+
ctx.position,
|
|
38
34
|
enemy.position,
|
|
39
35
|
enemy.velocity,
|
|
40
|
-
|
|
36
|
+
ctx.speed,
|
|
41
37
|
);
|
|
42
|
-
if (angle !== null)
|
|
38
|
+
if (angle !== null)
|
|
43
39
|
{
|
|
44
|
-
|
|
40
|
+
const target = moveInDirection(ctx.position, angle, 100);
|
|
41
|
+
return turnToward(target.x, target.y);
|
|
45
42
|
}
|
|
46
|
-
return
|
|
43
|
+
return turnToward(enemy.position.x, enemy.position.y);
|
|
47
44
|
};
|
|
48
45
|
|
|
46
|
+
const StraightAI: MissileFunction = () => flyStraight();
|
|
47
|
+
|
|
49
48
|
/**
|
|
50
49
|
* Check if position is near a wall.
|
|
51
50
|
*/
|
|
52
51
|
function isNearWall(position: {x: number; y: number}): boolean
|
|
53
52
|
{
|
|
54
53
|
return (
|
|
55
|
-
position.x < WALL_BUFFER ||
|
|
56
|
-
position.x >
|
|
57
|
-
position.y < WALL_BUFFER ||
|
|
58
|
-
position.y >
|
|
54
|
+
position.x < ARENA_MIN + WALL_BUFFER ||
|
|
55
|
+
position.x > ARENA_MAX - WALL_BUFFER ||
|
|
56
|
+
position.y < ARENA_MIN + WALL_BUFFER ||
|
|
57
|
+
position.y > ARENA_MAX - WALL_BUFFER
|
|
59
58
|
);
|
|
60
59
|
}
|
|
61
60
|
|
|
@@ -73,12 +72,22 @@ function combatMove(
|
|
|
73
72
|
strafeIntensity: number,
|
|
74
73
|
approachSpeed: number,
|
|
75
74
|
distanceDeadZone: number,
|
|
76
|
-
|
|
75
|
+
centerBias: number,
|
|
76
|
+
): {x: number; y: number}
|
|
77
77
|
{
|
|
78
78
|
const deltaX = enemy.position.x - position.x;
|
|
79
79
|
const deltaY = enemy.position.y - position.y;
|
|
80
80
|
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
81
81
|
|
|
82
|
+
// Approach the center-side of the enemy so knockback pushes toward center, not lava.
|
|
83
|
+
const center = (ARENA_MIN + ARENA_MAX) / 2;
|
|
84
|
+
const toCenterX = center - enemy.position.x;
|
|
85
|
+
const toCenterY = center - enemy.position.y;
|
|
86
|
+
const toCenterDist = Math.sqrt(toCenterX * toCenterX + toCenterY * toCenterY) || 1;
|
|
87
|
+
const approachDirX = deltaX / normalizedDistance + (toCenterX / toCenterDist) * centerBias;
|
|
88
|
+
const approachDirY = deltaY / normalizedDistance + (toCenterY / toCenterDist) * centerBias;
|
|
89
|
+
const approachMag = Math.sqrt(approachDirX * approachDirX + approachDirY * approachDirY) || 1;
|
|
90
|
+
|
|
82
91
|
const strafeClockwise = {
|
|
83
92
|
x: -deltaY / normalizedDistance,
|
|
84
93
|
y: deltaX / normalizedDistance,
|
|
@@ -105,16 +114,16 @@ function combatMove(
|
|
|
105
114
|
y: position.y + strafeCounterClockwise.y * 100,
|
|
106
115
|
};
|
|
107
116
|
const scoreClockwise = Math.min(
|
|
108
|
-
futureClockwise.x,
|
|
109
|
-
|
|
110
|
-
futureClockwise.y,
|
|
111
|
-
|
|
117
|
+
futureClockwise.x - ARENA_MIN,
|
|
118
|
+
ARENA_MAX - futureClockwise.x,
|
|
119
|
+
futureClockwise.y - ARENA_MIN,
|
|
120
|
+
ARENA_MAX - futureClockwise.y,
|
|
112
121
|
);
|
|
113
122
|
const scoreCounterClockwise = Math.min(
|
|
114
|
-
futureCounterClockwise.x,
|
|
115
|
-
|
|
116
|
-
futureCounterClockwise.y,
|
|
117
|
-
|
|
123
|
+
futureCounterClockwise.x - ARENA_MIN,
|
|
124
|
+
ARENA_MAX - futureCounterClockwise.x,
|
|
125
|
+
futureCounterClockwise.y - ARENA_MIN,
|
|
126
|
+
ARENA_MAX - futureCounterClockwise.y,
|
|
118
127
|
);
|
|
119
128
|
strafeDirection =
|
|
120
129
|
scoreClockwise > scoreCounterClockwise
|
|
@@ -129,34 +138,34 @@ function combatMove(
|
|
|
129
138
|
|
|
130
139
|
if (dodgeDirection)
|
|
131
140
|
{
|
|
132
|
-
return {x: strafeDirection.x
|
|
141
|
+
return {x: strafeDirection.x, y: strafeDirection.y};
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
let moveX = strafeDirection.x * strafeIntensity;
|
|
136
145
|
let moveY = strafeDirection.y * strafeIntensity;
|
|
137
146
|
|
|
138
|
-
if (currentDistance > targetDistance + distanceDeadZone)
|
|
147
|
+
if (currentDistance > targetDistance + distanceDeadZone)
|
|
139
148
|
{
|
|
140
|
-
moveX += (
|
|
141
|
-
moveY += (
|
|
149
|
+
moveX += (approachDirX / approachMag) * approachSpeed;
|
|
150
|
+
moveY += (approachDirY / approachMag) * approachSpeed;
|
|
142
151
|
}
|
|
143
152
|
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
144
153
|
{
|
|
145
154
|
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
146
155
|
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
147
|
-
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
148
|
-
if (position.x >
|
|
149
|
-
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
150
|
-
if (position.y >
|
|
156
|
+
if (position.x < ARENA_MIN + WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
157
|
+
if (position.x > ARENA_MAX - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
158
|
+
if (position.y < ARENA_MIN + WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
159
|
+
if (position.y > ARENA_MAX - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
151
160
|
moveX += retreatX;
|
|
152
161
|
moveY += retreatY;
|
|
153
162
|
}
|
|
154
163
|
|
|
155
164
|
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
156
|
-
if (magnitude >
|
|
165
|
+
if (magnitude > 1)
|
|
157
166
|
{
|
|
158
|
-
moveX =
|
|
159
|
-
moveY =
|
|
167
|
+
moveX = moveX / magnitude;
|
|
168
|
+
moveY = moveY / magnitude;
|
|
160
169
|
}
|
|
161
170
|
|
|
162
171
|
return {x: moveX, y: moveY};
|
|
@@ -178,10 +187,22 @@ function combatMove(
|
|
|
178
187
|
*
|
|
179
188
|
* TIER: 2 (enhanced Stormchaser)
|
|
180
189
|
*/
|
|
181
|
-
export
|
|
190
|
+
export function Stormcaller(): FinalAction
|
|
182
191
|
{
|
|
192
|
+
const position = usePosition();
|
|
193
|
+
const enemy = useEnemy();
|
|
194
|
+
const status = useStatus();
|
|
195
|
+
const blinkCooldown = useBlinkCooldown();
|
|
196
|
+
const health = useHealth();
|
|
197
|
+
const tick = useTick();
|
|
198
|
+
const threats = useThreats();
|
|
199
|
+
const lastMissileConfig = useLastMissileConfig();
|
|
200
|
+
const castingSpell = useCastingSpell();
|
|
201
|
+
const castProgressData = useCastProgress();
|
|
202
|
+
|
|
203
|
+
|
|
183
204
|
// Tunable parameters (run optimizer script to find optimal values)
|
|
184
|
-
const targetDistance = useParam('targetDistance',
|
|
205
|
+
const targetDistance = useParam('targetDistance', 176, {
|
|
185
206
|
range: 200,
|
|
186
207
|
min: 0,
|
|
187
208
|
});
|
|
@@ -191,24 +212,24 @@ export const Stormcaller: WizardFunction = ({state}) =>
|
|
|
191
212
|
min: 1,
|
|
192
213
|
steps: 7,
|
|
193
214
|
});
|
|
194
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod',
|
|
215
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 208, {
|
|
195
216
|
range: 75,
|
|
196
217
|
min: 80,
|
|
197
218
|
max: 300,
|
|
198
219
|
});
|
|
199
|
-
const strafeIntensity = useParam('strafeIntensity',
|
|
200
|
-
range:
|
|
201
|
-
min:
|
|
202
|
-
max:
|
|
220
|
+
const strafeIntensity = useParam('strafeIntensity', 0.9, {
|
|
221
|
+
range: 0.4,
|
|
222
|
+
min: 0.2,
|
|
223
|
+
max: 1,
|
|
203
224
|
steps: 7,
|
|
204
225
|
});
|
|
205
|
-
const approachSpeed = useParam('approachSpeed',
|
|
206
|
-
range:
|
|
207
|
-
min:
|
|
208
|
-
max:
|
|
226
|
+
const approachSpeed = useParam('approachSpeed', 0.45, {
|
|
227
|
+
range: 0.3,
|
|
228
|
+
min: 0.1,
|
|
229
|
+
max: 0.7,
|
|
209
230
|
steps: 7,
|
|
210
231
|
});
|
|
211
|
-
const distanceDeadZone = useParam('distanceDeadZone',
|
|
232
|
+
const distanceDeadZone = useParam('distanceDeadZone', 33, {
|
|
212
233
|
range: 25,
|
|
213
234
|
min: 10,
|
|
214
235
|
max: 60,
|
|
@@ -219,78 +240,95 @@ export const Stormcaller: WizardFunction = ({state}) =>
|
|
|
219
240
|
min: 50,
|
|
220
241
|
max: 300,
|
|
221
242
|
});
|
|
222
|
-
const blinkWindowMax = useParam('blinkWindowMax',
|
|
243
|
+
const blinkWindowMax = useParam('blinkWindowMax', 31, {
|
|
223
244
|
range: 40,
|
|
224
245
|
min: 20,
|
|
225
246
|
max: 120,
|
|
226
247
|
steps: 7,
|
|
227
248
|
});
|
|
228
|
-
const attackRange = useParam('attackRange',
|
|
249
|
+
const attackRange = useParam('attackRange', 590, {
|
|
229
250
|
range: 200,
|
|
230
251
|
min: 300,
|
|
231
252
|
max: 800,
|
|
232
253
|
});
|
|
233
|
-
const flightTimeDivisor = useParam('flightTimeDivisor', 10.
|
|
254
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 10.5, {
|
|
234
255
|
range: 4,
|
|
235
256
|
min: 2,
|
|
236
257
|
max: 12,
|
|
237
258
|
steps: 5,
|
|
238
259
|
});
|
|
260
|
+
const centerBias = useParam('centerBias', 0.26, {
|
|
261
|
+
range: 0.4,
|
|
262
|
+
min: 0,
|
|
263
|
+
max: 0.8,
|
|
264
|
+
steps: 5,
|
|
265
|
+
});
|
|
266
|
+
const maxBlinkDistance = useParam('maxBlinkDistance', 178, {
|
|
267
|
+
range: 100,
|
|
268
|
+
min: 50,
|
|
269
|
+
max: 300,
|
|
270
|
+
steps: 7,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const distance = distanceTo(position, enemy.position);
|
|
239
274
|
|
|
240
|
-
|
|
241
|
-
if (!enemy)
|
|
275
|
+
function capBlinkDistance(target: {x: number; y: number}): {x: number; y: number}
|
|
242
276
|
{
|
|
243
|
-
|
|
277
|
+
const dx = target.x - position.x;
|
|
278
|
+
const dy = target.y - position.y;
|
|
279
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
280
|
+
if (dist <= maxBlinkDistance) return target;
|
|
281
|
+
const scale = maxBlinkDistance / dist;
|
|
282
|
+
return {x: position.x + dx * scale, y: position.y + dy * scale};
|
|
244
283
|
}
|
|
245
284
|
|
|
246
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
247
|
-
|
|
248
285
|
// Analyze threats using simulation
|
|
249
|
-
const threats = getThreats(state);
|
|
250
286
|
const urgentThreat = getMostUrgentThreat(threats);
|
|
251
287
|
|
|
252
288
|
// === DEFENSE: Handle channeling state ===
|
|
253
|
-
if (
|
|
289
|
+
if (status === 'channeling')
|
|
254
290
|
{
|
|
255
291
|
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
256
292
|
{
|
|
257
|
-
return
|
|
293
|
+
return cancel();
|
|
258
294
|
}
|
|
259
|
-
return
|
|
295
|
+
return idle();
|
|
260
296
|
}
|
|
261
297
|
|
|
262
298
|
// === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
|
|
263
299
|
// DPS trade: we accepted the hit when we started casting. Only cancel to survive.
|
|
264
300
|
if (
|
|
265
|
-
|
|
266
|
-
|
|
301
|
+
status === 'casting' &&
|
|
302
|
+
castingSpell === 'missile' &&
|
|
267
303
|
urgentThreat &&
|
|
268
|
-
urgentThreat.projectile.damage >=
|
|
304
|
+
urgentThreat.projectile.damage >= health
|
|
269
305
|
)
|
|
270
306
|
{
|
|
271
|
-
const remainingCast = (
|
|
307
|
+
const remainingCast = ((castProgressData?.total) ?? 0) - ((castProgressData?.current) ?? 0);
|
|
272
308
|
if (
|
|
273
309
|
urgentThreat.ticksToImpact <=
|
|
274
|
-
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1
|
|
310
|
+
remainingCast + RULES.GCD_DURATION + RULES.SHIELD_CAST_TIME + 1
|
|
275
311
|
)
|
|
276
312
|
{
|
|
277
313
|
const dodgeDirection = getDodgeDirectionForMovement(
|
|
278
314
|
threats,
|
|
279
|
-
|
|
315
|
+
position,
|
|
280
316
|
);
|
|
281
|
-
const
|
|
282
|
-
|
|
317
|
+
const cancelMove = combatMove(
|
|
318
|
+
position,
|
|
283
319
|
enemy,
|
|
284
320
|
distance,
|
|
285
321
|
targetDistance,
|
|
286
322
|
dodgeDirection,
|
|
287
|
-
|
|
323
|
+
tick,
|
|
288
324
|
strafeCyclePeriod,
|
|
289
325
|
strafeIntensity,
|
|
290
326
|
approachSpeed,
|
|
291
327
|
distanceDeadZone,
|
|
328
|
+
centerBias,
|
|
292
329
|
);
|
|
293
|
-
|
|
330
|
+
const safeCancelMove = keepOffWalls(position, cancelMove);
|
|
331
|
+
return cancel().move(safeCancelMove.x, safeCancelMove.y);
|
|
294
332
|
}
|
|
295
333
|
}
|
|
296
334
|
|
|
@@ -298,27 +336,25 @@ export const Stormcaller: WizardFunction = ({state}) =>
|
|
|
298
336
|
// Blink dodges the incoming missile — no GCD after, so can immediately fire back.
|
|
299
337
|
// Only shield when blink is on cooldown.
|
|
300
338
|
if (
|
|
301
|
-
|
|
339
|
+
status === 'idle' &&
|
|
302
340
|
urgentThreat &&
|
|
303
|
-
|
|
341
|
+
blinkCooldown === 0 &&
|
|
304
342
|
urgentThreat.ticksToImpact >= 10 &&
|
|
305
343
|
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
306
344
|
)
|
|
307
345
|
{
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
311
|
-
};
|
|
346
|
+
const blinkTarget = capBlinkDistance(getBlinkToCenter(position));
|
|
347
|
+
return blink(blinkTarget.x, blinkTarget.y);
|
|
312
348
|
}
|
|
313
349
|
|
|
314
350
|
// === DEFENSE: Shield threats (when blink is on cooldown) ===
|
|
315
|
-
if (
|
|
351
|
+
if (status === 'idle' && urgentThreat)
|
|
316
352
|
{
|
|
317
353
|
const forceShield = shouldForceShield(urgentThreat);
|
|
318
354
|
const undodgeable = !urgentThreat.bestDodgeDirection;
|
|
319
|
-
const healthLow =
|
|
355
|
+
const healthLow = health < shieldHealthThreshold;
|
|
320
356
|
const healthCritical =
|
|
321
|
-
|
|
357
|
+
health <= urgentThreat.projectile.damage * 2 + 1;
|
|
322
358
|
const doShield =
|
|
323
359
|
forceShield || (undodgeable && healthLow) || healthCritical;
|
|
324
360
|
|
|
@@ -326,36 +362,58 @@ export const Stormcaller: WizardFunction = ({state}) =>
|
|
|
326
362
|
{
|
|
327
363
|
if (urgentThreat.ticksToStartShield <= 3)
|
|
328
364
|
{
|
|
329
|
-
return
|
|
330
|
-
move: {x: 0, y: 0},
|
|
331
|
-
startCast: {spell: 'shield'},
|
|
332
|
-
};
|
|
365
|
+
return shield();
|
|
333
366
|
}
|
|
334
367
|
}
|
|
335
368
|
}
|
|
336
369
|
|
|
370
|
+
// === Preemptive shield — shield when enemy is casting a missile at close range ===
|
|
371
|
+
if (
|
|
372
|
+
status === 'idle' &&
|
|
373
|
+
distance < 300 &&
|
|
374
|
+
enemy.status === 'casting' &&
|
|
375
|
+
enemy.castingSpell === 'missile'
|
|
376
|
+
)
|
|
377
|
+
{
|
|
378
|
+
const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
379
|
+
const estimatedFlightTicks = distance / 8;
|
|
380
|
+
const ticksUntilHit = remainingCast + estimatedFlightTicks;
|
|
381
|
+
|
|
382
|
+
if (
|
|
383
|
+
ticksUntilHit >= RULES.SHIELD_CAST_TIME &&
|
|
384
|
+
ticksUntilHit < RULES.SHIELD_CAST_TIME + 15
|
|
385
|
+
)
|
|
386
|
+
{
|
|
387
|
+
return shield();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
337
391
|
// === MOVEMENT: Smart combat movement ===
|
|
338
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats,
|
|
392
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, position);
|
|
339
393
|
const move = combatMove(
|
|
340
|
-
|
|
394
|
+
position,
|
|
341
395
|
enemy,
|
|
342
396
|
distance,
|
|
343
397
|
targetDistance,
|
|
344
398
|
dodgeDirection,
|
|
345
|
-
|
|
399
|
+
tick,
|
|
346
400
|
strafeCyclePeriod,
|
|
347
401
|
strafeIntensity,
|
|
348
402
|
approachSpeed,
|
|
349
403
|
distanceDeadZone,
|
|
404
|
+
centerBias,
|
|
350
405
|
);
|
|
351
|
-
|
|
406
|
+
const safeMove = keepOffWalls(position, move);
|
|
352
407
|
// === OFFENSE: Adaptive missile fitting with predictive homing ===
|
|
353
|
-
if (
|
|
408
|
+
if (status === 'idle' && distance < attackRange)
|
|
354
409
|
{
|
|
410
|
+
const reposition = getPreCastReposition(position, enemy.position);
|
|
411
|
+
if (reposition) return moveAction(reposition.x, reposition.y);
|
|
412
|
+
|
|
355
413
|
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
356
414
|
|
|
357
415
|
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
358
|
-
if (enemy.
|
|
416
|
+
if (enemy.status === 'casting' && enemy.castingSpell === 'missile')
|
|
359
417
|
{
|
|
360
418
|
const remainingEnemyCast =
|
|
361
419
|
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
@@ -367,7 +425,7 @@ export const Stormcaller: WizardFunction = ({state}) =>
|
|
|
367
425
|
}
|
|
368
426
|
|
|
369
427
|
const safeBudget =
|
|
370
|
-
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
428
|
+
nextThreatTime - RULES.GCD_DURATION - RULES.SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
371
429
|
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
372
430
|
|
|
373
431
|
// Predictive homing compensates for movement during cast
|
|
@@ -376,23 +434,12 @@ export const Stormcaller: WizardFunction = ({state}) =>
|
|
|
376
434
|
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
377
435
|
minTurnRate,
|
|
378
436
|
maxDamage: enemyHP,
|
|
379
|
-
lastMissileConfig:
|
|
437
|
+
lastMissileConfig: lastMissileConfig,
|
|
380
438
|
});
|
|
381
439
|
|
|
382
440
|
if (config && config.damage >= minDmg)
|
|
383
441
|
{
|
|
384
|
-
return
|
|
385
|
-
move,
|
|
386
|
-
startCast: {
|
|
387
|
-
spell: 'missile',
|
|
388
|
-
config,
|
|
389
|
-
missileAI:
|
|
390
|
-
config.turnRate > 0
|
|
391
|
-
? StormcallerMissileAI
|
|
392
|
-
: (): MissileActions => ({}),
|
|
393
|
-
direction: angleTo(state.position, enemy.position),
|
|
394
|
-
},
|
|
395
|
-
};
|
|
442
|
+
return missile(config, config.turnRate > 0 ? StormcallerMissileAI : StraightAI, angleTo(position, enemy.position)).move(safeMove.x, safeMove.y);
|
|
396
443
|
}
|
|
397
444
|
|
|
398
445
|
// Budget too small for useful missile — wait for threat to pass, then fire
|
|
@@ -400,18 +447,15 @@ export const Stormcaller: WizardFunction = ({state}) =>
|
|
|
400
447
|
|
|
401
448
|
// === FALLBACK DEFENSE: Shield when no attack window exists ===
|
|
402
449
|
if (
|
|
403
|
-
|
|
450
|
+
status === 'idle' &&
|
|
404
451
|
urgentThreat &&
|
|
405
452
|
!urgentThreat.bestDodgeDirection &&
|
|
406
453
|
urgentThreat.canBlockInTime &&
|
|
407
454
|
urgentThreat.ticksToStartShield <= 3
|
|
408
455
|
)
|
|
409
456
|
{
|
|
410
|
-
return
|
|
411
|
-
move: {x: 0, y: 0},
|
|
412
|
-
startCast: {spell: 'shield'},
|
|
413
|
-
};
|
|
457
|
+
return shield();
|
|
414
458
|
}
|
|
415
459
|
|
|
416
|
-
return
|
|
417
|
-
}
|
|
460
|
+
return moveAction(safeMove.x, safeMove.y);
|
|
461
|
+
}
|