@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.
Files changed (50) hide show
  1. package/README.md +28 -28
  2. package/dist/{chunk-L7Z7OFXD.js → chunk-7VDJHO22.js} +3 -3
  3. package/dist/chunk-7VDJHO22.js.map +1 -0
  4. package/dist/index-browser.d.ts +1 -1
  5. package/dist/index-browser.js +1 -1
  6. package/dist/index.js +1 -1
  7. package/package.json +79 -78
  8. package/src/bots/berserker/01_Stormchaser.ts +457 -457
  9. package/src/bots/berserker/02_Stormcaller.ts +417 -417
  10. package/src/bots/berserker/03_Stormforger.ts +481 -481
  11. package/src/bots/caster/01_Flamecaller.ts +286 -286
  12. package/src/bots/caster/02_Pyromancer.ts +350 -350
  13. package/src/bots/caster/03_Infernalist.ts +492 -492
  14. package/src/bots/defensive/01_Turtle.ts +151 -151
  15. package/src/bots/defensive/02_Sentinel.ts +134 -134
  16. package/src/bots/defensive/03_Golem.ts +357 -357
  17. package/src/bots/duelist/01_Battlemage.ts +433 -433
  18. package/src/bots/duelist/02_Warmage.ts +438 -438
  19. package/src/bots/duelist/03_Archmage.ts +588 -588
  20. package/src/bots/homing/01_Bonemancer.ts +67 -67
  21. package/src/bots/homing/02_Lich.ts +356 -356
  22. package/src/bots/homing/03_Archlich.ts +220 -220
  23. package/src/bots/kiter/01_Spellspinner.ts +398 -398
  24. package/src/bots/kiter/02_Spellweaver.ts +378 -378
  25. package/src/bots/kiter/03_Spellbinder.ts +448 -448
  26. package/src/bots/melee/01_Shadowblade.ts +270 -270
  27. package/src/bots/melee/02_Nightblade.ts +437 -437
  28. package/src/bots/melee/03_Voidblade.ts +582 -582
  29. package/src/bots/sniper/01_Spellshot.ts +385 -385
  30. package/src/bots/sniper/02_Spelltracer.ts +441 -441
  31. package/src/bots/sniper/03_Spellseeker.ts +546 -546
  32. package/src/bots/standalone/Critter.ts +89 -89
  33. package/src/bots/standalone/Doombringer.ts +91 -91
  34. package/src/bots/standalone/Hogger.ts +228 -228
  35. package/src/bots/standalone/Rookie.ts +50 -50
  36. package/src/bots/standalone/TargetDummy.ts +21 -21
  37. package/src/bots/test/cheater.ts +405 -405
  38. package/src/bots/test/crasher.ts +81 -81
  39. package/src/engine/hooks-runtime.ts +394 -394
  40. package/src/engine/manual-match.ts +289 -289
  41. package/src/engine/missile-templates.ts +155 -155
  42. package/src/engine/physics.ts +143 -143
  43. package/src/engine/simulation.ts +828 -828
  44. package/src/engine/spells.ts +128 -128
  45. package/src/engine-version.ts +1 -1
  46. package/src/index.ts +23 -23
  47. package/src/rules.ts +254 -254
  48. package/src/types.ts +193 -193
  49. package/src/utils/index.ts +6 -6
  50. package/dist/chunk-L7Z7OFXD.js.map +0 -1
