@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.
Files changed (50) hide show
  1. package/README.md +28 -28
  2. package/dist/{chunk-L7Z7OFXD.js → chunk-OL7ETV6V.js} +3 -3
  3. package/dist/chunk-OL7ETV6V.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,546 +1,546 @@
1
- import {WizardFunction, MissileAIFunction} from '../../types.js';
2
- import {angleTo} from '../../utils/angles.js';
3
- import {distanceTo} from '../../utils/distance.js';
4
- import {
5
- ARENA_SIZE,
6
- GCD_DURATION,
7
- SHIELD_CAST_TIME,
8
- BLINK_CAST_TIME,
9
- } from '../../rules.js';
10
- import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
11
- import {
12
- getThreats,
13
- getMostUrgentThreat,
14
- getDodgeDirectionForMovement,
15
- getBlinkToCenter,
16
- getBlinkToIncreaseDistance,
17
- fitMissileToBudget,
18
- shouldForceShield,
19
- MIN_BLINK_ESCAPE_DISTANCE,
20
- MAX_CAST_BUDGET,
21
- MIN_USEFUL_DAMAGE,
22
- getClosingRate,
23
- getEnemyVulnerabilityTicks,
24
- isSafeToAttack,
25
- } from '../shared.js';
26
- import {useParam} from '../../engine/params-runtime.js';
27
-
28
- const HomingAI: MissileAIFunction = ({worldState}) =>
29
- {
30
- const enemy = worldState.enemies[0];
31
- return {turnToward: enemy?.position};
32
- };
33
-
34
- const StraightAI: MissileAIFunction = () => ({});
35
-
36
- const WALL_BUFFER = 50;
37
- const SHIELD_BUFFER = 3;
38
-
39
- /**
40
- * Smart combat movement: dodge, avoid walls, maintain sniper range.
41
- */
42
- function combatMove(
43
- position: {x: number; y: number},
44
- enemy: {position: {x: number; y: number}},
45
- currentDistance: number,
46
- minDistance: number,
47
- maxDistance: number,
48
- dodgeDirection: {x: number; y: number} | null,
49
- tick: number,
50
- strafeCyclePeriod: number,
51
- strafeIntensity: number,
52
- approachSpeed: number,
53
- ): {x: number; y: number}
54
- {
55
- const deltaX = enemy.position.x - position.x;
56
- const deltaY = enemy.position.y - position.y;
57
- const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
58
-
59
- const strafeClockwise = {
60
- x: -deltaY / normalizedDistance,
61
- y: deltaX / normalizedDistance,
62
- };
63
- const strafeCounterClockwise = {
64
- x: deltaY / normalizedDistance,
65
- y: -deltaX / normalizedDistance,
66
- };
67
-
68
- let strafeDirection: {x: number; y: number};
69
-
70
- if (dodgeDirection)
71
- {
72
- strafeDirection = dodgeDirection;
73
- }
74
- else if (
75
- position.x < WALL_BUFFER ||
76
- position.x > ARENA_SIZE - WALL_BUFFER ||
77
- position.y < WALL_BUFFER ||
78
- position.y > ARENA_SIZE - WALL_BUFFER
79
- )
80
- {
81
- const futureClockwise = {
82
- x: position.x + strafeClockwise.x * 100,
83
- y: position.y + strafeClockwise.y * 100,
84
- };
85
- const futureCounterClockwise = {
86
- x: position.x + strafeCounterClockwise.x * 100,
87
- y: position.y + strafeCounterClockwise.y * 100,
88
- };
89
- const scoreClockwise = Math.min(
90
- futureClockwise.x,
91
- ARENA_SIZE - futureClockwise.x,
92
- futureClockwise.y,
93
- ARENA_SIZE - futureClockwise.y,
94
- );
95
- const scoreCounterClockwise = Math.min(
96
- futureCounterClockwise.x,
97
- ARENA_SIZE - futureCounterClockwise.x,
98
- futureCounterClockwise.y,
99
- ARENA_SIZE - futureCounterClockwise.y,
100
- );
101
- strafeDirection =
102
- scoreClockwise > scoreCounterClockwise
103
- ? strafeClockwise
104
- : strafeCounterClockwise;
105
- }
106
- else
107
- {
108
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
109
- strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
110
- }
111
-
112
- if (dodgeDirection)
113
- {
114
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
115
- }
116
-
117
- let moveX = strafeDirection.x * strafeIntensity;
118
- let moveY = strafeDirection.y * strafeIntensity;
119
-
120
- if (currentDistance > maxDistance)
121
- {
122
- moveX += (deltaX / normalizedDistance) * approachSpeed;
123
- moveY += (deltaY / normalizedDistance) * approachSpeed;
124
- }
125
- else if (currentDistance < minDistance)
126
- {
127
- let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
128
- let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
129
- if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
130
- if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
131
- if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
132
- if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
133
- moveX += retreatX;
134
- moveY += retreatY;
135
- }
136
-
137
- const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
138
- if (magnitude > 100)
139
- {
140
- moveX = (moveX / magnitude) * 100;
141
- moveY = (moveY / magnitude) * 100;
142
- }
143
-
144
- return {x: moveX, y: moveY};
145
- }
146
-
147
- /**
148
- * Bot: Spellseeker
149
- *
150
- * BEHAVIOR: Elite sniper that uses intercept-aimed straight missiles during vulnerability
151
- * windows. Combines Spelltracer's adaptive fitting with precise lead-position aiming
152
- * and vulnerability exploitation. Straight punish missiles at sniper range are nearly
153
- * unavoidable. Proactive distance control via closing rate detection.
154
- *
155
- * KEY IMPROVEMENTS OVER SPELLTRACER:
156
- * - Intercept-aimed punish: getLeadPosition + straight missiles during vulnerability
157
- * - Proactive distance blink: monitors closing rate, blinks before danger zone
158
- * - Progressive cast-cancel: graduated damage thresholds
159
- * - Warmup exploitation: always passes lastMissileConfig
160
- *
161
- * PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
162
- * TIER: 3 (elite Sniper line)
163
- */
164
- export const Spellseeker: WizardFunction = ({state}) =>
165
- {
166
- const dangerDistance = useParam('dangerDistance', 65, {
167
- range: 175,
168
- min: 0,
169
- });
170
- const targetDistance = useParam('targetDistance', 289, {
171
- range: 200,
172
- min: 0,
173
- });
174
- const distanceDeadZone = useParam('distanceDeadZone', 22, {
175
- range: 50,
176
- min: 0,
177
- });
178
- const rangeMin = targetDistance - distanceDeadZone;
179
- const rangeMax = targetDistance + distanceDeadZone;
180
- const minTurnRate = useParam('minTurnRate', 3.5, {range: 1.5, steps: 5});
181
- const closingRateThreshold = useParam('closingRateThreshold', 1.79, {
182
- range: 0.65,
183
- min: 0,
184
- steps: 5,
185
- });
186
- const punishMinVulnerability = useParam('punishMinVulnerability', 279, {
187
- range: 30,
188
- min: 10,
189
- steps: 5,
190
- });
191
- const preferBlinkDodge =
192
- useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
193
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
194
- range: 75,
195
- min: 80,
196
- max: 250,
197
- });
198
- const strafeIntensity = useParam('strafeIntensity', 87, {
199
- range: 40,
200
- min: 20,
201
- max: 100,
202
- steps: 7,
203
- });
204
- const approachSpeed = useParam('approachSpeed', 80, {
205
- range: 30,
206
- min: 10,
207
- max: 80,
208
- steps: 7,
209
- });
210
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
211
- range: 75,
212
- min: 50,
213
- max: 300,
214
- });
215
- const blinkWindowMax = useParam('blinkWindowMax', 88, {
216
- range: 40,
217
- min: 20,
218
- max: 120,
219
- steps: 7,
220
- });
221
- const proactiveBlinkRange = useParam('proactiveBlinkRange', 433, {
222
- range: 150,
223
- min: 100,
224
- max: 500,
225
- steps: 7,
226
- });
227
- const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
228
- range: 4,
229
- min: 2,
230
- max: 12,
231
- steps: 5,
232
- });
233
- const castingFlightTimeDivisor = useParam('castingFlightTimeDivisor', 12, {
234
- range: 4,
235
- min: 3,
236
- max: 12,
237
- steps: 5,
238
- });
239
- const enemyResponseEstimate = useParam('enemyResponseEstimate', 168, {
240
- range: 100,
241
- min: 50,
242
- max: 300,
243
- steps: 7,
244
- });
245
- const threatFallbackThreshold = useParam('threatFallbackThreshold', 250, {
246
- range: 100,
247
- min: 100,
248
- max: 400,
249
- steps: 7,
250
- });
251
- const punishAttackRange = useParam('punishAttackRange', 213, {
252
- range: 150,
253
- min: 200,
254
- max: 700,
255
- steps: 7,
256
- });
257
- const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
258
- range: 25,
259
- min: 10,
260
- max: 80,
261
- steps: 5,
262
- });
263
-
264
- const enemy = state.enemies[0];
265
- if (!enemy)
266
- {
267
- return {move: {x: 0, y: 0}};
268
- }
269
-
270
- const distance = distanceTo(state.position, enemy.position);
271
- const threats = getThreats(state);
272
- const urgentThreat = getMostUrgentThreat(threats);
273
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
274
- const move = combatMove(
275
- state.position,
276
- enemy,
277
- distance,
278
- rangeMin,
279
- rangeMax,
280
- dodgeDirection,
281
- state.tick,
282
- strafeCyclePeriod,
283
- strafeIntensity,
284
- approachSpeed,
285
- );
286
-
287
- // === STEP 1: CHANNELING — cancel shield when safe ===
288
- if (state.state === 'channeling')
289
- {
290
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
291
- {
292
- return {move, cancel: true};
293
- }
294
- return {move: {x: 0, y: 0}};
295
- }
296
-
297
- // === STEP 2: PROGRESSIVE CAST-CANCEL ===
298
- if (
299
- state.state === 'casting' &&
300
- state.castingSpell === 'missile' &&
301
- urgentThreat &&
302
- !urgentThreat.bestDodgeDirection
303
- )
304
- {
305
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
306
- const defenseTime =
307
- state.blinkCooldown === 0
308
- ? BLINK_CAST_TIME
309
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
310
- const willArriveInWindow =
311
- urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
312
-
313
- if (willArriveInWindow)
314
- {
315
- const incomingDamage = urgentThreat.projectile.damage;
316
- const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
317
-
318
- if (incomingDamage >= state.health || incomingDamage >= 20)
319
- {
320
- return {move, cancel: true};
321
- }
322
- if (incomingDamage >= 10 && castRatio < 0.8)
323
- {
324
- return {move, cancel: true};
325
- }
326
- }
327
- }
328
-
329
- // === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
330
- if (
331
- preferBlinkDodge &&
332
- state.state === 'idle' &&
333
- urgentThreat &&
334
- state.blinkCooldown === 0 &&
335
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
336
- urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
337
- urgentThreat.ticksToImpact <= blinkWindowMax
338
- )
339
- {
340
- const midRange = targetDistance;
341
- const target =
342
- distance < midRange
343
- ? (getBlinkToIncreaseDistance(
344
- state.position,
345
- enemy.position,
346
- state.projectiles,
347
- ) ?? getBlinkToCenter(state.position))
348
- : getBlinkToCenter(state.position);
349
- return {
350
- move: {x: 0, y: 0},
351
- startCast: {spell: 'blink', target},
352
- };
353
- }
354
-
355
- // === STEP 4: SHIELD — undodgeable or critical threats ===
356
- if (
357
- state.state === 'idle' &&
358
- urgentThreat &&
359
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
360
- )
361
- {
362
- if (
363
- urgentThreat.canBlockInTime &&
364
- urgentThreat.ticksToStartShield <= SHIELD_BUFFER
365
- )
366
- {
367
- return {
368
- move: {x: 0, y: 0},
369
- startCast: {spell: 'shield'},
370
- };
371
- }
372
- }
373
-
374
- // === STEP 5: PROACTIVE DISTANCE BLINK ===
375
- if (
376
- state.state === 'idle' &&
377
- state.blinkCooldown === 0 &&
378
- (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
379
- )
380
- {
381
- const closingRate = getClosingRate(
382
- state.position,
383
- enemy.position,
384
- enemy.velocity,
385
- );
386
- if (
387
- (closingRate > closingRateThreshold && distance < proactiveBlinkRange) ||
388
- distance < dangerDistance
389
- )
390
- {
391
- const blinkTarget = getBlinkToIncreaseDistance(
392
- state.position,
393
- enemy.position,
394
- state.projectiles,
395
- MIN_BLINK_ESCAPE_DISTANCE,
396
- );
397
- if (blinkTarget)
398
- {
399
- return {
400
- move: {x: 0, y: 0},
401
- startCast: {spell: 'blink', target: blinkTarget},
402
- };
403
- }
404
- }
405
- }
406
-
407
- // === STEP 6: OFFENSE ===
408
- if (state.state === 'idle' && distance < 600)
409
- {
410
- const offenseDefenseTime =
411
- state.blinkCooldown === 0
412
- ? BLINK_CAST_TIME
413
- : SHIELD_CAST_TIME + SHIELD_BUFFER;
414
- const enemyHP = Math.ceil(enemy.health);
415
- const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
416
-
417
- // --- PUNISH MODE: intercept-aimed straight missiles during vulnerability ---
418
- if (
419
- vulnerabilityTicks >= punishMinVulnerability &&
420
- distance < punishAttackRange
421
- )
422
- {
423
- const flightTimeEstimate = distance / flightTimeDivisor;
424
- const punishBudget = vulnerabilityTicks - flightTimeEstimate;
425
- const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
426
- const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
427
- const effectiveBudget = Math.min(
428
- punishBudget,
429
- safeBudget,
430
- MAX_CAST_BUDGET,
431
- );
432
-
433
- if (effectiveBudget > 0)
434
- {
435
- const config = fitMissileToBudget(effectiveBudget, distance, {
436
- minTurnRate: 0,
437
- maxDamage: enemyHP,
438
- lastMissileConfig: state.lastMissileConfig,
439
- });
440
-
441
- if (config && config.damage >= MIN_USEFUL_DAMAGE)
442
- {
443
- const castTime = getMissileCastTime(config, state.lastMissileConfig);
444
- const actualFlight = distance / config.speed;
445
-
446
- if (
447
- castTime + actualFlight < vulnerabilityTicks &&
448
- isSafeToAttack(threats, castTime)
449
- )
450
- {
451
- const aimTarget =
452
- config.turnRate > 0
453
- ? enemy.position
454
- : getLeadPosition(
455
- enemy.position,
456
- enemy.velocity,
457
- config.speed,
458
- state.position,
459
- );
460
- return {
461
- move,
462
- startCast: {
463
- spell: 'missile',
464
- config,
465
- missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
466
- direction: angleTo(state.position, aimTarget),
467
- },
468
- };
469
- }
470
- }
471
- }
472
- }
473
-
474
- // --- STANDARD MODE: adaptive homing missiles ---
475
- let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
476
-
477
- // Anticipate enemy's next missile based on their current state
478
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
479
- {
480
- const remainingEnemyCast =
481
- (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
482
- nextThreatTime = Math.min(
483
- nextThreatTime,
484
- remainingEnemyCast + distance / castingFlightTimeDivisor,
485
- );
486
- }
487
- // No imminent threat — estimate enemy's likely response time
488
- if (nextThreatTime > threatFallbackThreshold)
489
- {
490
- const enemyLockTime = getEnemyVulnerabilityTicks(enemy);
491
- nextThreatTime = Math.min(
492
- nextThreatTime,
493
- enemyLockTime +
494
- enemyResponseEstimate +
495
- distance / castingFlightTimeDivisor,
496
- );
497
- }
498
-
499
- const safeBudget =
500
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
501
- const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
502
-
503
- const config = fitMissileToBudget(budgetTicks, distance, {
504
- minTurnRate,
505
- maxDamage: enemyHP,
506
- lastMissileConfig: state.lastMissileConfig,
507
- });
508
-
509
- // HIGH-DAMAGE PRIORITY: Sniper missiles need meaningful shield chip.
510
- // Fixed big-shot fallback ensures Spellseeker always does significant damage.
511
- const BIG_SHOT = {damage: 28, speed: 10, turnRate: 0.5, duration: 80};
512
-
513
- if (
514
- config &&
515
- config.damage >= 15 &&
516
- isSafeToAttack(threats, safeAttackCastEstimate)
517
- )
518
- {
519
- return {
520
- move,
521
- startCast: {
522
- spell: 'missile',
523
- config,
524
- missileAI: HomingAI,
525
- direction: angleTo(state.position, enemy.position),
526
- },
527
- };
528
- }
529
-
530
- // Adaptive fitting weak or failed — fire big shot for meaningful damage
531
- if (!urgentThreat || urgentThreat.ticksToImpact > 50)
532
- {
533
- return {
534
- move,
535
- startCast: {
536
- spell: 'missile',
537
- config: BIG_SHOT,
538
- missileAI: HomingAI,
539
- direction: angleTo(state.position, enemy.position),
540
- },
541
- };
542
- }
543
- }
544
-
545
- return {move};
546
- };
1
+ import {WizardFunction, MissileAIFunction} from '../../types.js';
2
+ import {angleTo} from '../../utils/angles.js';
3
+ import {distanceTo} from '../../utils/distance.js';
4
+ import {
5
+ ARENA_SIZE,
6
+ GCD_DURATION,
7
+ SHIELD_CAST_TIME,
8
+ BLINK_CAST_TIME,
9
+ } from '../../rules.js';
10
+ import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
11
+ import {
12
+ getThreats,
13
+ getMostUrgentThreat,
14
+ getDodgeDirectionForMovement,
15
+ getBlinkToCenter,
16
+ getBlinkToIncreaseDistance,
17
+ fitMissileToBudget,
18
+ shouldForceShield,
19
+ MIN_BLINK_ESCAPE_DISTANCE,
20
+ MAX_CAST_BUDGET,
21
+ MIN_USEFUL_DAMAGE,
22
+ getClosingRate,
23
+ getEnemyVulnerabilityTicks,
24
+ isSafeToAttack,
25
+ } from '../shared.js';
26
+ import {useParam} from '../../engine/params-runtime.js';
27
+
28
+ const HomingAI: MissileAIFunction = ({worldState}) =>
29
+ {
30
+ const enemy = worldState.enemies[0];
31
+ return {turnToward: enemy?.position};
32
+ };
33
+
34
+ const StraightAI: MissileAIFunction = () => ({});
35
+
36
+ const WALL_BUFFER = 50;
37
+ const SHIELD_BUFFER = 3;
38
+
39
+ /**
40
+ * Smart combat movement: dodge, avoid walls, maintain sniper range.
41
+ */
42
+ function combatMove(
43
+ position: {x: number; y: number},
44
+ enemy: {position: {x: number; y: number}},
45
+ currentDistance: number,
46
+ minDistance: number,
47
+ maxDistance: number,
48
+ dodgeDirection: {x: number; y: number} | null,
49
+ tick: number,
50
+ strafeCyclePeriod: number,
51
+ strafeIntensity: number,
52
+ approachSpeed: number,
53
+ ): {x: number; y: number}
54
+ {
55
+ const deltaX = enemy.position.x - position.x;
56
+ const deltaY = enemy.position.y - position.y;
57
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
58
+
59
+ const strafeClockwise = {
60
+ x: -deltaY / normalizedDistance,
61
+ y: deltaX / normalizedDistance,
62
+ };
63
+ const strafeCounterClockwise = {
64
+ x: deltaY / normalizedDistance,
65
+ y: -deltaX / normalizedDistance,
66
+ };
67
+
68
+ let strafeDirection: {x: number; y: number};
69
+
70
+ if (dodgeDirection)
71
+ {
72
+ strafeDirection = dodgeDirection;
73
+ }
74
+ else if (
75
+ position.x < WALL_BUFFER ||
76
+ position.x > ARENA_SIZE - WALL_BUFFER ||
77
+ position.y < WALL_BUFFER ||
78
+ position.y > ARENA_SIZE - WALL_BUFFER
79
+ )
80
+ {
81
+ const futureClockwise = {
82
+ x: position.x + strafeClockwise.x * 100,
83
+ y: position.y + strafeClockwise.y * 100,
84
+ };
85
+ const futureCounterClockwise = {
86
+ x: position.x + strafeCounterClockwise.x * 100,
87
+ y: position.y + strafeCounterClockwise.y * 100,
88
+ };
89
+ const scoreClockwise = Math.min(
90
+ futureClockwise.x,
91
+ ARENA_SIZE - futureClockwise.x,
92
+ futureClockwise.y,
93
+ ARENA_SIZE - futureClockwise.y,
94
+ );
95
+ const scoreCounterClockwise = Math.min(
96
+ futureCounterClockwise.x,
97
+ ARENA_SIZE - futureCounterClockwise.x,
98
+ futureCounterClockwise.y,
99
+ ARENA_SIZE - futureCounterClockwise.y,
100
+ );
101
+ strafeDirection =
102
+ scoreClockwise > scoreCounterClockwise
103
+ ? strafeClockwise
104
+ : strafeCounterClockwise;
105
+ }
106
+ else
107
+ {
108
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
109
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
110
+ }
111
+
112
+ if (dodgeDirection)
113
+ {
114
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
115
+ }
116
+
117
+ let moveX = strafeDirection.x * strafeIntensity;
118
+ let moveY = strafeDirection.y * strafeIntensity;
119
+
120
+ if (currentDistance > maxDistance)
121
+ {
122
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
123
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
124
+ }
125
+ else if (currentDistance < minDistance)
126
+ {
127
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
128
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
129
+ if (position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
130
+ if (position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0) retreatX = 0;
131
+ if (position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
132
+ if (position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0) retreatY = 0;
133
+ moveX += retreatX;
134
+ moveY += retreatY;
135
+ }
136
+
137
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
138
+ if (magnitude > 100)
139
+ {
140
+ moveX = (moveX / magnitude) * 100;
141
+ moveY = (moveY / magnitude) * 100;
142
+ }
143
+
144
+ return {x: moveX, y: moveY};
145
+ }
146
+
147
+ /**
148
+ * Bot: Spellseeker
149
+ *
150
+ * BEHAVIOR: Elite sniper that uses intercept-aimed straight missiles during vulnerability
151
+ * windows. Combines Spelltracer's adaptive fitting with precise lead-position aiming
152
+ * and vulnerability exploitation. Straight punish missiles at sniper range are nearly
153
+ * unavoidable. Proactive distance control via closing rate detection.
154
+ *
155
+ * KEY IMPROVEMENTS OVER SPELLTRACER:
156
+ * - Intercept-aimed punish: getLeadPosition + straight missiles during vulnerability
157
+ * - Proactive distance blink: monitors closing rate, blinks before danger zone
158
+ * - Progressive cast-cancel: graduated damage thresholds
159
+ * - Warmup exploitation: always passes lastMissileConfig
160
+ *
161
+ * PROGRESSION LINE: Spellshot → Spelltracer → Spellseeker
162
+ * TIER: 3 (elite Sniper line)
163
+ */
164
+ export const Spellseeker: WizardFunction = ({state}) =>
165
+ {
166
+ const dangerDistance = useParam('dangerDistance', 65, {
167
+ range: 175,
168
+ min: 0,
169
+ });
170
+ const targetDistance = useParam('targetDistance', 289, {
171
+ range: 200,
172
+ min: 0,
173
+ });
174
+ const distanceDeadZone = useParam('distanceDeadZone', 22, {
175
+ range: 50,
176
+ min: 0,
177
+ });
178
+ const rangeMin = targetDistance - distanceDeadZone;
179
+ const rangeMax = targetDistance + distanceDeadZone;
180
+ const minTurnRate = useParam('minTurnRate', 3.5, {range: 1.5, steps: 5});
181
+ const closingRateThreshold = useParam('closingRateThreshold', 1.79, {
182
+ range: 0.65,
183
+ min: 0,
184
+ steps: 5,
185
+ });
186
+ const punishMinVulnerability = useParam('punishMinVulnerability', 279, {
187
+ range: 30,
188
+ min: 10,
189
+ steps: 5,
190
+ });
191
+ const preferBlinkDodge =
192
+ useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
193
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 231, {
194
+ range: 75,
195
+ min: 80,
196
+ max: 250,
197
+ });
198
+ const strafeIntensity = useParam('strafeIntensity', 87, {
199
+ range: 40,
200
+ min: 20,
201
+ max: 100,
202
+ steps: 7,
203
+ });
204
+ const approachSpeed = useParam('approachSpeed', 80, {
205
+ range: 30,
206
+ min: 10,
207
+ max: 80,
208
+ steps: 7,
209
+ });
210
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
211
+ range: 75,
212
+ min: 50,
213
+ max: 300,
214
+ });
215
+ const blinkWindowMax = useParam('blinkWindowMax', 88, {
216
+ range: 40,
217
+ min: 20,
218
+ max: 120,
219
+ steps: 7,
220
+ });
221
+ const proactiveBlinkRange = useParam('proactiveBlinkRange', 433, {
222
+ range: 150,
223
+ min: 100,
224
+ max: 500,
225
+ steps: 7,
226
+ });
227
+ const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
228
+ range: 4,
229
+ min: 2,
230
+ max: 12,
231
+ steps: 5,
232
+ });
233
+ const castingFlightTimeDivisor = useParam('castingFlightTimeDivisor', 12, {
234
+ range: 4,
235
+ min: 3,
236
+ max: 12,
237
+ steps: 5,
238
+ });
239
+ const enemyResponseEstimate = useParam('enemyResponseEstimate', 168, {
240
+ range: 100,
241
+ min: 50,
242
+ max: 300,
243
+ steps: 7,
244
+ });
245
+ const threatFallbackThreshold = useParam('threatFallbackThreshold', 250, {
246
+ range: 100,
247
+ min: 100,
248
+ max: 400,
249
+ steps: 7,
250
+ });
251
+ const punishAttackRange = useParam('punishAttackRange', 213, {
252
+ range: 150,
253
+ min: 200,
254
+ max: 700,
255
+ steps: 7,
256
+ });
257
+ const safeAttackCastEstimate = useParam('safeAttackCastEstimate', 35, {
258
+ range: 25,
259
+ min: 10,
260
+ max: 80,
261
+ steps: 5,
262
+ });
263
+
264
+ const enemy = state.enemies[0];
265
+ if (!enemy)
266
+ {
267
+ return {move: {x: 0, y: 0}};
268
+ }
269
+
270
+ const distance = distanceTo(state.position, enemy.position);
271
+ const threats = getThreats(state);
272
+ const urgentThreat = getMostUrgentThreat(threats);
273
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
274
+ const move = combatMove(
275
+ state.position,
276
+ enemy,
277
+ distance,
278
+ rangeMin,
279
+ rangeMax,
280
+ dodgeDirection,
281
+ state.tick,
282
+ strafeCyclePeriod,
283
+ strafeIntensity,
284
+ approachSpeed,
285
+ );
286
+
287
+ // === STEP 1: CHANNELING — cancel shield when safe ===
288
+ if (state.state === 'channeling')
289
+ {
290
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
291
+ {
292
+ return {move, cancel: true};
293
+ }
294
+ return {move: {x: 0, y: 0}};
295
+ }
296
+
297
+ // === STEP 2: PROGRESSIVE CAST-CANCEL ===
298
+ if (
299
+ state.state === 'casting' &&
300
+ state.castingSpell === 'missile' &&
301
+ urgentThreat &&
302
+ !urgentThreat.bestDodgeDirection
303
+ )
304
+ {
305
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
306
+ const defenseTime =
307
+ state.blinkCooldown === 0
308
+ ? BLINK_CAST_TIME
309
+ : SHIELD_CAST_TIME + SHIELD_BUFFER;
310
+ const willArriveInWindow =
311
+ urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
312
+
313
+ if (willArriveInWindow)
314
+ {
315
+ const incomingDamage = urgentThreat.projectile.damage;
316
+ const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
317
+
318
+ if (incomingDamage >= state.health || incomingDamage >= 20)
319
+ {
320
+ return {move, cancel: true};
321
+ }
322
+ if (incomingDamage >= 10 && castRatio < 0.8)
323
+ {
324
+ return {move, cancel: true};
325
+ }
326
+ }
327
+ }
328
+
329
+ // === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
330
+ if (
331
+ preferBlinkDodge &&
332
+ state.state === 'idle' &&
333
+ urgentThreat &&
334
+ state.blinkCooldown === 0 &&
335
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
336
+ urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
337
+ urgentThreat.ticksToImpact <= blinkWindowMax
338
+ )
339
+ {
340
+ const midRange = targetDistance;
341
+ const target =
342
+ distance < midRange
343
+ ? (getBlinkToIncreaseDistance(
344
+ state.position,
345
+ enemy.position,
346
+ state.projectiles,
347
+ ) ?? getBlinkToCenter(state.position))
348
+ : getBlinkToCenter(state.position);
349
+ return {
350
+ move: {x: 0, y: 0},
351
+ startCast: {spell: 'blink', target},
352
+ };
353
+ }
354
+
355
+ // === STEP 4: SHIELD — undodgeable or critical threats ===
356
+ if (
357
+ state.state === 'idle' &&
358
+ urgentThreat &&
359
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
360
+ )
361
+ {
362
+ if (
363
+ urgentThreat.canBlockInTime &&
364
+ urgentThreat.ticksToStartShield <= SHIELD_BUFFER
365
+ )
366
+ {
367
+ return {
368
+ move: {x: 0, y: 0},
369
+ startCast: {spell: 'shield'},
370
+ };
371
+ }
372
+ }
373
+
374
+ // === STEP 5: PROACTIVE DISTANCE BLINK ===
375
+ if (
376
+ state.state === 'idle' &&
377
+ state.blinkCooldown === 0 &&
378
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
379
+ )
380
+ {
381
+ const closingRate = getClosingRate(
382
+ state.position,
383
+ enemy.position,
384
+ enemy.velocity,
385
+ );
386
+ if (
387
+ (closingRate > closingRateThreshold && distance < proactiveBlinkRange) ||
388
+ distance < dangerDistance
389
+ )
390
+ {
391
+ const blinkTarget = getBlinkToIncreaseDistance(
392
+ state.position,
393
+ enemy.position,
394
+ state.projectiles,
395
+ MIN_BLINK_ESCAPE_DISTANCE,
396
+ );
397
+ if (blinkTarget)
398
+ {
399
+ return {
400
+ move: {x: 0, y: 0},
401
+ startCast: {spell: 'blink', target: blinkTarget},
402
+ };
403
+ }
404
+ }
405
+ }
406
+
407
+ // === STEP 6: OFFENSE ===
408
+ if (state.state === 'idle' && distance < 600)
409
+ {
410
+ const offenseDefenseTime =
411
+ state.blinkCooldown === 0
412
+ ? BLINK_CAST_TIME
413
+ : SHIELD_CAST_TIME + SHIELD_BUFFER;
414
+ const enemyHP = Math.ceil(enemy.health);
415
+ const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
416
+
417
+ // --- PUNISH MODE: intercept-aimed straight missiles during vulnerability ---
418
+ if (
419
+ vulnerabilityTicks >= punishMinVulnerability &&
420
+ distance < punishAttackRange
421
+ )
422
+ {
423
+ const flightTimeEstimate = distance / flightTimeDivisor;
424
+ const punishBudget = vulnerabilityTicks - flightTimeEstimate;
425
+ const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
426
+ const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
427
+ const effectiveBudget = Math.min(
428
+ punishBudget,
429
+ safeBudget,
430
+ MAX_CAST_BUDGET,
431
+ );
432
+
433
+ if (effectiveBudget > 0)
434
+ {
435
+ const config = fitMissileToBudget(effectiveBudget, distance, {
436
+ minTurnRate: 0,
437
+ maxDamage: enemyHP,
438
+ lastMissileConfig: state.lastMissileConfig,
439
+ });
440
+
441
+ if (config && config.damage >= MIN_USEFUL_DAMAGE)
442
+ {
443
+ const castTime = getMissileCastTime(config, state.lastMissileConfig);
444
+ const actualFlight = distance / config.speed;
445
+
446
+ if (
447
+ castTime + actualFlight < vulnerabilityTicks &&
448
+ isSafeToAttack(threats, castTime)
449
+ )
450
+ {
451
+ const aimTarget =
452
+ config.turnRate > 0
453
+ ? enemy.position
454
+ : getLeadPosition(
455
+ enemy.position,
456
+ enemy.velocity,
457
+ config.speed,
458
+ state.position,
459
+ );
460
+ return {
461
+ move,
462
+ startCast: {
463
+ spell: 'missile',
464
+ config,
465
+ missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
466
+ direction: angleTo(state.position, aimTarget),
467
+ },
468
+ };
469
+ }
470
+ }
471
+ }
472
+ }
473
+
474
+ // --- STANDARD MODE: adaptive homing missiles ---
475
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
476
+
477
+ // Anticipate enemy's next missile based on their current state
478
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
479
+ {
480
+ const remainingEnemyCast =
481
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
482
+ nextThreatTime = Math.min(
483
+ nextThreatTime,
484
+ remainingEnemyCast + distance / castingFlightTimeDivisor,
485
+ );
486
+ }
487
+ // No imminent threat — estimate enemy's likely response time
488
+ if (nextThreatTime > threatFallbackThreshold)
489
+ {
490
+ const enemyLockTime = getEnemyVulnerabilityTicks(enemy);
491
+ nextThreatTime = Math.min(
492
+ nextThreatTime,
493
+ enemyLockTime +
494
+ enemyResponseEstimate +
495
+ distance / castingFlightTimeDivisor,
496
+ );
497
+ }
498
+
499
+ const safeBudget =
500
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
501
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
502
+
503
+ const config = fitMissileToBudget(budgetTicks, distance, {
504
+ minTurnRate,
505
+ maxDamage: enemyHP,
506
+ lastMissileConfig: state.lastMissileConfig,
507
+ });
508
+
509
+ // HIGH-DAMAGE PRIORITY: Sniper missiles need meaningful shield chip.
510
+ // Fixed big-shot fallback ensures Spellseeker always does significant damage.
511
+ const BIG_SHOT = {damage: 28, speed: 10, turnRate: 0.5, duration: 80};
512
+
513
+ if (
514
+ config &&
515
+ config.damage >= 15 &&
516
+ isSafeToAttack(threats, safeAttackCastEstimate)
517
+ )
518
+ {
519
+ return {
520
+ move,
521
+ startCast: {
522
+ spell: 'missile',
523
+ config,
524
+ missileAI: HomingAI,
525
+ direction: angleTo(state.position, enemy.position),
526
+ },
527
+ };
528
+ }
529
+
530
+ // Adaptive fitting weak or failed — fire big shot for meaningful damage
531
+ if (!urgentThreat || urgentThreat.ticksToImpact > 50)
532
+ {
533
+ return {
534
+ move,
535
+ startCast: {
536
+ spell: 'missile',
537
+ config: BIG_SHOT,
538
+ missileAI: HomingAI,
539
+ direction: angleTo(state.position, enemy.position),
540
+ },
541
+ };
542
+ }
543
+ }
544
+
545
+ return {move};
546
+ };