@vibemancer/core 0.1.0

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 (78) hide show
  1. package/README.md +28 -0
  2. package/dist/chunk-L7Z7OFXD.js +9140 -0
  3. package/dist/chunk-L7Z7OFXD.js.map +1 -0
  4. package/dist/index-browser.d.ts +2602 -0
  5. package/dist/index-browser.js +407 -0
  6. package/dist/index-browser.js.map +1 -0
  7. package/dist/index.d.ts +150 -0
  8. package/dist/index.js +750 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +79 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +457 -0
  12. package/src/bots/berserker/02_Stormcaller.ts +417 -0
  13. package/src/bots/berserker/03_Stormforger.ts +481 -0
  14. package/src/bots/caster/01_Flamecaller.ts +286 -0
  15. package/src/bots/caster/02_Pyromancer.ts +350 -0
  16. package/src/bots/caster/03_Infernalist.ts +492 -0
  17. package/src/bots/defensive/01_Turtle.ts +151 -0
  18. package/src/bots/defensive/02_Sentinel.ts +134 -0
  19. package/src/bots/defensive/03_Golem.ts +357 -0
  20. package/src/bots/duelist/01_Battlemage.ts +433 -0
  21. package/src/bots/duelist/02_Warmage.ts +438 -0
  22. package/src/bots/duelist/03_Archmage.ts +588 -0
  23. package/src/bots/homing/01_Bonemancer.ts +67 -0
  24. package/src/bots/homing/02_Lich.ts +356 -0
  25. package/src/bots/homing/03_Archlich.ts +220 -0
  26. package/src/bots/index.ts +30 -0
  27. package/src/bots/kiter/01_Spellspinner.ts +398 -0
  28. package/src/bots/kiter/02_Spellweaver.ts +378 -0
  29. package/src/bots/kiter/03_Spellbinder.ts +448 -0
  30. package/src/bots/melee/01_Shadowblade.ts +270 -0
  31. package/src/bots/melee/02_Nightblade.ts +437 -0
  32. package/src/bots/melee/03_Voidblade.ts +582 -0
  33. package/src/bots/registry.ts +207 -0
  34. package/src/bots/shared.ts +472 -0
  35. package/src/bots/sniper/01_Spellshot.ts +385 -0
  36. package/src/bots/sniper/02_Spelltracer.ts +441 -0
  37. package/src/bots/sniper/03_Spellseeker.ts +546 -0
  38. package/src/bots/standalone/Critter.ts +89 -0
  39. package/src/bots/standalone/Doombringer.ts +91 -0
  40. package/src/bots/standalone/Hogger.ts +228 -0
  41. package/src/bots/standalone/Rookie.ts +50 -0
  42. package/src/bots/standalone/TargetDummy.ts +21 -0
  43. package/src/bots/test/cheater.ts +405 -0
  44. package/src/bots/test/crasher.ts +81 -0
  45. package/src/engine/hooks-runtime.ts +394 -0
  46. package/src/engine/manual-match.ts +289 -0
  47. package/src/engine/missile-templates.ts +155 -0
  48. package/src/engine/optimizer.ts +220 -0
  49. package/src/engine/params-runtime.ts +189 -0
  50. package/src/engine/physics.ts +143 -0
  51. package/src/engine/sandbox-browser.ts +671 -0
  52. package/src/engine/sandbox-compile.ts +197 -0
  53. package/src/engine/sandbox-harness.ts +367 -0
  54. package/src/engine/sandbox.ts +332 -0
  55. package/src/engine/simulation.ts +828 -0
  56. package/src/engine/spells.ts +128 -0
  57. package/src/engine-version.ts +11 -0
  58. package/src/hooks/action-builders.ts +210 -0
  59. package/src/hooks/bot-wrapper.ts +84 -0
  60. package/src/hooks/index.ts +75 -0
  61. package/src/hooks/state-hooks.ts +354 -0
  62. package/src/hooks/threat-analysis.ts +365 -0
  63. package/src/hooks/types.ts +142 -0
  64. package/src/index-browser.ts +30 -0
  65. package/src/index.ts +24 -0
  66. package/src/rules.ts +254 -0
  67. package/src/stats.ts +262 -0
  68. package/src/testing.ts +207 -0
  69. package/src/trace.ts +430 -0
  70. package/src/types.ts +193 -0
  71. package/src/utils/angles.ts +47 -0
  72. package/src/utils/combat.ts +279 -0
  73. package/src/utils/distance.ts +21 -0
  74. package/src/utils/index.ts +7 -0
  75. package/src/utils/movement.ts +108 -0
  76. package/src/utils/random.ts +65 -0
  77. package/src/utils/spatial.ts +63 -0
  78. package/src/utils/targeting.ts +45 -0