@@ -1,441 +1,441 @@
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 {
6
- getThreats,
7
- getMostUrgentThreat,
8
- getDodgeDirectionForMovement,
9
- getBlinkToCenter,
10
- getBlinkToIncreaseDistance,
11
- fitMissileToBudget,
12
- MIN_BLINK_ESCAPE_DISTANCE,
13
- shouldForceShield,
14
- MAX_CAST_BUDGET,
15
- getEnemyVulnerabilityTicks,
16
- } from '../shared.js';
17
- import {useParam} from '../../engine/params-runtime.js';
18
-
19
- const HomingAI: MissileAIFunction = ({worldState}) =>
20
- {
21
- const enemy = worldState.enemies[0];
22
- return {turnToward: enemy?.position};
23
- };
24
- const WALL_BUFFER = 50;
25
-
26
- const SHIELD_BUFFER = 3;
27
-
28
- /**
29
- * Smart combat movement: dodge using threat analysis, avoid walls, commit to direction.
30
- */
31
- function combatMove(
32
- position: {x: number; y: number},
33
- enemy: {position: {x: number; y: number}},
34
- currentDistance: number,
35
- minDistance: number,
36
- maxDistance: number,
37
- dodgeDirection: {x: number; y: number} | null,
38
- tick: number,
39
- strafeCyclePeriod: number,
40
- strafeIntensity: number,
41
- approachSpeed: number,
42
- ): {x: number; y: number}
43
- {
44
- const deltaX = enemy.position.x - position.x;
45
- const deltaY = enemy.position.y - position.y;
46
- const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
47
-
48
- const strafeClockwise = {
49
- x: -deltaY / normalizedDistance,
50
- y: deltaX / normalizedDistance,
51
- };
52
- const strafeCounterClockwise = {
53
- x: deltaY / normalizedDistance,
54
- y: -deltaX / normalizedDistance,
55
- };
56
-
57
- let strafeDirection: {x: number; y: number};
58
-
59
- if (dodgeDirection)
60
- {
61
- strafeDirection = dodgeDirection;
62
- }
63
- else if (
64
- position.x < WALL_BUFFER ||
65
- position.x > ARENA_SIZE - WALL_BUFFER ||
66
- position.y < WALL_BUFFER ||
67
- position.y > ARENA_SIZE - WALL_BUFFER
68
- )
69
- {
70
- const futureClockwise = {
71
- x: position.x + strafeClockwise.x * 100,
72
- y: position.y + strafeClockwise.y * 100,
73
- };
74
- const futureCounterClockwise = {
75
- x: position.x + strafeCounterClockwise.x * 100,
76
- y: position.y + strafeCounterClockwise.y * 100,
77
- };
78
- const scoreClockwise = Math.min(
79
- futureClockwise.x,
80
- ARENA_SIZE - futureClockwise.x,
81
- futureClockwise.y,
82
- ARENA_SIZE - futureClockwise.y,
83
- );
84
- const scoreCounterClockwise = Math.min(
85
- futureCounterClockwise.x,
86
- ARENA_SIZE - futureCounterClockwise.x,
87
- futureCounterClockwise.y,
88
- ARENA_SIZE - futureCounterClockwise.y,
89
- );
90
- strafeDirection =
91
- scoreClockwise > scoreCounterClockwise
92
- ? strafeClockwise
93
- : strafeCounterClockwise;
94
- }
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
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
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: Spelltracer
140
- *
141
- * BEHAVIOR: Enhanced ranged sniper with adaptive missile fitting and intercept
142
- * prediction. Uses fitMissileToBudget to find the highest-damage fast missile
143
- * that fits the safe window, then fires it along the predicted intercept angle.
144
- * Maintains medium-long range (300-450), shields undodgeable threats with proper
145
- * timing, emergency blinks, and distance blinks when cornered. The key mechanic
146
- * is still PREDICTION — but now with adaptive damage optimization.
147
- *
148
- * PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
149
- * - Spellshot (tier 1): Fixed config intercept prediction, non-homing missiles
150
- * - Spelltracer (tier 2): + adaptive fitting, timed defense, distance management
151
- * - Spellseeker (tier 3): Future — perfect prediction, multi-angle attacks
152
- *
153
- * TIER: 2 (enhanced Spellshot)
154
- */
155
- export const Spelltracer: WizardFunction = ({state}) =>
156
- {
157
- // Tunable parameters (run optimizer script to find optimal values)
158
- const dangerDistance = useParam('dangerDistance', 199, {
159
- range: 175,
160
- min: 0,
161
- });
162
- const targetDistance = useParam('targetDistance', 555, {range: 200, min: 0});
163
- const distanceDeadZone = useParam('distanceDeadZone', 12, {
164
- range: 50,
165
- min: 0,
166
- });
167
- const rangeMin = targetDistance - distanceDeadZone;
168
- const rangeMax = targetDistance + distanceDeadZone;
169
- const minTurnRate = useParam('minTurnRate', 0.9, {range: 1.5, steps: 5});
170
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 140, {
171
- range: 75,
172
- min: 80,
173
- max: 300,
174
- });
175
- const strafeIntensity = useParam('strafeIntensity', 48, {
176
- range: 40,
177
- min: 20,
178
- max: 100,
179
- steps: 7,
180
- });
181
- const approachSpeed = useParam('approachSpeed', 80, {
182
- range: 30,
183
- min: 10,
184
- max: 80,
185
- steps: 7,
186
- });
187
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
188
- range: 75,
189
- min: 50,
190
- max: 300,
191
- });
192
- const blinkWindowMax = useParam('blinkWindowMax', 23, {
193
- range: 40,
194
- min: 20,
195
- max: 120,
196
- steps: 7,
197
- });
198
- const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
199
- range: 4,
200
- min: 3,
201
- max: 12,
202
- steps: 5,
203
- });
204
- const enemyResponseEstimate = useParam('enemyResponseEstimate', 217, {
205
- range: 100,
206
- min: 50,
207
- max: 300,
208
- steps: 7,
209
- });
210
- const threatFallbackThreshold = useParam('threatFallbackThreshold', 250, {
211
- range: 100,
212
- min: 100,
213
- max: 400,
214
- steps: 7,
215
- });
216
-
217
- const enemy = state.enemies[0];
218
- if (!enemy)
219
- {
220
- return {move: {x: 0, y: 0}};
221
- }
222
-
223
- const distance = distanceTo(state.position, enemy.position);
224
-
225
- const threats = getThreats(state);
226
- const urgentThreat = getMostUrgentThreat(threats);
227
-
228
- // === DEFENSE: Handle channeling ===
229
- if (state.state === 'channeling')
230
- {
231
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
232
- {
233
- return {move: {x: 0, y: 0}, cancel: true};
234
- }
235
- return {move: {x: 0, y: 0}};
236
- }
237
-
238
- // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
239
- // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
240
- if (
241
- state.state === 'casting' &&
242
- state.castingSpell === 'missile' &&
243
- urgentThreat &&
244
- urgentThreat.projectile.damage >= state.health
245
- )
246
- {
247
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
248
- if (
249
- urgentThreat.ticksToImpact <=
250
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
251
- )
252
- {
253
- const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
254
- const cancelMove = combatMove(
255
- state.position,
256
- enemy,
257
- distance,
258
- rangeMin,
259
- rangeMax,
260
- dodgeDir,
261
- state.tick,
262
- strafeCyclePeriod,
263
- strafeIntensity,
264
- approachSpeed,
265
- );
266
- return {move: cancelMove, cancel: true};
267
- }
268
- }
269
-
270
- // === BLINK: Dodge undodgeable/forced-shield threats (offensive alternative to shielding) ===
271
- // When we would otherwise shield, blink instead — no GCD after blink, so can fire back immediately.
272
- // Only for threats that can't be dodged by movement (homing/undodgeable).
273
- if (
274
- state.state === 'idle' &&
275
- urgentThreat &&
276
- state.blinkCooldown === 0 &&
277
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
278
- urgentThreat.ticksToImpact >= 10 &&
279
- urgentThreat.ticksToImpact <= blinkWindowMax
280
- )
281
- {
282
- return {
283
- move: {x: 0, y: 0},
284
- startCast: {
285
- spell: 'blink',
286
- target:
287
- distance < targetDistance
288
- ? (getBlinkToIncreaseDistance(
289
- state.position,
290
- enemy.position,
291
- state.projectiles,
292
- ) ?? getBlinkToCenter(state.position))
293
- : getBlinkToCenter(state.position),
294
- },
295
- };
296
- }
297
-
298
- // === DEFENSE: Shield undodgeable or high-damage homing threats (when blink on cooldown) ===
299
- if (
300
- state.state === 'idle' &&
301
- urgentThreat &&
302
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
303
- )
304
- {
305
- if (urgentThreat.canBlockInTime)
306
- {
307
- if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
308
- {
309
- return {
310
- move: {x: 0, y: 0},
311
- startCast: {spell: 'shield'},
312
- };
313
- }
314
- }
315
- }
316
-
317
- // === DISTANCE BLINK: enemy too close, blink to reestablish range ===
318
- if (
319
- state.state === 'idle' &&
320
- distance < dangerDistance &&
321
- state.blinkCooldown === 0 &&
322
- (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
323
- )
324
- {
325
- const blinkTarget = getBlinkToIncreaseDistance(
326
- state.position,
327
- enemy.position,
328
- state.projectiles,
329
- MIN_BLINK_ESCAPE_DISTANCE,
330
- );
331
- if (blinkTarget)
332
- {
333
- return {
334
- move: {x: 0, y: 0},
335
- startCast: {spell: 'blink', target: blinkTarget},
336
- };
337
- }
338
- }
339
-
340
- // Smart combat movement
341
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
342
- const move = combatMove(
343
- state.position,
344
- enemy,
345
- distance,
346
- rangeMin,
347
- rangeMax,
348
- dodgeDirection,
349
- state.tick,
350
- strafeCyclePeriod,
351
- strafeIntensity,
352
- approachSpeed,
353
- );
354
-
355
- // === OFFENSE: Adaptive homing missiles ===
356
- // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
357
- const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
358
- const hasNearbyLethalMissile = state.projectiles.some(
359
- (p) =>
360
- !myProjectileIds.has(p.id) &&
361
- p.damage >= state.health &&
362
- distanceTo(state.position, p.position) / p.speed <
363
- MAX_CAST_BUDGET + GCD_DURATION,
364
- );
365
- if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
366
- {
367
- let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
368
-
369
- // Anticipate enemy's next missile based on their current state
370
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
371
- {
372
- const remainingEnemyCast =
373
- (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
374
- nextThreatTime = Math.min(
375
- nextThreatTime,
376
- remainingEnemyCast + distance / flightTimeDivisor,
377
- );
378
- }
379
- // No imminent threat — estimate enemy's likely response time
380
- if (nextThreatTime > threatFallbackThreshold)
381
- {
382
- const enemyLockTime = getEnemyVulnerabilityTicks(enemy);
383
- nextThreatTime = Math.min(
384
- nextThreatTime,
385
- enemyLockTime + enemyResponseEstimate + distance / flightTimeDivisor,
386
- );
387
- }
388
-
389
- const safeBudget =
390
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
391
- const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
392
-
393
- // Try adaptive fitting for the safe budget
394
- const enemyHP = Math.ceil(enemy.health);
395
- const config = fitMissileToBudget(budgetTicks, distance, {
396
- minTurnRate,
397
- maxDamage: enemyHP,
398
- lastMissileConfig: state.lastMissileConfig,
399
- });
400
-
401
- // HIGH-DAMAGE PRIORITY: Sniper missiles need to chip through shields.
402
- // Damage 5 through 90% shield = 0.5 chip (useless).
403
- // Damage 25 through 90% shield = 2.5 chip (kills in ~24 hits).
404
- // Always prefer the adaptive config if damage >= 15; otherwise use a
405
- // fixed big-shot fallback that guarantees meaningful shield chip.
406
- const BIG_SHOT = {damage: 25, speed: 10, turnRate: 0.5, duration: 80};
407
-
408
- if (config && config.damage >= 15)
409
- {
410
- return {
411
- move,
412
- startCast: {
413
- spell: 'missile',
414
- config,
415
- missileAI: HomingAI,
416
- direction: angleTo(state.position, enemy.position),
417
- },
418
- };
419
- }
420
-
421
- // Adaptive fitting produced weak missile or failed — fire big shot instead
422
- // This ensures Spelltracer always does meaningful damage, even if it takes
423
- // a hit during the longer cast (trade is worthwhile at high damage).
424
- if (!urgentThreat || urgentThreat.ticksToImpact > 50)
425
- {
426
- return {
427
- move,
428
- startCast: {
429
- spell: 'missile',
430
- config: BIG_SHOT,
431
- missileAI: HomingAI,
432
- direction: angleTo(state.position, enemy.position),
433
- },
434
- };
435
- }
436
-
437
- // Imminent threat — wait for it to pass, then fire
438
- }
439
-
440
- return {move};
441
- };
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 {
6
+ getThreats,
7
+ getMostUrgentThreat,
8
+ getDodgeDirectionForMovement,
9
+ getBlinkToCenter,
10
+ getBlinkToIncreaseDistance,
11
+ fitMissileToBudget,
12
+ MIN_BLINK_ESCAPE_DISTANCE,
13
+ shouldForceShield,
14
+ MAX_CAST_BUDGET,
15
+ getEnemyVulnerabilityTicks,
16
+ } from '../shared.js';
17
+ import {useParam} from '../../engine/params-runtime.js';
18
+
19
+ const HomingAI: MissileAIFunction = ({worldState}) =>
20
+ {
21
+ const enemy = worldState.enemies[0];
22
+ return {turnToward: enemy?.position};
23
+ };
24
+ const WALL_BUFFER = 50;
25
+
26
+ const SHIELD_BUFFER = 3;
27
+
28
+ /**
29
+ * Smart combat movement: dodge using threat analysis, avoid walls, commit to direction.
30
+ */
31
+ function combatMove(
32
+ position: {x: number; y: number},
33
+ enemy: {position: {x: number; y: number}},
34
+ currentDistance: number,
35
+ minDistance: number,
36
+ maxDistance: number,
37
+ dodgeDirection: {x: number; y: number} | null,
38
+ tick: number,
39
+ strafeCyclePeriod: number,
40
+ strafeIntensity: number,
41
+ approachSpeed: number,
42
+ ): {x: number; y: number}
43
+ {
44
+ const deltaX = enemy.position.x - position.x;
45
+ const deltaY = enemy.position.y - position.y;
46
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
47
+
48
+ const strafeClockwise = {
49
+ x: -deltaY / normalizedDistance,
50
+ y: deltaX / normalizedDistance,
51
+ };
52
+ const strafeCounterClockwise = {
53
+ x: deltaY / normalizedDistance,
54
+ y: -deltaX / normalizedDistance,
55
+ };
56
+
57
+ let strafeDirection: {x: number; y: number};
58
+
59
+ if (dodgeDirection)
60
+ {
61
+ strafeDirection = dodgeDirection;
62
+ }
63
+ else if (
64
+ position.x < WALL_BUFFER ||
65
+ position.x > ARENA_SIZE - WALL_BUFFER ||
66
+ position.y < WALL_BUFFER ||
67
+ position.y > ARENA_SIZE - WALL_BUFFER
68
+ )
69
+ {
70
+ const futureClockwise = {
71
+ x: position.x + strafeClockwise.x * 100,
72
+ y: position.y + strafeClockwise.y * 100,
73
+ };
74
+ const futureCounterClockwise = {
75
+ x: position.x + strafeCounterClockwise.x * 100,
76
+ y: position.y + strafeCounterClockwise.y * 100,
77
+ };
78
+ const scoreClockwise = Math.min(
79
+ futureClockwise.x,
80
+ ARENA_SIZE - futureClockwise.x,
81
+ futureClockwise.y,
82
+ ARENA_SIZE - futureClockwise.y,
83
+ );
84
+ const scoreCounterClockwise = Math.min(
85
+ futureCounterClockwise.x,
86
+ ARENA_SIZE - futureCounterClockwise.x,
87
+ futureCounterClockwise.y,
88
+ ARENA_SIZE - futureCounterClockwise.y,
89
+ );
90
+ strafeDirection =
91
+ scoreClockwise > scoreCounterClockwise
92
+ ? strafeClockwise
93
+ : strafeCounterClockwise;
94
+ }
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
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
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: Spelltracer
140
+ *
141
+ * BEHAVIOR: Enhanced ranged sniper with adaptive missile fitting and intercept
142
+ * prediction. Uses fitMissileToBudget to find the highest-damage fast missile
143
+ * that fits the safe window, then fires it along the predicted intercept angle.
144
+ * Maintains medium-long range (300-450), shields undodgeable threats with proper
145
+ * timing, emergency blinks, and distance blinks when cornered. The key mechanic
146
+ * is still PREDICTION — but now with adaptive damage optimization.
147
+ *
148
+ * PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
149
+ * - Spellshot (tier 1): Fixed config intercept prediction, non-homing missiles
150
+ * - Spelltracer (tier 2): + adaptive fitting, timed defense, distance management
151
+ * - Spellseeker (tier 3): Future — perfect prediction, multi-angle attacks
152
+ *
153
+ * TIER: 2 (enhanced Spellshot)
154
+ */
155
+ export const Spelltracer: WizardFunction = ({state}) =>
156
+ {
157
+ // Tunable parameters (run optimizer script to find optimal values)
158
+ const dangerDistance = useParam('dangerDistance', 199, {
159
+ range: 175,
160
+ min: 0,
161
+ });
162
+ const targetDistance = useParam('targetDistance', 555, {range: 200, min: 0});
163
+ const distanceDeadZone = useParam('distanceDeadZone', 12, {
164
+ range: 50,
165
+ min: 0,
166
+ });
167
+ const rangeMin = targetDistance - distanceDeadZone;
168
+ const rangeMax = targetDistance + distanceDeadZone;
169
+ const minTurnRate = useParam('minTurnRate', 0.9, {range: 1.5, steps: 5});
170
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 140, {
171
+ range: 75,
172
+ min: 80,
173
+ max: 300,
174
+ });
175
+ const strafeIntensity = useParam('strafeIntensity', 48, {
176
+ range: 40,
177
+ min: 20,
178
+ max: 100,
179
+ steps: 7,
180
+ });
181
+ const approachSpeed = useParam('approachSpeed', 80, {
182
+ range: 30,
183
+ min: 10,
184
+ max: 80,
185
+ steps: 7,
186
+ });
187
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
188
+ range: 75,
189
+ min: 50,
190
+ max: 300,
191
+ });
192
+ const blinkWindowMax = useParam('blinkWindowMax', 23, {
193
+ range: 40,
194
+ min: 20,
195
+ max: 120,
196
+ steps: 7,
197
+ });
198
+ const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
199
+ range: 4,
200
+ min: 3,
201
+ max: 12,
202
+ steps: 5,
203
+ });
204
+ const enemyResponseEstimate = useParam('enemyResponseEstimate', 217, {
205
+ range: 100,
206
+ min: 50,
207
+ max: 300,
208
+ steps: 7,
209
+ });
210
+ const threatFallbackThreshold = useParam('threatFallbackThreshold', 250, {
211
+ range: 100,
212
+ min: 100,
213
+ max: 400,
214
+ steps: 7,
215
+ });
216
+
217
+ const enemy = state.enemies[0];
218
+ if (!enemy)
219
+ {
220
+ return {move: {x: 0, y: 0}};
221
+ }
222
+
223
+ const distance = distanceTo(state.position, enemy.position);
224
+
225
+ const threats = getThreats(state);
226
+ const urgentThreat = getMostUrgentThreat(threats);
227
+
228
+ // === DEFENSE: Handle channeling ===
229
+ if (state.state === 'channeling')
230
+ {
231
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
232
+ {
233
+ return {move: {x: 0, y: 0}, cancel: true};
234
+ }
235
+ return {move: {x: 0, y: 0}};
236
+ }
237
+
238
+ // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
239
+ // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
240
+ if (
241
+ state.state === 'casting' &&
242
+ state.castingSpell === 'missile' &&
243
+ urgentThreat &&
244
+ urgentThreat.projectile.damage >= state.health
245
+ )
246
+ {
247
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
248
+ if (
249
+ urgentThreat.ticksToImpact <=
250
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
251
+ )
252
+ {
253
+ const dodgeDir = getDodgeDirectionForMovement(threats, state.position);
254
+ const cancelMove = combatMove(
255
+ state.position,
256
+ enemy,
257
+ distance,
258
+ rangeMin,
259
+ rangeMax,
260
+ dodgeDir,
261
+ state.tick,
262
+ strafeCyclePeriod,
263
+ strafeIntensity,
264
+ approachSpeed,
265
+ );
266
+ return {move: cancelMove, cancel: true};
267
+ }
268
+ }
269
+
270
+ // === BLINK: Dodge undodgeable/forced-shield threats (offensive alternative to shielding) ===
271
+ // When we would otherwise shield, blink instead — no GCD after blink, so can fire back immediately.
272
+ // Only for threats that can't be dodged by movement (homing/undodgeable).
273
+ if (
274
+ state.state === 'idle' &&
275
+ urgentThreat &&
276
+ state.blinkCooldown === 0 &&
277
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
278
+ urgentThreat.ticksToImpact >= 10 &&
279
+ urgentThreat.ticksToImpact <= blinkWindowMax
280
+ )
281
+ {
282
+ return {
283
+ move: {x: 0, y: 0},
284
+ startCast: {
285
+ spell: 'blink',
286
+ target:
287
+ distance < targetDistance
288
+ ? (getBlinkToIncreaseDistance(
289
+ state.position,
290
+ enemy.position,
291
+ state.projectiles,
292
+ ) ?? getBlinkToCenter(state.position))
293
+ : getBlinkToCenter(state.position),
294
+ },
295
+ };
296
+ }
297
+
298
+ // === DEFENSE: Shield undodgeable or high-damage homing threats (when blink on cooldown) ===
299
+ if (
300
+ state.state === 'idle' &&
301
+ urgentThreat &&
302
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
303
+ )
304
+ {
305
+ if (urgentThreat.canBlockInTime)
306
+ {
307
+ if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
308
+ {
309
+ return {
310
+ move: {x: 0, y: 0},
311
+ startCast: {spell: 'shield'},
312
+ };
313
+ }
314
+ }
315
+ }
316
+
317
+ // === DISTANCE BLINK: enemy too close, blink to reestablish range ===
318
+ if (
319
+ state.state === 'idle' &&
320
+ distance < dangerDistance &&
321
+ state.blinkCooldown === 0 &&
322
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
323
+ )
324
+ {
325
+ const blinkTarget = getBlinkToIncreaseDistance(
326
+ state.position,
327
+ enemy.position,
328
+ state.projectiles,
329
+ MIN_BLINK_ESCAPE_DISTANCE,
330
+ );
331
+ if (blinkTarget)
332
+ {
333
+ return {
334
+ move: {x: 0, y: 0},
335
+ startCast: {spell: 'blink', target: blinkTarget},
336
+ };
337
+ }
338
+ }
339
+
340
+ // Smart combat movement
341
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
342
+ const move = combatMove(
343
+ state.position,
344
+ enemy,
345
+ distance,
346
+ rangeMin,
347
+ rangeMax,
348
+ dodgeDirection,
349
+ state.tick,
350
+ strafeCyclePeriod,
351
+ strafeIntensity,
352
+ approachSpeed,
353
+ );
354
+
355
+ // === OFFENSE: Adaptive homing missiles ===
356
+ // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
357
+ const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
358
+ const hasNearbyLethalMissile = state.projectiles.some(
359
+ (p) =>
360
+ !myProjectileIds.has(p.id) &&
361
+ p.damage >= state.health &&
362
+ distanceTo(state.position, p.position) / p.speed <
363
+ MAX_CAST_BUDGET + GCD_DURATION,
364
+ );
365
+ if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
366
+ {
367
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
368
+
369
+ // Anticipate enemy's next missile based on their current state
370
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
371
+ {
372
+ const remainingEnemyCast =
373
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
374
+ nextThreatTime = Math.min(
375
+ nextThreatTime,
376
+ remainingEnemyCast + distance / flightTimeDivisor,
377
+ );
378
+ }
379
+ // No imminent threat — estimate enemy's likely response time
380
+ if (nextThreatTime > threatFallbackThreshold)
381
+ {
382
+ const enemyLockTime = getEnemyVulnerabilityTicks(enemy);
383
+ nextThreatTime = Math.min(
384
+ nextThreatTime,
385
+ enemyLockTime + enemyResponseEstimate + distance / flightTimeDivisor,
386
+ );
387
+ }
388
+
389
+ const safeBudget =
390
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
391
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
392
+
393
+ // Try adaptive fitting for the safe budget
394
+ const enemyHP = Math.ceil(enemy.health);
395
+ const config = fitMissileToBudget(budgetTicks, distance, {
396
+ minTurnRate,
397
+ maxDamage: enemyHP,
398
+ lastMissileConfig: state.lastMissileConfig,
399
+ });
400
+
401
+ // HIGH-DAMAGE PRIORITY: Sniper missiles need to chip through shields.
402
+ // Damage 5 through 90% shield = 0.5 chip (useless).
403
+ // Damage 25 through 90% shield = 2.5 chip (kills in ~24 hits).
404
+ // Always prefer the adaptive config if damage >= 15; otherwise use a
405
+ // fixed big-shot fallback that guarantees meaningful shield chip.
406
+ const BIG_SHOT = {damage: 25, speed: 10, turnRate: 0.5, duration: 80};
407
+
408
+ if (config && config.damage >= 15)
409
+ {
410
+ return {
411
+ move,
412
+ startCast: {
413
+ spell: 'missile',
414
+ config,
415
+ missileAI: HomingAI,
416
+ direction: angleTo(state.position, enemy.position),
417
+ },
418
+ };
419
+ }
420
+
421
+ // Adaptive fitting produced weak missile or failed — fire big shot instead
422
+ // This ensures Spelltracer always does meaningful damage, even if it takes
423
+ // a hit during the longer cast (trade is worthwhile at high damage).
424
+ if (!urgentThreat || urgentThreat.ticksToImpact > 50)
425
+ {
426
+ return {
427
+ move,
428
+ startCast: {
429
+ spell: 'missile',
430
+ config: BIG_SHOT,
431
+ missileAI: HomingAI,
432
+ direction: angleTo(state.position, enemy.position),
433
+ },
434
+ };
435
+ }
436
+
437
+ // Imminent threat — wait for it to pass, then fire
438
+ }
439
+
440
+ return {move};
441
+ };