@vibemancer/core 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-7VDJHO22.js} +3 -3
- package/dist/chunk-7VDJHO22.js.map +1 -0
- package/dist/index-browser.d.ts +1 -1
- package/dist/index-browser.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +79 -78
- package/src/bots/berserker/01_Stormchaser.ts +457 -457
- package/src/bots/berserker/02_Stormcaller.ts +417 -417
- package/src/bots/berserker/03_Stormforger.ts +481 -481
- package/src/bots/caster/01_Flamecaller.ts +286 -286
- package/src/bots/caster/02_Pyromancer.ts +350 -350
- package/src/bots/caster/03_Infernalist.ts +492 -492
- package/src/bots/defensive/01_Turtle.ts +151 -151
- package/src/bots/defensive/02_Sentinel.ts +134 -134
- package/src/bots/defensive/03_Golem.ts +357 -357
- package/src/bots/duelist/01_Battlemage.ts +433 -433
- package/src/bots/duelist/02_Warmage.ts +438 -438
- package/src/bots/duelist/03_Archmage.ts +588 -588
- package/src/bots/homing/01_Bonemancer.ts +67 -67
- package/src/bots/homing/02_Lich.ts +356 -356
- package/src/bots/homing/03_Archlich.ts +220 -220
- package/src/bots/kiter/01_Spellspinner.ts +398 -398
- package/src/bots/kiter/02_Spellweaver.ts +378 -378
- package/src/bots/kiter/03_Spellbinder.ts +448 -448
- package/src/bots/melee/01_Shadowblade.ts +270 -270
- package/src/bots/melee/02_Nightblade.ts +437 -437
- package/src/bots/melee/03_Voidblade.ts +582 -582
- package/src/bots/sniper/01_Spellshot.ts +385 -385
- package/src/bots/sniper/02_Spelltracer.ts +441 -441
- package/src/bots/sniper/03_Spellseeker.ts +546 -546
- package/src/bots/standalone/Critter.ts +89 -89
- package/src/bots/standalone/Doombringer.ts +91 -91
- package/src/bots/standalone/Hogger.ts +228 -228
- package/src/bots/standalone/Rookie.ts +50 -50
- package/src/bots/standalone/TargetDummy.ts +21 -21
- package/src/bots/test/cheater.ts +405 -405
- package/src/bots/test/crasher.ts +81 -81
- package/src/engine/hooks-runtime.ts +394 -394
- package/src/engine/manual-match.ts +289 -289
- package/src/engine/missile-templates.ts +155 -155
- package/src/engine/physics.ts +143 -143
- package/src/engine/simulation.ts +828 -828
- package/src/engine/spells.ts +128 -128
- package/src/engine-version.ts +1 -1
- package/src/index.ts +23 -23
- package/src/rules.ts +254 -254
- package/src/types.ts +193 -193
- package/src/utils/index.ts +6 -6
- package/dist/chunk-L7Z7OFXD.js.map +0 -1
|
@@ -1,385 +1,385 @@
|
|
|
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} from '../../utils/combat.js';
|
|
6
|
-
import {
|
|
7
|
-
getThreats,
|
|
8
|
-
getMostUrgentThreat,
|
|
9
|
-
getDodgeDirectionForMovement,
|
|
10
|
-
isSafeToAttack,
|
|
11
|
-
getBlinkToCenter,
|
|
12
|
-
getBlinkToIncreaseDistance,
|
|
13
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
14
|
-
shouldForceShield,
|
|
15
|
-
} from '../shared.js';
|
|
16
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
17
|
-
|
|
18
|
-
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
19
|
-
{
|
|
20
|
-
const enemy = worldState.enemies[0];
|
|
21
|
-
return {turnToward: enemy?.position};
|
|
22
|
-
};
|
|
23
|
-
const WALL_BUFFER = 50;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Smart combat movement: dodge using threat analysis, avoid walls, commit to direction.
|
|
27
|
-
*/
|
|
28
|
-
function combatMove(
|
|
29
|
-
position: {x: number; y: number},
|
|
30
|
-
enemy: {position: {x: number; y: number}},
|
|
31
|
-
currentDistance: number,
|
|
32
|
-
minDistance: number,
|
|
33
|
-
maxDistance: number,
|
|
34
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
35
|
-
tick: number,
|
|
36
|
-
strafeCyclePeriod: number,
|
|
37
|
-
strafeIntensity: number,
|
|
38
|
-
approachSpeed: number,
|
|
39
|
-
): {x: number; y: number}
|
|
40
|
-
{
|
|
41
|
-
const deltaX = enemy.position.x - position.x;
|
|
42
|
-
const deltaY = enemy.position.y - position.y;
|
|
43
|
-
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
44
|
-
|
|
45
|
-
const strafeClockwise = {
|
|
46
|
-
x: -deltaY / normalizedDistance,
|
|
47
|
-
y: deltaX / normalizedDistance,
|
|
48
|
-
};
|
|
49
|
-
const strafeCounterClockwise = {
|
|
50
|
-
x: deltaY / normalizedDistance,
|
|
51
|
-
y: -deltaX / normalizedDistance,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
let strafeDirection: {x: number; y: number};
|
|
55
|
-
|
|
56
|
-
// Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
|
|
57
|
-
if (dodgeDirection)
|
|
58
|
-
{
|
|
59
|
-
strafeDirection = dodgeDirection;
|
|
60
|
-
}
|
|
61
|
-
// Priority 2: Avoid walls
|
|
62
|
-
else if (
|
|
63
|
-
position.x < WALL_BUFFER ||
|
|
64
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
65
|
-
position.y < WALL_BUFFER ||
|
|
66
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
67
|
-
)
|
|
68
|
-
{
|
|
69
|
-
const futureClockwise = {
|
|
70
|
-
x: position.x + strafeClockwise.x * 100,
|
|
71
|
-
y: position.y + strafeClockwise.y * 100,
|
|
72
|
-
};
|
|
73
|
-
const futureCounterClockwise = {
|
|
74
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
75
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
76
|
-
};
|
|
77
|
-
const scoreClockwise = Math.min(
|
|
78
|
-
futureClockwise.x,
|
|
79
|
-
ARENA_SIZE - futureClockwise.x,
|
|
80
|
-
futureClockwise.y,
|
|
81
|
-
ARENA_SIZE - futureClockwise.y,
|
|
82
|
-
);
|
|
83
|
-
const scoreCounterClockwise = Math.min(
|
|
84
|
-
futureCounterClockwise.x,
|
|
85
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
86
|
-
futureCounterClockwise.y,
|
|
87
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
88
|
-
);
|
|
89
|
-
strafeDirection =
|
|
90
|
-
scoreClockwise > scoreCounterClockwise
|
|
91
|
-
? strafeClockwise
|
|
92
|
-
: strafeCounterClockwise;
|
|
93
|
-
}
|
|
94
|
-
// Priority 3: Alternate strafe direction
|
|
95
|
-
else
|
|
96
|
-
{
|
|
97
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
98
|
-
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// When actively dodging, commit fully — no approach/retreat dilution
|
|
102
|
-
if (dodgeDirection)
|
|
103
|
-
{
|
|
104
|
-
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
let moveX = strafeDirection.x * strafeIntensity;
|
|
108
|
-
let moveY = strafeDirection.y * strafeIntensity;
|
|
109
|
-
|
|
110
|
-
// Distance management (only when not dodging)
|
|
111
|
-
if (currentDistance > maxDistance)
|
|
112
|
-
{
|
|
113
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
114
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
115
|
-
}
|
|
116
|
-
else if (currentDistance < minDistance)
|
|
117
|
-
{
|
|
118
|
-
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
119
|
-
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
120
|
-
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
121
|
-
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
122
|
-
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
123
|
-
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
124
|
-
moveX += retreatX;
|
|
125
|
-
moveY += retreatY;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
129
|
-
if (magnitude > 100)
|
|
130
|
-
{
|
|
131
|
-
moveX = (moveX / magnitude) * 100;
|
|
132
|
-
moveY = (moveY / magnitude) * 100;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {x: moveX, y: moveY};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Bot: Spellshot
|
|
140
|
-
*
|
|
141
|
-
* BEHAVIOR: Uses interceptAngle to calculate where the enemy will be and fires
|
|
142
|
-
* fast, non-homing missiles (speed 8, turnRate 0) along the predicted path.
|
|
143
|
-
* Strafes at medium range (300-400), shields undodgeable threats, emergency
|
|
144
|
-
* blinks when shield isn't available. The key mechanic is PREDICTION — these
|
|
145
|
-
* missiles don't track, they go exactly where you calculated the enemy would be.
|
|
146
|
-
*
|
|
147
|
-
* NAMING RATIONALE: "Spellshot" — a spell that is a single, precisely aimed shot.
|
|
148
|
-
* Like a sniper's "called shot" but magical. The defining feature is the intercept
|
|
149
|
-
* calculation: this bot doesn't fire tracking missiles, it calculates the exact
|
|
150
|
-
* angle needed to hit a moving target. "Shot" implies precision, singular impact,
|
|
151
|
-
* and skill-based aiming — everything this bot is about.
|
|
152
|
-
*
|
|
153
|
-
* PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
|
|
154
|
-
* - Spellshot (tier 1): Basic intercept prediction, non-homing missiles
|
|
155
|
-
* - Spelltracer (tier 2): Future — predictive homing (missiles that lead AND track)
|
|
156
|
-
* - Spellseeker (tier 3): Future — perfect prediction, multi-angle attacks
|
|
157
|
-
* The naming progression: shot (single bullet) → tracer (bullet that tracks a path)
|
|
158
|
-
* → seeker (actively hunts). Each tier adds more intelligence to the projectile,
|
|
159
|
-
* evolving from "I calculate where you'll be" to "my missile calculates where you'll be."
|
|
160
|
-
*
|
|
161
|
-
* NOTE: A separate future archetype "Spellslinger" (volume-of-fire) is reserved
|
|
162
|
-
* for a rapid-fire bot that prioritizes quantity over prediction.
|
|
163
|
-
*
|
|
164
|
-
* TIER: 1 (base)
|
|
165
|
-
*/
|
|
166
|
-
export const Spellshot: WizardFunction = ({state}) =>
|
|
167
|
-
{
|
|
168
|
-
const dangerDistance = useParam('dangerDistance', 45, {range: 100, min: 0});
|
|
169
|
-
const targetDistance = useParam('targetDistance', 339, {
|
|
170
|
-
range: 150,
|
|
171
|
-
min: 0,
|
|
172
|
-
});
|
|
173
|
-
const distanceDeadZone = useParam('distanceDeadZone', 22, {
|
|
174
|
-
range: 50,
|
|
175
|
-
min: 0,
|
|
176
|
-
});
|
|
177
|
-
const minCombatDistance = targetDistance - distanceDeadZone;
|
|
178
|
-
const maxCombatDistance = targetDistance + distanceDeadZone;
|
|
179
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 230, {
|
|
180
|
-
range: 75,
|
|
181
|
-
min: 80,
|
|
182
|
-
max: 300,
|
|
183
|
-
});
|
|
184
|
-
const strafeIntensity = useParam('strafeIntensity', 36, {
|
|
185
|
-
range: 40,
|
|
186
|
-
min: 20,
|
|
187
|
-
max: 100,
|
|
188
|
-
steps: 7,
|
|
189
|
-
});
|
|
190
|
-
const approachSpeed = useParam('approachSpeed', 78, {
|
|
191
|
-
range: 30,
|
|
192
|
-
min: 10,
|
|
193
|
-
max: 80,
|
|
194
|
-
steps: 7,
|
|
195
|
-
});
|
|
196
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
197
|
-
range: 75,
|
|
198
|
-
min: 50,
|
|
199
|
-
max: 300,
|
|
200
|
-
});
|
|
201
|
-
const missileDamage = useParam('missileDamage', 30, {
|
|
202
|
-
range: 10,
|
|
203
|
-
min: 5,
|
|
204
|
-
max: 30,
|
|
205
|
-
steps: 5,
|
|
206
|
-
});
|
|
207
|
-
const missileSpeed = useParam('missileSpeed', 12, {
|
|
208
|
-
range: 3,
|
|
209
|
-
min: 3,
|
|
210
|
-
max: 12,
|
|
211
|
-
steps: 5,
|
|
212
|
-
});
|
|
213
|
-
const missileTurnRate = useParam('missileTurnRate', 0.3, {
|
|
214
|
-
range: 1.5,
|
|
215
|
-
steps: 5,
|
|
216
|
-
});
|
|
217
|
-
const missileMinDuration = useParam('missileMinDuration', 55, {
|
|
218
|
-
range: 30,
|
|
219
|
-
min: 20,
|
|
220
|
-
max: 100,
|
|
221
|
-
steps: 7,
|
|
222
|
-
});
|
|
223
|
-
const durationRangeFactor = useParam('durationRangeFactor', 0.67, {
|
|
224
|
-
range: 0.5,
|
|
225
|
-
min: 0.5,
|
|
226
|
-
max: 2.0,
|
|
227
|
-
steps: 5,
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const enemy = state.enemies[0];
|
|
231
|
-
if (!enemy)
|
|
232
|
-
{
|
|
233
|
-
return {move: {x: 0, y: 0}};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
237
|
-
|
|
238
|
-
// Analyze threats using simulation
|
|
239
|
-
const threats = getThreats(state);
|
|
240
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
241
|
-
|
|
242
|
-
// === DEFENSE: Handle channeling ===
|
|
243
|
-
if (state.state === 'channeling')
|
|
244
|
-
{
|
|
245
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
246
|
-
{
|
|
247
|
-
return {move: {x: 0, y: 0}, cancel: true};
|
|
248
|
-
}
|
|
249
|
-
return {move: {x: 0, y: 0}};
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// === DEFENSE: Cancel missile cast if dangerous threat will arrive before we can shield ===
|
|
253
|
-
// Only cancel for: high-damage homing (always) or undodgeable when health is at risk
|
|
254
|
-
if (
|
|
255
|
-
state.state === 'casting' &&
|
|
256
|
-
state.castingSpell === 'missile' &&
|
|
257
|
-
urgentThreat &&
|
|
258
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
259
|
-
)
|
|
260
|
-
{
|
|
261
|
-
const mustCancel =
|
|
262
|
-
shouldForceShield(urgentThreat) ||
|
|
263
|
-
state.health <= urgentThreat.projectile.damage * 2 + 1;
|
|
264
|
-
if (mustCancel)
|
|
265
|
-
{
|
|
266
|
-
const remainingCast =
|
|
267
|
-
(state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
268
|
-
if (
|
|
269
|
-
urgentThreat.ticksToImpact <=
|
|
270
|
-
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 3
|
|
271
|
-
)
|
|
272
|
-
{
|
|
273
|
-
const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
|
|
274
|
-
const cancelMove = combatMove(
|
|
275
|
-
state.position,
|
|
276
|
-
enemy,
|
|
277
|
-
distance,
|
|
278
|
-
minCombatDistance,
|
|
279
|
-
maxCombatDistance,
|
|
280
|
-
dodgeDir,
|
|
281
|
-
state.tick,
|
|
282
|
-
strafeCyclePeriod,
|
|
283
|
-
strafeIntensity,
|
|
284
|
-
approachSpeed,
|
|
285
|
-
);
|
|
286
|
-
return {move: cancelMove, cancel: true};
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// === DEFENSE: Shield undodgeable or high-damage homing threats ===
|
|
292
|
-
if (
|
|
293
|
-
state.state === 'idle' &&
|
|
294
|
-
urgentThreat &&
|
|
295
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
296
|
-
)
|
|
297
|
-
{
|
|
298
|
-
if (urgentThreat.canBlockInTime)
|
|
299
|
-
{
|
|
300
|
-
// Start shield at the right time (small buffer for safety)
|
|
301
|
-
if (urgentThreat.ticksToStartShield <= 3)
|
|
302
|
-
{
|
|
303
|
-
return {
|
|
304
|
-
move: {x: 0, y: 0},
|
|
305
|
-
startCast: {spell: 'shield'},
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
// Can't dodge, can't shield → blink away (emergency)
|
|
310
|
-
else if (state.blinkCooldown === 0)
|
|
311
|
-
{
|
|
312
|
-
return {
|
|
313
|
-
move: {x: 0, y: 0},
|
|
314
|
-
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
|
|
320
|
-
if (
|
|
321
|
-
state.state === 'idle' &&
|
|
322
|
-
distance < dangerDistance &&
|
|
323
|
-
state.blinkCooldown === 0 &&
|
|
324
|
-
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
325
|
-
)
|
|
326
|
-
{
|
|
327
|
-
const blinkTarget = getBlinkToIncreaseDistance(
|
|
328
|
-
state.position,
|
|
329
|
-
enemy.position,
|
|
330
|
-
state.projectiles,
|
|
331
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
332
|
-
);
|
|
333
|
-
if (blinkTarget)
|
|
334
|
-
{
|
|
335
|
-
return {
|
|
336
|
-
move: {x: 0, y: 0},
|
|
337
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Smart combat movement
|
|
343
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
344
|
-
const move = combatMove(
|
|
345
|
-
state.position,
|
|
346
|
-
enemy,
|
|
347
|
-
distance,
|
|
348
|
-
minCombatDistance,
|
|
349
|
-
maxCombatDistance,
|
|
350
|
-
dodgeDirection,
|
|
351
|
-
state.tick,
|
|
352
|
-
strafeCyclePeriod,
|
|
353
|
-
strafeIntensity,
|
|
354
|
-
approachSpeed,
|
|
355
|
-
);
|
|
356
|
-
|
|
357
|
-
// === OFFENSE: Fast homing missiles ===
|
|
358
|
-
if (state.state === 'idle' && distance < 600)
|
|
359
|
-
{
|
|
360
|
-
const missileConfig = {
|
|
361
|
-
damage: missileDamage,
|
|
362
|
-
speed: missileSpeed,
|
|
363
|
-
turnRate: missileTurnRate,
|
|
364
|
-
duration: Math.max(
|
|
365
|
-
missileMinDuration,
|
|
366
|
-
Math.ceil((distance * durationRangeFactor) / missileSpeed),
|
|
367
|
-
),
|
|
368
|
-
};
|
|
369
|
-
const castTime = getMissileCastTime(missileConfig, state.lastMissileConfig);
|
|
370
|
-
if (isSafeToAttack(threats, castTime))
|
|
371
|
-
{
|
|
372
|
-
return {
|
|
373
|
-
move,
|
|
374
|
-
startCast: {
|
|
375
|
-
spell: 'missile',
|
|
376
|
-
config: missileConfig,
|
|
377
|
-
missileAI: HomingAI,
|
|
378
|
-
direction: angleTo(state.position, enemy.position),
|
|
379
|
-
},
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
return {move};
|
|
385
|
-
};
|
|
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} from '../../utils/combat.js';
|
|
6
|
+
import {
|
|
7
|
+
getThreats,
|
|
8
|
+
getMostUrgentThreat,
|
|
9
|
+
getDodgeDirectionForMovement,
|
|
10
|
+
isSafeToAttack,
|
|
11
|
+
getBlinkToCenter,
|
|
12
|
+
getBlinkToIncreaseDistance,
|
|
13
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
14
|
+
shouldForceShield,
|
|
15
|
+
} from '../shared.js';
|
|
16
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
17
|
+
|
|
18
|
+
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
19
|
+
{
|
|
20
|
+
const enemy = worldState.enemies[0];
|
|
21
|
+
return {turnToward: enemy?.position};
|
|
22
|
+
};
|
|
23
|
+
const WALL_BUFFER = 50;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Smart combat movement: dodge using threat analysis, avoid walls, commit to direction.
|
|
27
|
+
*/
|
|
28
|
+
function combatMove(
|
|
29
|
+
position: {x: number; y: number},
|
|
30
|
+
enemy: {position: {x: number; y: number}},
|
|
31
|
+
currentDistance: number,
|
|
32
|
+
minDistance: number,
|
|
33
|
+
maxDistance: number,
|
|
34
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
35
|
+
tick: number,
|
|
36
|
+
strafeCyclePeriod: number,
|
|
37
|
+
strafeIntensity: number,
|
|
38
|
+
approachSpeed: number,
|
|
39
|
+
): {x: number; y: number}
|
|
40
|
+
{
|
|
41
|
+
const deltaX = enemy.position.x - position.x;
|
|
42
|
+
const deltaY = enemy.position.y - position.y;
|
|
43
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
44
|
+
|
|
45
|
+
const strafeClockwise = {
|
|
46
|
+
x: -deltaY / normalizedDistance,
|
|
47
|
+
y: deltaX / normalizedDistance,
|
|
48
|
+
};
|
|
49
|
+
const strafeCounterClockwise = {
|
|
50
|
+
x: deltaY / normalizedDistance,
|
|
51
|
+
y: -deltaX / normalizedDistance,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let strafeDirection: {x: number; y: number};
|
|
55
|
+
|
|
56
|
+
// Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
|
|
57
|
+
if (dodgeDirection)
|
|
58
|
+
{
|
|
59
|
+
strafeDirection = dodgeDirection;
|
|
60
|
+
}
|
|
61
|
+
// Priority 2: Avoid walls
|
|
62
|
+
else if (
|
|
63
|
+
position.x < WALL_BUFFER ||
|
|
64
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
65
|
+
position.y < WALL_BUFFER ||
|
|
66
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
67
|
+
)
|
|
68
|
+
{
|
|
69
|
+
const futureClockwise = {
|
|
70
|
+
x: position.x + strafeClockwise.x * 100,
|
|
71
|
+
y: position.y + strafeClockwise.y * 100,
|
|
72
|
+
};
|
|
73
|
+
const futureCounterClockwise = {
|
|
74
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
75
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
76
|
+
};
|
|
77
|
+
const scoreClockwise = Math.min(
|
|
78
|
+
futureClockwise.x,
|
|
79
|
+
ARENA_SIZE - futureClockwise.x,
|
|
80
|
+
futureClockwise.y,
|
|
81
|
+
ARENA_SIZE - futureClockwise.y,
|
|
82
|
+
);
|
|
83
|
+
const scoreCounterClockwise = Math.min(
|
|
84
|
+
futureCounterClockwise.x,
|
|
85
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
86
|
+
futureCounterClockwise.y,
|
|
87
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
88
|
+
);
|
|
89
|
+
strafeDirection =
|
|
90
|
+
scoreClockwise > scoreCounterClockwise
|
|
91
|
+
? strafeClockwise
|
|
92
|
+
: strafeCounterClockwise;
|
|
93
|
+
}
|
|
94
|
+
// Priority 3: Alternate strafe direction
|
|
95
|
+
else
|
|
96
|
+
{
|
|
97
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
98
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// When actively dodging, commit fully — no approach/retreat dilution
|
|
102
|
+
if (dodgeDirection)
|
|
103
|
+
{
|
|
104
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
108
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
109
|
+
|
|
110
|
+
// Distance management (only when not dodging)
|
|
111
|
+
if (currentDistance > maxDistance)
|
|
112
|
+
{
|
|
113
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
114
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
115
|
+
}
|
|
116
|
+
else if (currentDistance < minDistance)
|
|
117
|
+
{
|
|
118
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
119
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
120
|
+
if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
121
|
+
if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
|
|
122
|
+
if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
123
|
+
if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
|
|
124
|
+
moveX += retreatX;
|
|
125
|
+
moveY += retreatY;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
129
|
+
if (magnitude > 100)
|
|
130
|
+
{
|
|
131
|
+
moveX = (moveX / magnitude) * 100;
|
|
132
|
+
moveY = (moveY / magnitude) * 100;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {x: moveX, y: moveY};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bot: Spellshot
|
|
140
|
+
*
|
|
141
|
+
* BEHAVIOR: Uses interceptAngle to calculate where the enemy will be and fires
|
|
142
|
+
* fast, non-homing missiles (speed 8, turnRate 0) along the predicted path.
|
|
143
|
+
* Strafes at medium range (300-400), shields undodgeable threats, emergency
|
|
144
|
+
* blinks when shield isn't available. The key mechanic is PREDICTION — these
|
|
145
|
+
* missiles don't track, they go exactly where you calculated the enemy would be.
|
|
146
|
+
*
|
|
147
|
+
* NAMING RATIONALE: "Spellshot" — a spell that is a single, precisely aimed shot.
|
|
148
|
+
* Like a sniper's "called shot" but magical. The defining feature is the intercept
|
|
149
|
+
* calculation: this bot doesn't fire tracking missiles, it calculates the exact
|
|
150
|
+
* angle needed to hit a moving target. "Shot" implies precision, singular impact,
|
|
151
|
+
* and skill-based aiming — everything this bot is about.
|
|
152
|
+
*
|
|
153
|
+
* PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
|
|
154
|
+
* - Spellshot (tier 1): Basic intercept prediction, non-homing missiles
|
|
155
|
+
* - Spelltracer (tier 2): Future — predictive homing (missiles that lead AND track)
|
|
156
|
+
* - Spellseeker (tier 3): Future — perfect prediction, multi-angle attacks
|
|
157
|
+
* The naming progression: shot (single bullet) → tracer (bullet that tracks a path)
|
|
158
|
+
* → seeker (actively hunts). Each tier adds more intelligence to the projectile,
|
|
159
|
+
* evolving from "I calculate where you'll be" to "my missile calculates where you'll be."
|
|
160
|
+
*
|
|
161
|
+
* NOTE: A separate future archetype "Spellslinger" (volume-of-fire) is reserved
|
|
162
|
+
* for a rapid-fire bot that prioritizes quantity over prediction.
|
|
163
|
+
*
|
|
164
|
+
* TIER: 1 (base)
|
|
165
|
+
*/
|
|
166
|
+
export const Spellshot: WizardFunction = ({state}) =>
|
|
167
|
+
{
|
|
168
|
+
const dangerDistance = useParam('dangerDistance', 45, {range: 100, min: 0});
|
|
169
|
+
const targetDistance = useParam('targetDistance', 339, {
|
|
170
|
+
range: 150,
|
|
171
|
+
min: 0,
|
|
172
|
+
});
|
|
173
|
+
const distanceDeadZone = useParam('distanceDeadZone', 22, {
|
|
174
|
+
range: 50,
|
|
175
|
+
min: 0,
|
|
176
|
+
});
|
|
177
|
+
const minCombatDistance = targetDistance - distanceDeadZone;
|
|
178
|
+
const maxCombatDistance = targetDistance + distanceDeadZone;
|
|
179
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 230, {
|
|
180
|
+
range: 75,
|
|
181
|
+
min: 80,
|
|
182
|
+
max: 300,
|
|
183
|
+
});
|
|
184
|
+
const strafeIntensity = useParam('strafeIntensity', 36, {
|
|
185
|
+
range: 40,
|
|
186
|
+
min: 20,
|
|
187
|
+
max: 100,
|
|
188
|
+
steps: 7,
|
|
189
|
+
});
|
|
190
|
+
const approachSpeed = useParam('approachSpeed', 78, {
|
|
191
|
+
range: 30,
|
|
192
|
+
min: 10,
|
|
193
|
+
max: 80,
|
|
194
|
+
steps: 7,
|
|
195
|
+
});
|
|
196
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
197
|
+
range: 75,
|
|
198
|
+
min: 50,
|
|
199
|
+
max: 300,
|
|
200
|
+
});
|
|
201
|
+
const missileDamage = useParam('missileDamage', 30, {
|
|
202
|
+
range: 10,
|
|
203
|
+
min: 5,
|
|
204
|
+
max: 30,
|
|
205
|
+
steps: 5,
|
|
206
|
+
});
|
|
207
|
+
const missileSpeed = useParam('missileSpeed', 12, {
|
|
208
|
+
range: 3,
|
|
209
|
+
min: 3,
|
|
210
|
+
max: 12,
|
|
211
|
+
steps: 5,
|
|
212
|
+
});
|
|
213
|
+
const missileTurnRate = useParam('missileTurnRate', 0.3, {
|
|
214
|
+
range: 1.5,
|
|
215
|
+
steps: 5,
|
|
216
|
+
});
|
|
217
|
+
const missileMinDuration = useParam('missileMinDuration', 55, {
|
|
218
|
+
range: 30,
|
|
219
|
+
min: 20,
|
|
220
|
+
max: 100,
|
|
221
|
+
steps: 7,
|
|
222
|
+
});
|
|
223
|
+
const durationRangeFactor = useParam('durationRangeFactor', 0.67, {
|
|
224
|
+
range: 0.5,
|
|
225
|
+
min: 0.5,
|
|
226
|
+
max: 2.0,
|
|
227
|
+
steps: 5,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const enemy = state.enemies[0];
|
|
231
|
+
if (!enemy)
|
|
232
|
+
{
|
|
233
|
+
return {move: {x: 0, y: 0}};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
237
|
+
|
|
238
|
+
// Analyze threats using simulation
|
|
239
|
+
const threats = getThreats(state);
|
|
240
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
241
|
+
|
|
242
|
+
// === DEFENSE: Handle channeling ===
|
|
243
|
+
if (state.state === 'channeling')
|
|
244
|
+
{
|
|
245
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
246
|
+
{
|
|
247
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
248
|
+
}
|
|
249
|
+
return {move: {x: 0, y: 0}};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// === DEFENSE: Cancel missile cast if dangerous threat will arrive before we can shield ===
|
|
253
|
+
// Only cancel for: high-damage homing (always) or undodgeable when health is at risk
|
|
254
|
+
if (
|
|
255
|
+
state.state === 'casting' &&
|
|
256
|
+
state.castingSpell === 'missile' &&
|
|
257
|
+
urgentThreat &&
|
|
258
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
259
|
+
)
|
|
260
|
+
{
|
|
261
|
+
const mustCancel =
|
|
262
|
+
shouldForceShield(urgentThreat) ||
|
|
263
|
+
state.health <= urgentThreat.projectile.damage * 2 + 1;
|
|
264
|
+
if (mustCancel)
|
|
265
|
+
{
|
|
266
|
+
const remainingCast =
|
|
267
|
+
(state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
268
|
+
if (
|
|
269
|
+
urgentThreat.ticksToImpact <=
|
|
270
|
+
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + 3
|
|
271
|
+
)
|
|
272
|
+
{
|
|
273
|
+
const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
|
|
274
|
+
const cancelMove = combatMove(
|
|
275
|
+
state.position,
|
|
276
|
+
enemy,
|
|
277
|
+
distance,
|
|
278
|
+
minCombatDistance,
|
|
279
|
+
maxCombatDistance,
|
|
280
|
+
dodgeDir,
|
|
281
|
+
state.tick,
|
|
282
|
+
strafeCyclePeriod,
|
|
283
|
+
strafeIntensity,
|
|
284
|
+
approachSpeed,
|
|
285
|
+
);
|
|
286
|
+
return {move: cancelMove, cancel: true};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// === DEFENSE: Shield undodgeable or high-damage homing threats ===
|
|
292
|
+
if (
|
|
293
|
+
state.state === 'idle' &&
|
|
294
|
+
urgentThreat &&
|
|
295
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
296
|
+
)
|
|
297
|
+
{
|
|
298
|
+
if (urgentThreat.canBlockInTime)
|
|
299
|
+
{
|
|
300
|
+
// Start shield at the right time (small buffer for safety)
|
|
301
|
+
if (urgentThreat.ticksToStartShield <= 3)
|
|
302
|
+
{
|
|
303
|
+
return {
|
|
304
|
+
move: {x: 0, y: 0},
|
|
305
|
+
startCast: {spell: 'shield'},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Can't dodge, can't shield → blink away (emergency)
|
|
310
|
+
else if (state.blinkCooldown === 0)
|
|
311
|
+
{
|
|
312
|
+
return {
|
|
313
|
+
move: {x: 0, y: 0},
|
|
314
|
+
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
|
|
320
|
+
if (
|
|
321
|
+
state.state === 'idle' &&
|
|
322
|
+
distance < dangerDistance &&
|
|
323
|
+
state.blinkCooldown === 0 &&
|
|
324
|
+
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
325
|
+
)
|
|
326
|
+
{
|
|
327
|
+
const blinkTarget = getBlinkToIncreaseDistance(
|
|
328
|
+
state.position,
|
|
329
|
+
enemy.position,
|
|
330
|
+
state.projectiles,
|
|
331
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
332
|
+
);
|
|
333
|
+
if (blinkTarget)
|
|
334
|
+
{
|
|
335
|
+
return {
|
|
336
|
+
move: {x: 0, y: 0},
|
|
337
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Smart combat movement
|
|
343
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
344
|
+
const move = combatMove(
|
|
345
|
+
state.position,
|
|
346
|
+
enemy,
|
|
347
|
+
distance,
|
|
348
|
+
minCombatDistance,
|
|
349
|
+
maxCombatDistance,
|
|
350
|
+
dodgeDirection,
|
|
351
|
+
state.tick,
|
|
352
|
+
strafeCyclePeriod,
|
|
353
|
+
strafeIntensity,
|
|
354
|
+
approachSpeed,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// === OFFENSE: Fast homing missiles ===
|
|
358
|
+
if (state.state === 'idle' && distance < 600)
|
|
359
|
+
{
|
|
360
|
+
const missileConfig = {
|
|
361
|
+
damage: missileDamage,
|
|
362
|
+
speed: missileSpeed,
|
|
363
|
+
turnRate: missileTurnRate,
|
|
364
|
+
duration: Math.max(
|
|
365
|
+
missileMinDuration,
|
|
366
|
+
Math.ceil((distance * durationRangeFactor) / missileSpeed),
|
|
367
|
+
),
|
|
368
|
+
};
|
|
369
|
+
const castTime = getMissileCastTime(missileConfig, state.lastMissileConfig);
|
|
370
|
+
if (isSafeToAttack(threats, castTime))
|
|
371
|
+
{
|
|
372
|
+
return {
|
|
373
|
+
move,
|
|
374
|
+
startCast: {
|
|
375
|
+
spell: 'missile',
|
|
376
|
+
config: missileConfig,
|
|
377
|
+
missileAI: HomingAI,
|
|
378
|
+
direction: angleTo(state.position, enemy.position),
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {move};
|
|
385
|
+
};
|