@@ -0,0 +1,448 @@
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, getLeadPosition} from '../../utils/combat.js';
6
+ import {
7
+ getThreats,
8
+ getMostUrgentThreat,
9
+ getDodgeDirectionForMovement,
10
+ getBlinkToCenter,
11
+ getBlinkToIncreaseDistance,
12
+ fitMissileToBudget,
13
+ MIN_BLINK_ESCAPE_DISTANCE,
14
+ shouldForceShield,
15
+ MAX_CAST_BUDGET,
16
+ MIN_USEFUL_DAMAGE,
17
+ getEnemyVulnerabilityTicks,
18
+ } from '../shared.js';
19
+ import {useParam} from '../../engine/params-runtime.js';
20
+ const WALL_BUFFER = 60;
21
+
22
+ const SHIELD_BUFFER = 3;
23
+
24
+ /**
25
+ * Standard homing missile AI.
26
+ */
27
+ const HomingAI: MissileAIFunction = ({worldState}) =>
28
+ {
29
+ const enemy = worldState.enemies[0];
30
+ return {
31
+ turnToward: enemy?.position,
32
+ };
33
+ };
34
+
35
+ /**
36
+ * Straight missile AI — no homing, flies in initial direction.
37
+ */
38
+ const StraightAI: MissileAIFunction = () => ({});
39
+
40
+ /**
41
+ * Check if near wall.
42
+ */
43
+ function isNearWall(position: {x: number; y: number}): boolean
44
+ {
45
+ return (
46
+ position.x < WALL_BUFFER ||
47
+ position.x > ARENA_SIZE - WALL_BUFFER ||
48
+ position.y < WALL_BUFFER ||
49
+ position.y > ARENA_SIZE - WALL_BUFFER
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Smart strafe with threat-aware dodging and wall avoidance.
55
+ */
56
+ function smartStrafe(
57
+ position: {x: number; y: number},
58
+ enemy: {position: {x: number; y: number}},
59
+ dodgeDirection: {x: number; y: number} | null,
60
+ tick: number,
61
+ strafeCyclePeriod: number,
62
+ strafeIntensity: number,
63
+ ): {x: number; y: number}
64
+ {
65
+ const deltaX = enemy.position.x - position.x;
66
+ const deltaY = enemy.position.y - position.y;
67
+ const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
68
+ if (normalizedDistance === 0) return {x: 0, y: 0};
69
+
70
+ const strafeClockwise = {
71
+ x: -deltaY / normalizedDistance,
72
+ y: deltaX / normalizedDistance,
73
+ };
74
+ const strafeCounterClockwise = {
75
+ x: deltaY / normalizedDistance,
76
+ y: -deltaX / normalizedDistance,
77
+ };
78
+
79
+ let strafeDir: {x: number; y: number};
80
+
81
+ if (dodgeDirection)
82
+ {
83
+ strafeDir = dodgeDirection;
84
+ }
85
+ else if (isNearWall(position))
86
+ {
87
+ const futureClockwise = {
88
+ x: position.x + strafeClockwise.x * 100,
89
+ y: position.y + strafeClockwise.y * 100,
90
+ };
91
+ const futureCounterClockwise = {
92
+ x: position.x + strafeCounterClockwise.x * 100,
93
+ y: position.y + strafeCounterClockwise.y * 100,
94
+ };
95
+ const scoreClockwise = Math.min(
96
+ futureClockwise.x,
97
+ ARENA_SIZE - futureClockwise.x,
98
+ futureClockwise.y,
99
+ ARENA_SIZE - futureClockwise.y,
100
+ );
101
+ const scoreCounterClockwise = Math.min(
102
+ futureCounterClockwise.x,
103
+ ARENA_SIZE - futureCounterClockwise.x,
104
+ futureCounterClockwise.y,
105
+ ARENA_SIZE - futureCounterClockwise.y,
106
+ );
107
+ strafeDir =
108
+ scoreClockwise > scoreCounterClockwise
109
+ ? strafeClockwise
110
+ : strafeCounterClockwise;
111
+ }
112
+ else
113
+ {
114
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
115
+ strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
116
+ }
117
+
118
+ const intensity = dodgeDirection ? 100 : strafeIntensity;
119
+ return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
120
+ }
121
+
122
+ /**
123
+ * Bot: Spellbinder
124
+ *
125
+ * BEHAVIOR: Enhanced Spellweaver with vulnerability exploitation. Same medium-range
126
+ * kiting playstyle — maintains distance, strafes heavily, uses fitMissileToBudget
127
+ * for adaptive homing missiles. The T3 upgrade adds a punish mode that fires fast
128
+ * straight missiles timed to land while the enemy is locked in a cast or GCD,
129
+ * when they cannot shield. Defense, movement, and standard offense are identical
130
+ * to Spellweaver.
131
+ *
132
+ * NAMING RATIONALE: A binder constrains and locks down opponents. Where the
133
+ * Spellweaver optimizes missile patterns (adaptive fitting), the Spellbinder
134
+ * reads the enemy's state and punishes vulnerability windows — binding them
135
+ * to their commitments with unavoidable damage.
136
+ *
137
+ * PROGRESSION LINE: Spellspinner → Spellweaver → Spellbinder
138
+ * - Spellspinner (tier 1): Fixed homing missiles, constant strafe, basic defense
139
+ * - Spellweaver (tier 2): + adaptive missile fitting, more sophisticated patterns
140
+ * - Spellbinder (tier 3): + vulnerability punish mode with fast straight missiles
141
+ *
142
+ * TIER: 3 (elite Spellspinner line)
143
+ */
144
+ export const Spellbinder: WizardFunction = ({state}) =>
145
+ {
146
+ const targetDistance = useParam('targetDistance', 134, {
147
+ range: 150,
148
+ min: 0,
149
+ });
150
+ const dangerDistance = useParam('dangerDistance', 389, {
151
+ range: 125,
152
+ min: 0,
153
+ });
154
+ const minTurnRate = useParam('minTurnRate', 0.2, {range: 1.5, steps: 5});
155
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 123, {
156
+ range: 75,
157
+ min: 80,
158
+ max: 300,
159
+ });
160
+ const strafeIntensity = useParam('strafeIntensity', 30, {
161
+ range: 40,
162
+ min: 30,
163
+ max: 100,
164
+ steps: 7,
165
+ });
166
+ const approachSpeed = useParam('approachSpeed', 50, {
167
+ range: 30,
168
+ min: 10,
169
+ max: 70,
170
+ steps: 7,
171
+ });
172
+ const distanceDeadZone = useParam('distanceDeadZone', 7, {
173
+ range: 30,
174
+ min: 5,
175
+ max: 80,
176
+ steps: 7,
177
+ });
178
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
179
+ range: 75,
180
+ min: 50,
181
+ max: 300,
182
+ });
183
+ const flightTimeDivisor = useParam('flightTimeDivisor', 10.8, {
184
+ range: 4,
185
+ min: 2,
186
+ max: 12,
187
+ steps: 5,
188
+ });
189
+ const punishMinVulnerability = useParam('punishMinVulnerability', 30, {
190
+ range: 80,
191
+ min: 30,
192
+ steps: 7,
193
+ });
194
+
195
+ const enemy = state.enemies[0];
196
+ if (!enemy)
197
+ {
198
+ return {move: {x: 0, y: 0}};
199
+ }
200
+
201
+ const distance = distanceTo(state.position, enemy.position);
202
+
203
+ // Analyze threats using simulation
204
+ const threats = getThreats(state);
205
+ const urgentThreat = getMostUrgentThreat(threats);
206
+
207
+ // Smart strafe
208
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
209
+ const strafe = smartStrafe(
210
+ state.position,
211
+ enemy,
212
+ dodgeDirection,
213
+ state.tick,
214
+ strafeCyclePeriod,
215
+ strafeIntensity,
216
+ );
217
+
218
+ // Distance management (only when not dodging)
219
+ let moveX = strafe.x;
220
+ let moveY = strafe.y;
221
+
222
+ if (!dodgeDirection)
223
+ {
224
+ const deltaX = enemy.position.x - state.position.x;
225
+ const deltaY = enemy.position.y - state.position.y;
226
+ const normalizedDistance = distance > 0 ? distance : 1;
227
+
228
+ if (distance > targetDistance + distanceDeadZone)
229
+ {
230
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
231
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
232
+ }
233
+ else if (distance < targetDistance - distanceDeadZone)
234
+ {
235
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
236
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
237
+ if (state.position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
238
+ if (state.position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0)
239
+ retreatX = 0;
240
+ if (state.position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
241
+ if (state.position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0)
242
+ retreatY = 0;
243
+ moveX += retreatX;
244
+ moveY += retreatY;
245
+ }
246
+ }
247
+
248
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
249
+ if (magnitude > 100)
250
+ {
251
+ moveX = (moveX / magnitude) * 100;
252
+ moveY = (moveY / magnitude) * 100;
253
+ }
254
+
255
+ const move = {x: moveX, y: moveY};
256
+
257
+ // === DEFENSE: Handle channeling ===
258
+ if (state.state === 'channeling')
259
+ {
260
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
261
+ {
262
+ return {move, cancel: true};
263
+ }
264
+ return {move: {x: 0, y: 0}};
265
+ }
266
+
267
+ // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
268
+ // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
269
+ if (
270
+ state.state === 'casting' &&
271
+ state.castingSpell === 'missile' &&
272
+ urgentThreat &&
273
+ urgentThreat.projectile.damage >= state.health
274
+ )
275
+ {
276
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
277
+ if (
278
+ urgentThreat.ticksToImpact <=
279
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
280
+ )
281
+ {
282
+ return {move, cancel: true};
283
+ }
284
+ }
285
+
286
+ // === DEFENSE: Shield undodgeable or high-damage homing threats ===
287
+ if (
288
+ state.state === 'idle' &&
289
+ urgentThreat &&
290
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
291
+ )
292
+ {
293
+ if (urgentThreat.canBlockInTime)
294
+ {
295
+ if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
296
+ {
297
+ return {
298
+ move: {x: 0, y: 0},
299
+ startCast: {spell: 'shield'},
300
+ };
301
+ }
302
+ }
303
+ else if (state.blinkCooldown === 0)
304
+ {
305
+ return {
306
+ move: {x: 0, y: 0},
307
+ startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
308
+ };
309
+ }
310
+ }
311
+
312
+ // === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
313
+ if (
314
+ state.state === 'idle' &&
315
+ distance < dangerDistance &&
316
+ state.blinkCooldown === 0 &&
317
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
318
+ )
319
+ {
320
+ const blinkTarget = getBlinkToIncreaseDistance(
321
+ state.position,
322
+ enemy.position,
323
+ state.projectiles,
324
+ MIN_BLINK_ESCAPE_DISTANCE,
325
+ );
326
+ if (blinkTarget)
327
+ {
328
+ return {
329
+ move: {x: 0, y: 0},
330
+ startCast: {spell: 'blink', target: blinkTarget},
331
+ };
332
+ }
333
+ }
334
+
335
+ // === OFFENSE: Punish mode — fast straight missiles during enemy vulnerability ===
336
+ if (
337
+ state.state === 'idle' &&
338
+ getEnemyVulnerabilityTicks(enemy) >= punishMinVulnerability &&
339
+ distance < 500
340
+ )
341
+ {
342
+ const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
343
+ const flightTimeEstimate = distance / flightTimeDivisor;
344
+ const punishBudget = vulnerabilityTicks - flightTimeEstimate;
345
+ const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
346
+ const safeBudget =
347
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
348
+ const effectiveBudget = Math.min(
349
+ punishBudget,
350
+ safeBudget,
351
+ MAX_CAST_BUDGET,
352
+ );
353
+
354
+ if (effectiveBudget > 0)
355
+ {
356
+ const enemyHP = Math.ceil(enemy.health);
357
+ const config = fitMissileToBudget(effectiveBudget, distance, {
358
+ minTurnRate: 0,
359
+ maxDamage: enemyHP,
360
+ lastMissileConfig: state.lastMissileConfig,
361
+ });
362
+
363
+ if (config && config.damage >= MIN_USEFUL_DAMAGE)
364
+ {
365
+ const castTime = getMissileCastTime(config, state.lastMissileConfig);
366
+ const actualFlight = distance / config.speed;
367
+
368
+ if (castTime + actualFlight < vulnerabilityTicks)
369
+ {
370
+ const aimTarget =
371
+ config.turnRate > 0
372
+ ? enemy.position
373
+ : getLeadPosition(
374
+ enemy.position,
375
+ enemy.velocity,
376
+ config.speed,
377
+ state.position,
378
+ );
379
+ return {
380
+ move,
381
+ startCast: {
382
+ spell: 'missile',
383
+ config,
384
+ missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
385
+ direction: angleTo(state.position, aimTarget),
386
+ },
387
+ };
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ // === OFFENSE: Adaptive missile fitting (identical to Spellweaver) ===
394
+ // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
395
+ const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
396
+ const hasNearbyLethalMissile = state.projectiles.some(
397
+ (p) =>
398
+ !myProjectileIds.has(p.id) &&
399
+ p.damage >= state.health &&
400
+ distanceTo(state.position, p.position) / p.speed <
401
+ MAX_CAST_BUDGET + GCD_DURATION,
402
+ );
403
+ if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
404
+ {
405
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
406
+
407
+ // Account for enemy's active cast
408
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
409
+ {
410
+ const remainingEnemyCast =
411
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
412
+ const estimatedFlightTime = distance / flightTimeDivisor;
413
+ nextThreatTime = Math.min(
414
+ nextThreatTime,
415
+ remainingEnemyCast + estimatedFlightTime,
416
+ );
417
+ }
418
+ const safeBudget =
419
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
420
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
421
+
422
+ // Spellbinder always uses homing in standard mode (kiting means enemies are always moving)
423
+ const enemyHP = Math.ceil(enemy.health);
424
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
425
+ const config = fitMissileToBudget(budgetTicks, distance, {
426
+ minTurnRate,
427
+ maxDamage: enemyHP,
428
+ lastMissileConfig: state.lastMissileConfig,
429
+ });
430
+
431
+ if (config && config.damage >= minDmg)
432
+ {
433
+ return {
434
+ move,
435
+ startCast: {
436
+ spell: 'missile',
437
+ config,
438
+ missileAI: HomingAI,
439
+ direction: angleTo(state.position, enemy.position),
440
+ },
441
+ };
442
+ }
443
+
444
+ // Budget too small for useful missile — wait for threat to pass, then fire
445
+ }
446
+
447
+ return {move};
448
+ };
@@ -0,0 +1,270 @@
1
+ import {WizardFunction} from '../../types.js';
2
+ import {distanceTo} from '../../utils/distance.js';
3
+ import {ARENA_SIZE} from '../../rules.js';
4
+ import {
5
+ getThreats,
6
+ getMostUrgentThreat,
7
+ getDodgeDirectionForMovement,
8
+ getBlinkToDecreaseDistance,
9
+ shouldForceShield,
10
+ } from '../shared.js';
11
+ import {useParam} from '../../engine/params-runtime.js';
12
+ const WALL_BUFFER = 50;
13
+
14
+ // Melee stab: 20 damage, speed 8, 3-hit kill.
15
+ const STAB_CONFIG = {damage: 20, speed: 8, turnRate: 0, duration: 10};
16
+
17
+ /**
18
+ * Aggressive movement: dodge missiles using threat analysis, close distance, light strafe.
19
+ */
20
+ function aggressiveMove(
21
+ position: {x: number; y: number},
22
+ enemy: {position: {x: number; y: number}},
23
+ currentDistance: number,
24
+ dodgeDirection: {x: number; y: number} | null,
25
+ tick: number,
26
+ strafeCyclePeriod: number,
27
+ strafeIntensity: number,
28
+ approachSpeed: number,
29
+ ): {x: number; y: number}
30
+ {
31
+ const deltaX = enemy.position.x - position.x;
32
+ const deltaY = enemy.position.y - position.y;
33
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
34
+
35
+ const strafeClockwise = {
36
+ x: -deltaY / normalizedDistance,
37
+ y: deltaX / normalizedDistance,
38
+ };
39
+ const strafeCounterClockwise = {
40
+ x: deltaY / normalizedDistance,
41
+ y: -deltaX / normalizedDistance,
42
+ };
43
+
44
+ let strafeDirection: {x: number; y: number};
45
+
46
+ if (dodgeDirection)
47
+ {
48
+ strafeDirection = dodgeDirection;
49
+ }
50
+ else if (
51
+ position.x < WALL_BUFFER ||
52
+ position.x > ARENA_SIZE - WALL_BUFFER ||
53
+ position.y < WALL_BUFFER ||
54
+ position.y > ARENA_SIZE - WALL_BUFFER
55
+ )
56
+ {
57
+ const futureClockwise = {
58
+ x: position.x + strafeClockwise.x * 100,
59
+ y: position.y + strafeClockwise.y * 100,
60
+ };
61
+ const futureCounterClockwise = {
62
+ x: position.x + strafeCounterClockwise.x * 100,
63
+ y: position.y + strafeCounterClockwise.y * 100,
64
+ };
65
+ const scoreClockwise = Math.min(
66
+ futureClockwise.x,
67
+ ARENA_SIZE - futureClockwise.x,
68
+ futureClockwise.y,
69
+ ARENA_SIZE - futureClockwise.y,
70
+ );
71
+ const scoreCounterClockwise = Math.min(
72
+ futureCounterClockwise.x,
73
+ ARENA_SIZE - futureCounterClockwise.x,
74
+ futureCounterClockwise.y,
75
+ ARENA_SIZE - futureCounterClockwise.y,
76
+ );
77
+ strafeDirection =
78
+ scoreClockwise > scoreCounterClockwise
79
+ ? strafeClockwise
80
+ : strafeCounterClockwise;
81
+ }
82
+ else
83
+ {
84
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
85
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
86
+ }
87
+
88
+ // When actively dodging, commit fully
89
+ if (dodgeDirection)
90
+ {
91
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
92
+ }
93
+
94
+ // Light strafe + aggressive closing
95
+ let moveX = strafeDirection.x * strafeIntensity;
96
+ let moveY = strafeDirection.y * strafeIntensity;
97
+
98
+ // Always close distance aggressively (melee wants to be in the enemy's face)
99
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
100
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
101
+
102
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
103
+ if (magnitude > 100)
104
+ {
105
+ moveX = (moveX / magnitude) * 100;
106
+ moveY = (moveY / magnitude) * 100;
107
+ }
108
+
109
+ return {x: moveX, y: moveY};
110
+ }
111
+
112
+ /**
113
+ * Bot: Shadowblade
114
+ *
115
+ * BEHAVIOR: Melee assassin. Blinks to the enemy, then lands devastating point-blank
116
+ * stab attacks (15 damage, 30u range, ~60 tick cast = 2-hit kill). Runs directly
117
+ * at the enemy with minimal strafe, shields undodgeable threats. The entire
118
+ * strategy is: get close, stab, kill. Simple and brutal.
119
+ *
120
+ * PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
121
+ * - Shadowblade (tier 1): Offensive blink, melee stabs, basic shield
122
+ * - Nightblade (tier 2): + missile-aware blinks, adaptive stabs, timed defense
123
+ * - Voidblade (tier 3): Future — perfect assassination timing, inescapable engages
124
+ *
125
+ * TIER: 1 (base)
126
+ */
127
+ export const Shadowblade: WizardFunction = ({state}) =>
128
+ {
129
+ const stabRange = useParam('stabRange', 76, {
130
+ range: 30,
131
+ min: 20,
132
+ max: 80,
133
+ steps: 5,
134
+ });
135
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 252, {
136
+ range: 75,
137
+ min: 80,
138
+ max: 300,
139
+ });
140
+ const strafeIntensity = useParam('strafeIntensity', 55, {
141
+ range: 30,
142
+ min: 10,
143
+ max: 80,
144
+ steps: 7,
145
+ });
146
+ const approachSpeed = useParam('approachSpeed', 61, {
147
+ range: 20,
148
+ min: 50,
149
+ max: 100,
150
+ steps: 7,
151
+ });
152
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
153
+ range: 75,
154
+ min: 50,
155
+ max: 300,
156
+ });
157
+ const blinkWindowMax = useParam('blinkWindowMax', 23, {
158
+ range: 30,
159
+ min: 15,
160
+ max: 80,
161
+ steps: 7,
162
+ });
163
+
164
+ const enemy = state.enemies[0];
165
+ if (!enemy)
166
+ {
167
+ return {move: {x: 0, y: 0}};
168
+ }
169
+
170
+ const distance = distanceTo(state.position, enemy.position);
171
+
172
+ const threats = getThreats(state);
173
+ const urgentThreat = getMostUrgentThreat(threats);
174
+
175
+ // Cancel shield if no longer needed
176
+ if (state.state === 'channeling')
177
+ {
178
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
179
+ {
180
+ return {move: {x: 0, y: 0}, cancel: true};
181
+ }
182
+ return {move: {x: 0, y: 0}};
183
+ }
184
+
185
+ if (state.state === 'idle')
186
+ {
187
+ // === BLINK: Dodge missile + close gap simultaneously (first priority) ===
188
+ // Wait until the missile is close (<=40 ticks) so the blink actually dodges it.
189
+ // Blinking too early means the missile can still re-track. Too late (<10) and the
190
+ // blink cast won't complete in time.
191
+ if (
192
+ urgentThreat &&
193
+ state.blinkCooldown === 0 &&
194
+ distance > stabRange &&
195
+ urgentThreat.ticksToImpact >= 10 &&
196
+ urgentThreat.ticksToImpact <= blinkWindowMax
197
+ )
198
+ {
199
+ const blinkTarget =
200
+ getBlinkToDecreaseDistance(
201
+ state.position,
202
+ enemy.position,
203
+ state.projectiles,
204
+ ) ?? enemy.position;
205
+ return {
206
+ move: {x: 0, y: 0},
207
+ startCast: {spell: 'blink', target: blinkTarget},
208
+ };
209
+ }
210
+
211
+ // Pre-stab shield: already at melee range, survival beats landing another stab.
212
+ // Gated to stab range so out-of-range threats wait for the blink window.
213
+ if (
214
+ urgentThreat &&
215
+ distance < stabRange &&
216
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
217
+ urgentThreat.canBlockInTime &&
218
+ urgentThreat.ticksToStartShield <= 3
219
+ )
220
+ {
221
+ return {
222
+ move: {x: 0, y: 0},
223
+ startCast: {spell: 'shield'},
224
+ };
225
+ }
226
+
227
+ // Melee stab when close enough (offense over defense at melee range)
228
+ if (distance < stabRange)
229
+ {
230
+ return {
231
+ move: {x: 0, y: 0},
232
+ startCast: {
233
+ spell: 'missile',
234
+ config: STAB_CONFIG,
235
+ missileAI: () => ({}), // Non-homing, point blank
236
+ },
237
+ };
238
+ }
239
+
240
+ // === DEFENSE: Shield undodgeable threats (only when blink can't handle it) ===
241
+ // Don't shield if blink is available — wait for the blink window instead.
242
+ if (
243
+ urgentThreat &&
244
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
245
+ urgentThreat.canBlockInTime &&
246
+ urgentThreat.ticksToStartShield <= 3 &&
247
+ (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
248
+ )
249
+ {
250
+ return {
251
+ move: {x: 0, y: 0},
252
+ startCast: {spell: 'shield'},
253
+ };
254
+ }
255
+ }
256
+
257
+ // Aggressive movement — always close distance
258
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
259
+ const move = aggressiveMove(
260
+ state.position,
261
+ enemy,
262
+ distance,
263
+ dodgeDirection,
264
+ state.tick,
265
+ strafeCyclePeriod,
266
+ strafeIntensity,
267
+ approachSpeed,
268
+ );
269
+ return {move};
270
+ };