@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,433 +1,433 @@
|
|
|
1
|
-
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
-
import {angleTo} from '../../utils/angles.js';
|
|
3
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
-
import {getMissileCastTime} from '../../utils/combat.js';
|
|
5
|
-
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
6
|
-
import {
|
|
7
|
-
getThreats,
|
|
8
|
-
getMostUrgentThreat,
|
|
9
|
-
getDodgeDirectionForMovement,
|
|
10
|
-
isSafeToAttack,
|
|
11
|
-
getBlinkToDecreaseDistance,
|
|
12
|
-
shouldForceShield,
|
|
13
|
-
} from '../shared.js';
|
|
14
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
15
|
-
const WALL_BUFFER = 60;
|
|
16
|
-
|
|
17
|
-
const QUICK_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 70};
|
|
18
|
-
const HEAVY_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
|
|
19
|
-
|
|
20
|
-
// Missile effective range (speed * duration). Use HEAVY when QUICK can't reach.
|
|
21
|
-
const QUICK_RANGE = QUICK_CONFIG.speed * QUICK_CONFIG.duration; // 350
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Standard homing missile AI.
|
|
25
|
-
*/
|
|
26
|
-
const StandardHoming: MissileAIFunction = ({worldState}) =>
|
|
27
|
-
{
|
|
28
|
-
const enemy = worldState.enemies[0];
|
|
29
|
-
return {
|
|
30
|
-
turnToward: enemy?.position,
|
|
31
|
-
};
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Check if near wall.
|
|
36
|
-
*/
|
|
37
|
-
function isNearWall(position: {x: number; y: number}): boolean
|
|
38
|
-
{
|
|
39
|
-
return (
|
|
40
|
-
position.x < WALL_BUFFER ||
|
|
41
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
42
|
-
position.y < WALL_BUFFER ||
|
|
43
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Smart combat movement with threat-aware dodging.
|
|
49
|
-
*/
|
|
50
|
-
function combatMove(
|
|
51
|
-
position: {x: number; y: number},
|
|
52
|
-
enemy: {position: {x: number; y: number}},
|
|
53
|
-
currentDistance: number,
|
|
54
|
-
targetDistance: number,
|
|
55
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
56
|
-
tick: number,
|
|
57
|
-
strafeCyclePeriod: number,
|
|
58
|
-
strafeIntensity: number,
|
|
59
|
-
approachSpeed: number,
|
|
60
|
-
distanceDeadZone: number,
|
|
61
|
-
): {x: number; y: number}
|
|
62
|
-
{
|
|
63
|
-
const deltaX = enemy.position.x - position.x;
|
|
64
|
-
const deltaY = enemy.position.y - position.y;
|
|
65
|
-
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
66
|
-
|
|
67
|
-
// Perpendicular directions
|
|
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
|
-
// Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
|
|
80
|
-
if (dodgeDirection)
|
|
81
|
-
{
|
|
82
|
-
strafeDirection = dodgeDirection;
|
|
83
|
-
}
|
|
84
|
-
// Priority 2: Avoid walls
|
|
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
|
-
strafeDirection =
|
|
108
|
-
scoreClockwise > scoreCounterClockwise
|
|
109
|
-
? strafeClockwise
|
|
110
|
-
: strafeCounterClockwise;
|
|
111
|
-
}
|
|
112
|
-
// Priority 3: Commit to direction
|
|
113
|
-
else
|
|
114
|
-
{
|
|
115
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
116
|
-
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// When actively dodging, commit fully — no approach/retreat dilution
|
|
120
|
-
if (dodgeDirection)
|
|
121
|
-
{
|
|
122
|
-
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
let moveX = strafeDirection.x * strafeIntensity;
|
|
126
|
-
let moveY = strafeDirection.y * strafeIntensity;
|
|
127
|
-
|
|
128
|
-
// Distance management (only when not dodging)
|
|
129
|
-
if (currentDistance > targetDistance + distanceDeadZone)
|
|
130
|
-
{
|
|
131
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
132
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
133
|
-
}
|
|
134
|
-
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
135
|
-
{
|
|
136
|
-
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
137
|
-
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
138
|
-
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
139
|
-
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
140
|
-
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
141
|
-
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
142
|
-
moveX += retreatX;
|
|
143
|
-
moveY += retreatY;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Normalize
|
|
147
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
148
|
-
if (magnitude > 100)
|
|
149
|
-
{
|
|
150
|
-
moveX = (moveX / magnitude) * 100;
|
|
151
|
-
moveY = (moveY / magnitude) * 100;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return {x: moveX, y: moveY};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Bot: Battlemage
|
|
159
|
-
*
|
|
160
|
-
* BEHAVIOR: Balanced mid-range duelist. Shields undodgeable threats, interrupts
|
|
161
|
-
* enemy casts with quick missiles, saves blink for emergencies OR gap-closing.
|
|
162
|
-
* Switches between quick (10 dmg, fast) and heavy (15 dmg, slow) missile configs
|
|
163
|
-
* based on safety window and range. Will aggressively trade hits when health allows.
|
|
164
|
-
* The unique trait is cast-interruption: fires quick missiles specifically when the
|
|
165
|
-
* enemy is casting, punishing long cast times.
|
|
166
|
-
*
|
|
167
|
-
* PROGRESSION LINE: Battlemage → Warmage → Archmage
|
|
168
|
-
* - Battlemage (tier 1): Quick/heavy fixed configs, cast interruption, basic defense
|
|
169
|
-
* - Warmage (tier 2): Adaptive missile fitting (fitMissileToBudget), smarter attacks
|
|
170
|
-
* - Archmage (tier 3): Future — supreme duelist, perfect tactical mastery
|
|
171
|
-
*
|
|
172
|
-
* TIER: 1 (base)
|
|
173
|
-
*/
|
|
174
|
-
export const Battlemage: WizardFunction = ({state}) =>
|
|
175
|
-
{
|
|
176
|
-
const targetDistance = useParam('targetDistance', 274, {
|
|
177
|
-
range: 150,
|
|
178
|
-
min: 150,
|
|
179
|
-
});
|
|
180
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 219, {
|
|
181
|
-
range: 75,
|
|
182
|
-
min: 80,
|
|
183
|
-
max: 300,
|
|
184
|
-
});
|
|
185
|
-
const strafeIntensity = useParam('strafeIntensity', 36, {
|
|
186
|
-
range: 40,
|
|
187
|
-
min: 20,
|
|
188
|
-
max: 100,
|
|
189
|
-
steps: 7,
|
|
190
|
-
});
|
|
191
|
-
const approachSpeed = useParam('approachSpeed', 80, {
|
|
192
|
-
range: 30,
|
|
193
|
-
min: 10,
|
|
194
|
-
max: 80,
|
|
195
|
-
steps: 7,
|
|
196
|
-
});
|
|
197
|
-
const distanceDeadZone = useParam('distanceDeadZone', 31, {
|
|
198
|
-
range: 30,
|
|
199
|
-
min: 10,
|
|
200
|
-
max: 80,
|
|
201
|
-
steps: 7,
|
|
202
|
-
});
|
|
203
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
204
|
-
range: 75,
|
|
205
|
-
min: 50,
|
|
206
|
-
max: 300,
|
|
207
|
-
});
|
|
208
|
-
const blinkWindowMax = useParam('blinkWindowMax', 35, {
|
|
209
|
-
range: 30,
|
|
210
|
-
min: 15,
|
|
211
|
-
max: 80,
|
|
212
|
-
steps: 7,
|
|
213
|
-
});
|
|
214
|
-
const blinkDistanceOffset = useParam('blinkDistanceOffset', 100, {
|
|
215
|
-
range: 40,
|
|
216
|
-
min: 0,
|
|
217
|
-
max: 100,
|
|
218
|
-
steps: 7,
|
|
219
|
-
});
|
|
220
|
-
const cancelMinRemainingCast = useParam('cancelMinRemainingCast', 19, {
|
|
221
|
-
range: 30,
|
|
222
|
-
min: 5,
|
|
223
|
-
max: 100,
|
|
224
|
-
steps: 7,
|
|
225
|
-
});
|
|
226
|
-
const interruptRange = useParam('interruptRange', 324, {
|
|
227
|
-
range: 150,
|
|
228
|
-
min: 150,
|
|
229
|
-
max: 600,
|
|
230
|
-
steps: 7,
|
|
231
|
-
});
|
|
232
|
-
const attackRange = useParam('attackRange', 556, {
|
|
233
|
-
range: 150,
|
|
234
|
-
min: 200,
|
|
235
|
-
max: 700,
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
const enemy = state.enemies[0];
|
|
239
|
-
if (!enemy)
|
|
240
|
-
{
|
|
241
|
-
return {move: {x: 0, y: 0}};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
245
|
-
|
|
246
|
-
// Analyze threats using simulation
|
|
247
|
-
const threats = getThreats(state);
|
|
248
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
249
|
-
|
|
250
|
-
// === DEFENSE: Handle channeling ===
|
|
251
|
-
if (state.state === 'channeling')
|
|
252
|
-
{
|
|
253
|
-
// Cancel shield if no imminent threats
|
|
254
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
255
|
-
{
|
|
256
|
-
return {move: {x: 0, y: 0}, cancel: true};
|
|
257
|
-
}
|
|
258
|
-
return {move: {x: 0, y: 0}};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// === DEFENSE: Cancel long missile casts if undodgeable threat approaches ===
|
|
262
|
-
// Only cancel when health is too low to trade (aggressive casts accepted the risk).
|
|
263
|
-
if (
|
|
264
|
-
state.state === 'casting' &&
|
|
265
|
-
state.castingSpell === 'missile' &&
|
|
266
|
-
urgentThreat &&
|
|
267
|
-
!urgentThreat.bestDodgeDirection
|
|
268
|
-
)
|
|
269
|
-
{
|
|
270
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
271
|
-
if (
|
|
272
|
-
remainingCast > cancelMinRemainingCast &&
|
|
273
|
-
urgentThreat.ticksToImpact <=
|
|
274
|
-
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1 &&
|
|
275
|
-
state.health <= urgentThreat.projectile.damage * 2 + 1
|
|
276
|
-
)
|
|
277
|
-
{
|
|
278
|
-
const dodgeDirection = getDodgeDirectionForMovement(
|
|
279
|
-
threats,
|
|
280
|
-
state.position,
|
|
281
|
-
);
|
|
282
|
-
const move = combatMove(
|
|
283
|
-
state.position,
|
|
284
|
-
enemy,
|
|
285
|
-
distance,
|
|
286
|
-
targetDistance,
|
|
287
|
-
dodgeDirection,
|
|
288
|
-
state.tick,
|
|
289
|
-
strafeCyclePeriod,
|
|
290
|
-
strafeIntensity,
|
|
291
|
-
approachSpeed,
|
|
292
|
-
distanceDeadZone,
|
|
293
|
-
);
|
|
294
|
-
return {move, cancel: true};
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// === BLINK: Dodge missile + close gap simultaneously (first priority) ===
|
|
299
|
-
// Blink both dodges the incoming missile AND closes distance — better than shielding.
|
|
300
|
-
// Only shield when blink is on cooldown.
|
|
301
|
-
if (
|
|
302
|
-
state.state === 'idle' &&
|
|
303
|
-
urgentThreat &&
|
|
304
|
-
state.blinkCooldown === 0 &&
|
|
305
|
-
distance > targetDistance - blinkDistanceOffset &&
|
|
306
|
-
urgentThreat.ticksToImpact >= 10 &&
|
|
307
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
308
|
-
)
|
|
309
|
-
{
|
|
310
|
-
const blinkTarget =
|
|
311
|
-
getBlinkToDecreaseDistance(
|
|
312
|
-
state.position,
|
|
313
|
-
enemy.position,
|
|
314
|
-
state.projectiles,
|
|
315
|
-
) ?? enemy.position;
|
|
316
|
-
return {
|
|
317
|
-
move: {x: 0, y: 0},
|
|
318
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
|
|
323
|
-
if (
|
|
324
|
-
state.state === 'idle' &&
|
|
325
|
-
urgentThreat &&
|
|
326
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
327
|
-
)
|
|
328
|
-
{
|
|
329
|
-
if (
|
|
330
|
-
urgentThreat.canBlockInTime &&
|
|
331
|
-
urgentThreat.ticksToStartShield <= 3 &&
|
|
332
|
-
(state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
|
|
333
|
-
)
|
|
334
|
-
{
|
|
335
|
-
return {
|
|
336
|
-
move: {x: 0, y: 0},
|
|
337
|
-
startCast: {spell: 'shield'},
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Movement: smart strafe + maintain optimal distance
|
|
343
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
344
|
-
const move = combatMove(
|
|
345
|
-
state.position,
|
|
346
|
-
enemy,
|
|
347
|
-
distance,
|
|
348
|
-
targetDistance,
|
|
349
|
-
dodgeDirection,
|
|
350
|
-
state.tick,
|
|
351
|
-
strafeCyclePeriod,
|
|
352
|
-
strafeIntensity,
|
|
353
|
-
approachSpeed,
|
|
354
|
-
distanceDeadZone,
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
// === OFFENSE ===
|
|
358
|
-
if (state.state === 'idle')
|
|
359
|
-
{
|
|
360
|
-
// Interrupt if enemy is casting (quick attack)
|
|
361
|
-
if (enemy.state === 'casting' && distance < interruptRange)
|
|
362
|
-
{
|
|
363
|
-
const castTime = getMissileCastTime(QUICK_CONFIG);
|
|
364
|
-
if (isSafeToAttack(threats, castTime))
|
|
365
|
-
{
|
|
366
|
-
return {
|
|
367
|
-
move,
|
|
368
|
-
startCast: {
|
|
369
|
-
spell: 'missile',
|
|
370
|
-
config: QUICK_CONFIG,
|
|
371
|
-
missileAI: StandardHoming,
|
|
372
|
-
direction: angleTo(state.position, enemy.position),
|
|
373
|
-
},
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Standard attacks at combat range
|
|
379
|
-
if (distance < attackRange)
|
|
380
|
-
{
|
|
381
|
-
const heavyCastTime = getMissileCastTime(HEAVY_CONFIG);
|
|
382
|
-
if (isSafeToAttack(threats, heavyCastTime))
|
|
383
|
-
{
|
|
384
|
-
return {
|
|
385
|
-
move,
|
|
386
|
-
startCast: {
|
|
387
|
-
spell: 'missile',
|
|
388
|
-
config: HEAVY_CONFIG,
|
|
389
|
-
missileAI: StandardHoming,
|
|
390
|
-
direction: angleTo(state.position, enemy.position),
|
|
391
|
-
},
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Fallback: quick homing missile when heavy isn't safe (only if in range)
|
|
396
|
-
const quickCastTime = getMissileCastTime(QUICK_CONFIG);
|
|
397
|
-
if (
|
|
398
|
-
distance < QUICK_RANGE * 0.9 &&
|
|
399
|
-
isSafeToAttack(threats, quickCastTime)
|
|
400
|
-
)
|
|
401
|
-
{
|
|
402
|
-
return {
|
|
403
|
-
move,
|
|
404
|
-
startCast: {
|
|
405
|
-
spell: 'missile',
|
|
406
|
-
config: QUICK_CONFIG,
|
|
407
|
-
missileAI: StandardHoming,
|
|
408
|
-
direction: angleTo(state.position, enemy.position),
|
|
409
|
-
},
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Not safe to attack — wait for threat to pass, then fire
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// === FALLBACK DEFENSE: Shield when no attack window exists ===
|
|
418
|
-
if (
|
|
419
|
-
state.state === 'idle' &&
|
|
420
|
-
urgentThreat &&
|
|
421
|
-
!urgentThreat.bestDodgeDirection &&
|
|
422
|
-
urgentThreat.canBlockInTime &&
|
|
423
|
-
urgentThreat.ticksToStartShield <= 3
|
|
424
|
-
)
|
|
425
|
-
{
|
|
426
|
-
return {
|
|
427
|
-
move: {x: 0, y: 0},
|
|
428
|
-
startCast: {spell: 'shield'},
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
return {move};
|
|
433
|
-
};
|
|
1
|
+
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
+
import {angleTo} from '../../utils/angles.js';
|
|
3
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
+
import {getMissileCastTime} from '../../utils/combat.js';
|
|
5
|
+
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
6
|
+
import {
|
|
7
|
+
getThreats,
|
|
8
|
+
getMostUrgentThreat,
|
|
9
|
+
getDodgeDirectionForMovement,
|
|
10
|
+
isSafeToAttack,
|
|
11
|
+
getBlinkToDecreaseDistance,
|
|
12
|
+
shouldForceShield,
|
|
13
|
+
} from '../shared.js';
|
|
14
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
15
|
+
const WALL_BUFFER = 60;
|
|
16
|
+
|
|
17
|
+
const QUICK_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 70};
|
|
18
|
+
const HEAVY_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
|
|
19
|
+
|
|
20
|
+
// Missile effective range (speed * duration). Use HEAVY when QUICK can't reach.
|
|
21
|
+
const QUICK_RANGE = QUICK_CONFIG.speed * QUICK_CONFIG.duration; // 350
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Standard homing missile AI.
|
|
25
|
+
*/
|
|
26
|
+
const StandardHoming: MissileAIFunction = ({worldState}) =>
|
|
27
|
+
{
|
|
28
|
+
const enemy = worldState.enemies[0];
|
|
29
|
+
return {
|
|
30
|
+
turnToward: enemy?.position,
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if near wall.
|
|
36
|
+
*/
|
|
37
|
+
function isNearWall(position: {x: number; y: number}): boolean
|
|
38
|
+
{
|
|
39
|
+
return (
|
|
40
|
+
position.x < WALL_BUFFER ||
|
|
41
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
42
|
+
position.y < WALL_BUFFER ||
|
|
43
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Smart combat movement with threat-aware dodging.
|
|
49
|
+
*/
|
|
50
|
+
function combatMove(
|
|
51
|
+
position: {x: number; y: number},
|
|
52
|
+
enemy: {position: {x: number; y: number}},
|
|
53
|
+
currentDistance: number,
|
|
54
|
+
targetDistance: number,
|
|
55
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
56
|
+
tick: number,
|
|
57
|
+
strafeCyclePeriod: number,
|
|
58
|
+
strafeIntensity: number,
|
|
59
|
+
approachSpeed: number,
|
|
60
|
+
distanceDeadZone: number,
|
|
61
|
+
): {x: number; y: number}
|
|
62
|
+
{
|
|
63
|
+
const deltaX = enemy.position.x - position.x;
|
|
64
|
+
const deltaY = enemy.position.y - position.y;
|
|
65
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
66
|
+
|
|
67
|
+
// Perpendicular directions
|
|
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
|
+
// Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
|
|
80
|
+
if (dodgeDirection)
|
|
81
|
+
{
|
|
82
|
+
strafeDirection = dodgeDirection;
|
|
83
|
+
}
|
|
84
|
+
// Priority 2: Avoid walls
|
|
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
|
+
strafeDirection =
|
|
108
|
+
scoreClockwise > scoreCounterClockwise
|
|
109
|
+
? strafeClockwise
|
|
110
|
+
: strafeCounterClockwise;
|
|
111
|
+
}
|
|
112
|
+
// Priority 3: Commit to direction
|
|
113
|
+
else
|
|
114
|
+
{
|
|
115
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
116
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// When actively dodging, commit fully — no approach/retreat dilution
|
|
120
|
+
if (dodgeDirection)
|
|
121
|
+
{
|
|
122
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
126
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
127
|
+
|
|
128
|
+
// Distance management (only when not dodging)
|
|
129
|
+
if (currentDistance > targetDistance + distanceDeadZone)
|
|
130
|
+
{
|
|
131
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
132
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
133
|
+
}
|
|
134
|
+
else if (currentDistance < targetDistance - distanceDeadZone)
|
|
135
|
+
{
|
|
136
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
137
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
138
|
+
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
139
|
+
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
140
|
+
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
141
|
+
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
142
|
+
moveX += retreatX;
|
|
143
|
+
moveY += retreatY;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Normalize
|
|
147
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
148
|
+
if (magnitude > 100)
|
|
149
|
+
{
|
|
150
|
+
moveX = (moveX / magnitude) * 100;
|
|
151
|
+
moveY = (moveY / magnitude) * 100;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {x: moveX, y: moveY};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Bot: Battlemage
|
|
159
|
+
*
|
|
160
|
+
* BEHAVIOR: Balanced mid-range duelist. Shields undodgeable threats, interrupts
|
|
161
|
+
* enemy casts with quick missiles, saves blink for emergencies OR gap-closing.
|
|
162
|
+
* Switches between quick (10 dmg, fast) and heavy (15 dmg, slow) missile configs
|
|
163
|
+
* based on safety window and range. Will aggressively trade hits when health allows.
|
|
164
|
+
* The unique trait is cast-interruption: fires quick missiles specifically when the
|
|
165
|
+
* enemy is casting, punishing long cast times.
|
|
166
|
+
*
|
|
167
|
+
* PROGRESSION LINE: Battlemage → Warmage → Archmage
|
|
168
|
+
* - Battlemage (tier 1): Quick/heavy fixed configs, cast interruption, basic defense
|
|
169
|
+
* - Warmage (tier 2): Adaptive missile fitting (fitMissileToBudget), smarter attacks
|
|
170
|
+
* - Archmage (tier 3): Future — supreme duelist, perfect tactical mastery
|
|
171
|
+
*
|
|
172
|
+
* TIER: 1 (base)
|
|
173
|
+
*/
|
|
174
|
+
export const Battlemage: WizardFunction = ({state}) =>
|
|
175
|
+
{
|
|
176
|
+
const targetDistance = useParam('targetDistance', 274, {
|
|
177
|
+
range: 150,
|
|
178
|
+
min: 150,
|
|
179
|
+
});
|
|
180
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 219, {
|
|
181
|
+
range: 75,
|
|
182
|
+
min: 80,
|
|
183
|
+
max: 300,
|
|
184
|
+
});
|
|
185
|
+
const strafeIntensity = useParam('strafeIntensity', 36, {
|
|
186
|
+
range: 40,
|
|
187
|
+
min: 20,
|
|
188
|
+
max: 100,
|
|
189
|
+
steps: 7,
|
|
190
|
+
});
|
|
191
|
+
const approachSpeed = useParam('approachSpeed', 80, {
|
|
192
|
+
range: 30,
|
|
193
|
+
min: 10,
|
|
194
|
+
max: 80,
|
|
195
|
+
steps: 7,
|
|
196
|
+
});
|
|
197
|
+
const distanceDeadZone = useParam('distanceDeadZone', 31, {
|
|
198
|
+
range: 30,
|
|
199
|
+
min: 10,
|
|
200
|
+
max: 80,
|
|
201
|
+
steps: 7,
|
|
202
|
+
});
|
|
203
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
204
|
+
range: 75,
|
|
205
|
+
min: 50,
|
|
206
|
+
max: 300,
|
|
207
|
+
});
|
|
208
|
+
const blinkWindowMax = useParam('blinkWindowMax', 35, {
|
|
209
|
+
range: 30,
|
|
210
|
+
min: 15,
|
|
211
|
+
max: 80,
|
|
212
|
+
steps: 7,
|
|
213
|
+
});
|
|
214
|
+
const blinkDistanceOffset = useParam('blinkDistanceOffset', 100, {
|
|
215
|
+
range: 40,
|
|
216
|
+
min: 0,
|
|
217
|
+
max: 100,
|
|
218
|
+
steps: 7,
|
|
219
|
+
});
|
|
220
|
+
const cancelMinRemainingCast = useParam('cancelMinRemainingCast', 19, {
|
|
221
|
+
range: 30,
|
|
222
|
+
min: 5,
|
|
223
|
+
max: 100,
|
|
224
|
+
steps: 7,
|
|
225
|
+
});
|
|
226
|
+
const interruptRange = useParam('interruptRange', 324, {
|
|
227
|
+
range: 150,
|
|
228
|
+
min: 150,
|
|
229
|
+
max: 600,
|
|
230
|
+
steps: 7,
|
|
231
|
+
});
|
|
232
|
+
const attackRange = useParam('attackRange', 556, {
|
|
233
|
+
range: 150,
|
|
234
|
+
min: 200,
|
|
235
|
+
max: 700,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const enemy = state.enemies[0];
|
|
239
|
+
if (!enemy)
|
|
240
|
+
{
|
|
241
|
+
return {move: {x: 0, y: 0}};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
245
|
+
|
|
246
|
+
// Analyze threats using simulation
|
|
247
|
+
const threats = getThreats(state);
|
|
248
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
249
|
+
|
|
250
|
+
// === DEFENSE: Handle channeling ===
|
|
251
|
+
if (state.state === 'channeling')
|
|
252
|
+
{
|
|
253
|
+
// Cancel shield if no imminent threats
|
|
254
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
255
|
+
{
|
|
256
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
257
|
+
}
|
|
258
|
+
return {move: {x: 0, y: 0}};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// === DEFENSE: Cancel long missile casts if undodgeable threat approaches ===
|
|
262
|
+
// Only cancel when health is too low to trade (aggressive casts accepted the risk).
|
|
263
|
+
if (
|
|
264
|
+
state.state === 'casting' &&
|
|
265
|
+
state.castingSpell === 'missile' &&
|
|
266
|
+
urgentThreat &&
|
|
267
|
+
!urgentThreat.bestDodgeDirection
|
|
268
|
+
)
|
|
269
|
+
{
|
|
270
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
271
|
+
if (
|
|
272
|
+
remainingCast > cancelMinRemainingCast &&
|
|
273
|
+
urgentThreat.ticksToImpact <=
|
|
274
|
+
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 1 &&
|
|
275
|
+
state.health <= urgentThreat.projectile.damage * 2 + 1
|
|
276
|
+
)
|
|
277
|
+
{
|
|
278
|
+
const dodgeDirection = getDodgeDirectionForMovement(
|
|
279
|
+
threats,
|
|
280
|
+
state.position,
|
|
281
|
+
);
|
|
282
|
+
const move = combatMove(
|
|
283
|
+
state.position,
|
|
284
|
+
enemy,
|
|
285
|
+
distance,
|
|
286
|
+
targetDistance,
|
|
287
|
+
dodgeDirection,
|
|
288
|
+
state.tick,
|
|
289
|
+
strafeCyclePeriod,
|
|
290
|
+
strafeIntensity,
|
|
291
|
+
approachSpeed,
|
|
292
|
+
distanceDeadZone,
|
|
293
|
+
);
|
|
294
|
+
return {move, cancel: true};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// === BLINK: Dodge missile + close gap simultaneously (first priority) ===
|
|
299
|
+
// Blink both dodges the incoming missile AND closes distance — better than shielding.
|
|
300
|
+
// Only shield when blink is on cooldown.
|
|
301
|
+
if (
|
|
302
|
+
state.state === 'idle' &&
|
|
303
|
+
urgentThreat &&
|
|
304
|
+
state.blinkCooldown === 0 &&
|
|
305
|
+
distance > targetDistance - blinkDistanceOffset &&
|
|
306
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
307
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
308
|
+
)
|
|
309
|
+
{
|
|
310
|
+
const blinkTarget =
|
|
311
|
+
getBlinkToDecreaseDistance(
|
|
312
|
+
state.position,
|
|
313
|
+
enemy.position,
|
|
314
|
+
state.projectiles,
|
|
315
|
+
) ?? enemy.position;
|
|
316
|
+
return {
|
|
317
|
+
move: {x: 0, y: 0},
|
|
318
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
|
|
323
|
+
if (
|
|
324
|
+
state.state === 'idle' &&
|
|
325
|
+
urgentThreat &&
|
|
326
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
327
|
+
)
|
|
328
|
+
{
|
|
329
|
+
if (
|
|
330
|
+
urgentThreat.canBlockInTime &&
|
|
331
|
+
urgentThreat.ticksToStartShield <= 3 &&
|
|
332
|
+
(state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
|
|
333
|
+
)
|
|
334
|
+
{
|
|
335
|
+
return {
|
|
336
|
+
move: {x: 0, y: 0},
|
|
337
|
+
startCast: {spell: 'shield'},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Movement: smart strafe + maintain optimal distance
|
|
343
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
344
|
+
const move = combatMove(
|
|
345
|
+
state.position,
|
|
346
|
+
enemy,
|
|
347
|
+
distance,
|
|
348
|
+
targetDistance,
|
|
349
|
+
dodgeDirection,
|
|
350
|
+
state.tick,
|
|
351
|
+
strafeCyclePeriod,
|
|
352
|
+
strafeIntensity,
|
|
353
|
+
approachSpeed,
|
|
354
|
+
distanceDeadZone,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// === OFFENSE ===
|
|
358
|
+
if (state.state === 'idle')
|
|
359
|
+
{
|
|
360
|
+
// Interrupt if enemy is casting (quick attack)
|
|
361
|
+
if (enemy.state === 'casting' && distance < interruptRange)
|
|
362
|
+
{
|
|
363
|
+
const castTime = getMissileCastTime(QUICK_CONFIG);
|
|
364
|
+
if (isSafeToAttack(threats, castTime))
|
|
365
|
+
{
|
|
366
|
+
return {
|
|
367
|
+
move,
|
|
368
|
+
startCast: {
|
|
369
|
+
spell: 'missile',
|
|
370
|
+
config: QUICK_CONFIG,
|
|
371
|
+
missileAI: StandardHoming,
|
|
372
|
+
direction: angleTo(state.position, enemy.position),
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Standard attacks at combat range
|
|
379
|
+
if (distance < attackRange)
|
|
380
|
+
{
|
|
381
|
+
const heavyCastTime = getMissileCastTime(HEAVY_CONFIG);
|
|
382
|
+
if (isSafeToAttack(threats, heavyCastTime))
|
|
383
|
+
{
|
|
384
|
+
return {
|
|
385
|
+
move,
|
|
386
|
+
startCast: {
|
|
387
|
+
spell: 'missile',
|
|
388
|
+
config: HEAVY_CONFIG,
|
|
389
|
+
missileAI: StandardHoming,
|
|
390
|
+
direction: angleTo(state.position, enemy.position),
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Fallback: quick homing missile when heavy isn't safe (only if in range)
|
|
396
|
+
const quickCastTime = getMissileCastTime(QUICK_CONFIG);
|
|
397
|
+
if (
|
|
398
|
+
distance < QUICK_RANGE * 0.9 &&
|
|
399
|
+
isSafeToAttack(threats, quickCastTime)
|
|
400
|
+
)
|
|
401
|
+
{
|
|
402
|
+
return {
|
|
403
|
+
move,
|
|
404
|
+
startCast: {
|
|
405
|
+
spell: 'missile',
|
|
406
|
+
config: QUICK_CONFIG,
|
|
407
|
+
missileAI: StandardHoming,
|
|
408
|
+
direction: angleTo(state.position, enemy.position),
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Not safe to attack — wait for threat to pass, then fire
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// === FALLBACK DEFENSE: Shield when no attack window exists ===
|
|
418
|
+
if (
|
|
419
|
+
state.state === 'idle' &&
|
|
420
|
+
urgentThreat &&
|
|
421
|
+
!urgentThreat.bestDodgeDirection &&
|
|
422
|
+
urgentThreat.canBlockInTime &&
|
|
423
|
+
urgentThreat.ticksToStartShield <= 3
|
|
424
|
+
)
|
|
425
|
+
{
|
|
426
|
+
return {
|
|
427
|
+
move: {x: 0, y: 0},
|
|
428
|
+
startCast: {spell: 'shield'},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {move};
|
|
433
|
+
};
|