@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,437 +1,437 @@
1
- import {WizardFunction} from '../../types.js';
2
- import {distanceTo} from '../../utils/distance.js';
3
- import {angleTo} from '../../utils/angles.js';
4
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
- import {
6
- getThreats,
7
- getMostUrgentThreat,
8
- getDodgeDirectionForMovement,
9
- getBlinkToDecreaseDistance,
10
- shouldForceShield,
11
- fitMissileToBudget,
12
- MAX_CAST_BUDGET,
13
- MIN_USEFUL_DAMAGE,
14
- } from '../shared.js';
15
- import {useParam} from '../../engine/params-runtime.js';
16
- const WALL_BUFFER = 50;
17
- const SHIELD_BUFFER = 3;
18
-
19
- /**
20
- * Aggressive movement: dodge missiles, close distance, light strafe.
21
- */
22
- function aggressiveMove(
23
- position: {x: number; y: number},
24
- enemy: {position: {x: number; y: number}},
25
- currentDistance: number,
26
- dodgeDirection: {x: number; y: number} | null,
27
- tick: number,
28
- strafeCyclePeriod: number,
29
- strafeIntensity: number,
30
- approachSpeed: number,
31
- ): {x: number; y: number}
32
- {
33
- const deltaX = enemy.position.x - position.x;
34
- const deltaY = enemy.position.y - position.y;
35
- const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
36
-
37
- const strafeClockwise = {
38
- x: -deltaY / normalizedDistance,
39
- y: deltaX / normalizedDistance,
40
- };
41
- const strafeCounterClockwise = {
42
- x: deltaY / normalizedDistance,
43
- y: -deltaX / normalizedDistance,
44
- };
45
-
46
- let strafeDirection: {x: number; y: number};
47
-
48
- if (dodgeDirection)
49
- {
50
- strafeDirection = dodgeDirection;
51
- }
52
- else if (
53
- position.x < WALL_BUFFER ||
54
- position.x > ARENA_SIZE - WALL_BUFFER ||
55
- position.y < WALL_BUFFER ||
56
- position.y > ARENA_SIZE - WALL_BUFFER
57
- )
58
- {
59
- const futureClockwise = {
60
- x: position.x + strafeClockwise.x * 100,
61
- y: position.y + strafeClockwise.y * 100,
62
- };
63
- const futureCounterClockwise = {
64
- x: position.x + strafeCounterClockwise.x * 100,
65
- y: position.y + strafeCounterClockwise.y * 100,
66
- };
67
- const scoreClockwise = Math.min(
68
- futureClockwise.x,
69
- ARENA_SIZE - futureClockwise.x,
70
- futureClockwise.y,
71
- ARENA_SIZE - futureClockwise.y,
72
- );
73
- const scoreCounterClockwise = Math.min(
74
- futureCounterClockwise.x,
75
- ARENA_SIZE - futureCounterClockwise.x,
76
- futureCounterClockwise.y,
77
- ARENA_SIZE - futureCounterClockwise.y,
78
- );
79
- strafeDirection =
80
- scoreClockwise > scoreCounterClockwise
81
- ? strafeClockwise
82
- : strafeCounterClockwise;
83
- }
84
- else
85
- {
86
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
87
- strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
88
- }
89
-
90
- // When actively dodging, commit fully
91
- if (dodgeDirection)
92
- {
93
- return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
94
- }
95
-
96
- // Light strafe + aggressive closing
97
- let moveX = strafeDirection.x * strafeIntensity;
98
- let moveY = strafeDirection.y * strafeIntensity;
99
-
100
- // Always close distance aggressively
101
- moveX += (deltaX / normalizedDistance) * approachSpeed;
102
- moveY += (deltaY / normalizedDistance) * approachSpeed;
103
-
104
- const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
105
- if (magnitude > 100)
106
- {
107
- moveX = (moveX / magnitude) * 100;
108
- moveY = (moveY / magnitude) * 100;
109
- }
110
-
111
- return {x: moveX, y: moveY};
112
- }
113
-
114
- /**
115
- * Bot: Nightblade
116
- *
117
- * BEHAVIOR: Enhanced melee assassin. Same aggressive engagement as Shadowblade —
118
- * blinks directly to the enemy and stabs for 15 damage (2-hit kill). The tier 2
119
- * upgrade is PREEMPTIVE DEFENSE: Nightblade watches the enemy's cast bar and
120
- * shields before a point-blank missile is even launched. At melee range, missiles
121
- * arrive almost instantly after launch — too fast to react. Nightblade anticipates
122
- * the threat. Also has emergency blink and proper channeling management.
123
- *
124
- * PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
125
- * - Shadowblade (tier 1): Offensive blink, melee stabs, basic shield (reactive only)
126
- * - Nightblade (tier 2): + preemptive shield vs enemy casts, emergency blink
127
- * - Voidblade (tier 3): Future — perfect assassination timing, inescapable engages
128
- *
129
- * TIER: 2 (enhanced Shadowblade)
130
- */
131
- export const Nightblade: WizardFunction = ({state}) =>
132
- {
133
- const stabRange = useParam('stabRange', 263, {range: 38, min: 0, steps: 5});
134
- const stabRangeVsCasting = stabRange + 20;
135
- const preemptiveShieldRange = useParam('preemptiveShieldRange', 261, {
136
- range: 85,
137
- min: 0,
138
- steps: 7,
139
- });
140
- const acceptHitFraction = useParam('acceptHitFraction', 0, {
141
- range: 0.2,
142
- min: 0,
143
- max: 1,
144
- steps: 5,
145
- });
146
- const minTurnRate = useParam('minTurnRate', -11.4, {range: 5.25, steps: 5});
147
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {
148
- range: 75,
149
- min: 80,
150
- max: 300,
151
- });
152
- const strafeIntensity = useParam('strafeIntensity', 22, {
153
- range: 30,
154
- min: 10,
155
- max: 80,
156
- steps: 7,
157
- });
158
- const approachSpeed = useParam('approachSpeed', 92, {
159
- range: 20,
160
- min: 50,
161
- max: 100,
162
- steps: 7,
163
- });
164
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 50, {
165
- range: 75,
166
- min: 50,
167
- max: 300,
168
- });
169
- const blinkWindowMax = useParam('blinkWindowMax', 19, {
170
- range: 30,
171
- min: 15,
172
- max: 80,
173
- steps: 7,
174
- });
175
- const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
176
- range: 4,
177
- min: 2,
178
- max: 12,
179
- steps: 5,
180
- });
181
- const enemyFiringSoonThreshold = useParam('enemyFiringSoonThreshold', 50, {
182
- range: 30,
183
- min: 10,
184
- max: 100,
185
- steps: 7,
186
- });
187
- const meleeAbortRange = useParam('meleeAbortRange', 200, {
188
- range: 50,
189
- min: 50,
190
- max: 200,
191
- steps: 7,
192
- });
193
-
194
- const enemy = state.enemies[0];
195
- if (!enemy)
196
- {
197
- return {move: {x: 0, y: 0}};
198
- }
199
-
200
- const distance = distanceTo(state.position, enemy.position);
201
-
202
- const threats = getThreats(state);
203
- const urgentThreat = getMostUrgentThreat(threats);
204
-
205
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
206
- const move = aggressiveMove(
207
- state.position,
208
- enemy,
209
- distance,
210
- dodgeDirection,
211
- state.tick,
212
- strafeCyclePeriod,
213
- strafeIntensity,
214
- approachSpeed,
215
- );
216
-
217
- // === DEFENSE: Handle channeling ===
218
- if (state.state === 'channeling')
219
- {
220
- // Keep shield up if enemy is about to fire at close range (preemptive shield scenario)
221
- const enemyFiringSoon =
222
- distance < preemptiveShieldRange &&
223
- enemy.state === 'casting' &&
224
- enemy.castingSpell === 'missile' &&
225
- (enemy.castDuration ?? 0) -
226
- (enemy.castProgress ?? 0) +
227
- distance / flightTimeDivisor <
228
- enemyFiringSoonThreshold;
229
- if (
230
- (!urgentThreat && !enemyFiringSoon) ||
231
- (urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
232
- )
233
- {
234
- return {move, cancel: true};
235
- }
236
- return {move: {x: 0, y: 0}};
237
- }
238
-
239
- // === DEFENSE: Cancel missile cast for LETHAL incoming damage ===
240
- // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
241
- if (
242
- state.state === 'casting' &&
243
- state.castingSpell === 'missile' &&
244
- urgentThreat &&
245
- urgentThreat.projectile.damage >= state.health
246
- )
247
- {
248
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
249
- if (
250
- urgentThreat.ticksToImpact <=
251
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
252
- )
253
- {
254
- return {move, cancel: true};
255
- }
256
- }
257
-
258
- // === CANCEL: Abort melee cast if target escaped (non-homing missile won't reach) ===
259
- // Melee missiles are non-homing with short range (~63u). If the enemy blinked away,
260
- // continuing the cast is wasted time while we're vulnerable.
261
- if (
262
- state.state === 'casting' &&
263
- state.castingSpell === 'missile' &&
264
- distance > meleeAbortRange
265
- )
266
- {
267
- return {move, cancel: true};
268
- }
269
-
270
- // === BLINK: Dodge missile + close gap simultaneously (first priority) ===
271
- // Blink both dodges the incoming missile AND closes distance — better than shielding.
272
- // Only shield when blink is on cooldown.
273
- if (
274
- state.state === 'idle' &&
275
- urgentThreat &&
276
- state.blinkCooldown === 0 &&
277
- distance > stabRange &&
278
- urgentThreat.ticksToImpact >= 10 &&
279
- urgentThreat.ticksToImpact <= blinkWindowMax
280
- )
281
- {
282
- const blinkTarget =
283
- getBlinkToDecreaseDistance(
284
- state.position,
285
- enemy.position,
286
- state.projectiles,
287
- ) ?? enemy.position;
288
- return {
289
- move: {x: 0, y: 0},
290
- startCast: {spell: 'blink', target: blinkTarget},
291
- };
292
- }
293
-
294
- // === DEFENSE: Shield lethal/high-damage undodgeable threats — survival over offense ===
295
- if (
296
- state.state === 'idle' &&
297
- urgentThreat &&
298
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
299
- urgentThreat.projectile.damage >= state.health &&
300
- urgentThreat.canBlockInTime
301
- )
302
- {
303
- return {
304
- move: {x: 0, y: 0},
305
- startCast: {spell: 'shield'},
306
- };
307
- }
308
-
309
- // === OFFENSE: Budget-based melee stab when close enough ===
310
- // Tier 2 upgrade: fits the highest-damage stab into the available time window
311
- // before needing to shield. Also stabs from farther when enemy is casting/channeling
312
- // (they move at 0.5 or 0 speed, so the cast delay doesn't let them dodge).
313
- const effectiveStabRange =
314
- enemy.state === 'casting' || enemy.state === 'channeling'
315
- ? stabRangeVsCasting
316
- : stabRange;
317
- // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
318
- const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
319
- const hasNearbyLethalMissile = state.projectiles.some(
320
- (p) =>
321
- !myProjectileIds.has(p.id) &&
322
- p.damage >= state.health &&
323
- distanceTo(state.position, p.position) / p.speed <
324
- MAX_CAST_BUDGET + GCD_DURATION,
325
- );
326
- if (
327
- state.state === 'idle' &&
328
- distance < effectiveStabRange &&
329
- !hasNearbyLethalMissile
330
- )
331
- {
332
- let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
333
-
334
- // Account for enemy's active cast — their missile will arrive after cast completes + flight time
335
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
336
- {
337
- const remainingEnemyCast =
338
- (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
339
- const estimatedFlightTime = distance / flightTimeDivisor;
340
- nextThreatTime = Math.min(
341
- nextThreatTime,
342
- remainingEnemyCast + estimatedFlightTime,
343
- );
344
- }
345
-
346
- const safeBudget =
347
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
348
- const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
349
- const enemyHP = Math.ceil(enemy.health);
350
- const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
351
- let config = fitMissileToBudget(budgetTicks, distance, {
352
- minTurnRate,
353
- maxDamage: enemyHP,
354
- lastMissileConfig: state.lastMissileConfig,
355
- });
356
-
357
- // If budget too small for useful stab, fire bigger and accept the hit
358
- // Only accept small hits (< 25% HP) — don't trade away large chunks of health
359
- // Never accept when enemy is actively casting (their missile WILL arrive during our long cast)
360
- const enemyCasting =
361
- enemy.state === 'casting' && enemy.castingSpell === 'missile';
362
- if (
363
- (!config || config.damage < minDmg) &&
364
- !enemyCasting &&
365
- (!urgentThreat ||
366
- urgentThreat.projectile.damage < state.health * acceptHitFraction)
367
- )
368
- {
369
- config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
370
- minTurnRate,
371
- maxDamage: enemyHP,
372
- lastMissileConfig: state.lastMissileConfig,
373
- });
374
- }
375
-
376
- if (config)
377
- {
378
- return {
379
- move,
380
- startCast: {
381
- spell: 'missile',
382
- config,
383
- missileAI: () => ({}),
384
- direction: angleTo(state.position, enemy.position),
385
- },
386
- };
387
- }
388
- }
389
-
390
- // === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
391
- if (
392
- state.state === 'idle' &&
393
- urgentThreat &&
394
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
395
- )
396
- {
397
- if (
398
- urgentThreat.canBlockInTime &&
399
- (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
400
- )
401
- {
402
- return {
403
- move: {x: 0, y: 0},
404
- startCast: {spell: 'shield'},
405
- };
406
- }
407
- }
408
-
409
- // === DEFENSE: Preemptive shield when enemy is casting a missile at close range ===
410
- // At melee range, missiles arrive almost instantly after launch — too fast to react.
411
- // Shield BEFORE the missile launches. Trigger when shield will finish BEFORE missile hits.
412
- if (
413
- state.state === 'idle' &&
414
- distance < preemptiveShieldRange &&
415
- enemy.state === 'casting' &&
416
- enemy.castingSpell === 'missile'
417
- )
418
- {
419
- const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
420
- const estimatedFlightTicks = distance / flightTimeDivisor;
421
- const ticksUntilHit = remainingCast + estimatedFlightTicks;
422
- // Shield when hit is close enough that we MUST start now, but far enough that
423
- // the shield finishes in time. The window: SHIELD_CAST_TIME <= ticksUntilHit < SHIELD_CAST_TIME+15
424
- if (
425
- ticksUntilHit >= SHIELD_CAST_TIME &&
426
- ticksUntilHit < SHIELD_CAST_TIME + 15
427
- )
428
- {
429
- return {
430
- move: {x: 0, y: 0},
431
- startCast: {spell: 'shield'},
432
- };
433
- }
434
- }
435
-
436
- return {move};
437
- };
1
+ import {WizardFunction} from '../../types.js';
2
+ import {distanceTo} from '../../utils/distance.js';
3
+ import {angleTo} from '../../utils/angles.js';
4
+ import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
+ import {
6
+ getThreats,
7
+ getMostUrgentThreat,
8
+ getDodgeDirectionForMovement,
9
+ getBlinkToDecreaseDistance,
10
+ shouldForceShield,
11
+ fitMissileToBudget,
12
+ MAX_CAST_BUDGET,
13
+ MIN_USEFUL_DAMAGE,
14
+ } from '../shared.js';
15
+ import {useParam} from '../../engine/params-runtime.js';
16
+ const WALL_BUFFER = 50;
17
+ const SHIELD_BUFFER = 3;
18
+
19
+ /**
20
+ * Aggressive movement: dodge missiles, close distance, light strafe.
21
+ */
22
+ function aggressiveMove(
23
+ position: {x: number; y: number},
24
+ enemy: {position: {x: number; y: number}},
25
+ currentDistance: number,
26
+ dodgeDirection: {x: number; y: number} | null,
27
+ tick: number,
28
+ strafeCyclePeriod: number,
29
+ strafeIntensity: number,
30
+ approachSpeed: number,
31
+ ): {x: number; y: number}
32
+ {
33
+ const deltaX = enemy.position.x - position.x;
34
+ const deltaY = enemy.position.y - position.y;
35
+ const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
36
+
37
+ const strafeClockwise = {
38
+ x: -deltaY / normalizedDistance,
39
+ y: deltaX / normalizedDistance,
40
+ };
41
+ const strafeCounterClockwise = {
42
+ x: deltaY / normalizedDistance,
43
+ y: -deltaX / normalizedDistance,
44
+ };
45
+
46
+ let strafeDirection: {x: number; y: number};
47
+
48
+ if (dodgeDirection)
49
+ {
50
+ strafeDirection = dodgeDirection;
51
+ }
52
+ else if (
53
+ position.x < WALL_BUFFER ||
54
+ position.x > ARENA_SIZE - WALL_BUFFER ||
55
+ position.y < WALL_BUFFER ||
56
+ position.y > ARENA_SIZE - WALL_BUFFER
57
+ )
58
+ {
59
+ const futureClockwise = {
60
+ x: position.x + strafeClockwise.x * 100,
61
+ y: position.y + strafeClockwise.y * 100,
62
+ };
63
+ const futureCounterClockwise = {
64
+ x: position.x + strafeCounterClockwise.x * 100,
65
+ y: position.y + strafeCounterClockwise.y * 100,
66
+ };
67
+ const scoreClockwise = Math.min(
68
+ futureClockwise.x,
69
+ ARENA_SIZE - futureClockwise.x,
70
+ futureClockwise.y,
71
+ ARENA_SIZE - futureClockwise.y,
72
+ );
73
+ const scoreCounterClockwise = Math.min(
74
+ futureCounterClockwise.x,
75
+ ARENA_SIZE - futureCounterClockwise.x,
76
+ futureCounterClockwise.y,
77
+ ARENA_SIZE - futureCounterClockwise.y,
78
+ );
79
+ strafeDirection =
80
+ scoreClockwise > scoreCounterClockwise
81
+ ? strafeClockwise
82
+ : strafeCounterClockwise;
83
+ }
84
+ else
85
+ {
86
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
87
+ strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
88
+ }
89
+
90
+ // When actively dodging, commit fully
91
+ if (dodgeDirection)
92
+ {
93
+ return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
94
+ }
95
+
96
+ // Light strafe + aggressive closing
97
+ let moveX = strafeDirection.x * strafeIntensity;
98
+ let moveY = strafeDirection.y * strafeIntensity;
99
+
100
+ // Always close distance aggressively
101
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
102
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
103
+
104
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
105
+ if (magnitude > 100)
106
+ {
107
+ moveX = (moveX / magnitude) * 100;
108
+ moveY = (moveY / magnitude) * 100;
109
+ }
110
+
111
+ return {x: moveX, y: moveY};
112
+ }
113
+
114
+ /**
115
+ * Bot: Nightblade
116
+ *
117
+ * BEHAVIOR: Enhanced melee assassin. Same aggressive engagement as Shadowblade —
118
+ * blinks directly to the enemy and stabs for 15 damage (2-hit kill). The tier 2
119
+ * upgrade is PREEMPTIVE DEFENSE: Nightblade watches the enemy's cast bar and
120
+ * shields before a point-blank missile is even launched. At melee range, missiles
121
+ * arrive almost instantly after launch — too fast to react. Nightblade anticipates
122
+ * the threat. Also has emergency blink and proper channeling management.
123
+ *
124
+ * PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
125
+ * - Shadowblade (tier 1): Offensive blink, melee stabs, basic shield (reactive only)
126
+ * - Nightblade (tier 2): + preemptive shield vs enemy casts, emergency blink
127
+ * - Voidblade (tier 3): Future — perfect assassination timing, inescapable engages
128
+ *
129
+ * TIER: 2 (enhanced Shadowblade)
130
+ */
131
+ export const Nightblade: WizardFunction = ({state}) =>
132
+ {
133
+ const stabRange = useParam('stabRange', 263, {range: 38, min: 0, steps: 5});
134
+ const stabRangeVsCasting = stabRange + 20;
135
+ const preemptiveShieldRange = useParam('preemptiveShieldRange', 261, {
136
+ range: 85,
137
+ min: 0,
138
+ steps: 7,
139
+ });
140
+ const acceptHitFraction = useParam('acceptHitFraction', 0, {
141
+ range: 0.2,
142
+ min: 0,
143
+ max: 1,
144
+ steps: 5,
145
+ });
146
+ const minTurnRate = useParam('minTurnRate', -11.4, {range: 5.25, steps: 5});
147
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {
148
+ range: 75,
149
+ min: 80,
150
+ max: 300,
151
+ });
152
+ const strafeIntensity = useParam('strafeIntensity', 22, {
153
+ range: 30,
154
+ min: 10,
155
+ max: 80,
156
+ steps: 7,
157
+ });
158
+ const approachSpeed = useParam('approachSpeed', 92, {
159
+ range: 20,
160
+ min: 50,
161
+ max: 100,
162
+ steps: 7,
163
+ });
164
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 50, {
165
+ range: 75,
166
+ min: 50,
167
+ max: 300,
168
+ });
169
+ const blinkWindowMax = useParam('blinkWindowMax', 19, {
170
+ range: 30,
171
+ min: 15,
172
+ max: 80,
173
+ steps: 7,
174
+ });
175
+ const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
176
+ range: 4,
177
+ min: 2,
178
+ max: 12,
179
+ steps: 5,
180
+ });
181
+ const enemyFiringSoonThreshold = useParam('enemyFiringSoonThreshold', 50, {
182
+ range: 30,
183
+ min: 10,
184
+ max: 100,
185
+ steps: 7,
186
+ });
187
+ const meleeAbortRange = useParam('meleeAbortRange', 200, {
188
+ range: 50,
189
+ min: 50,
190
+ max: 200,
191
+ steps: 7,
192
+ });
193
+
194
+ const enemy = state.enemies[0];
195
+ if (!enemy)
196
+ {
197
+ return {move: {x: 0, y: 0}};
198
+ }
199
+
200
+ const distance = distanceTo(state.position, enemy.position);
201
+
202
+ const threats = getThreats(state);
203
+ const urgentThreat = getMostUrgentThreat(threats);
204
+
205
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
206
+ const move = aggressiveMove(
207
+ state.position,
208
+ enemy,
209
+ distance,
210
+ dodgeDirection,
211
+ state.tick,
212
+ strafeCyclePeriod,
213
+ strafeIntensity,
214
+ approachSpeed,
215
+ );
216
+
217
+ // === DEFENSE: Handle channeling ===
218
+ if (state.state === 'channeling')
219
+ {
220
+ // Keep shield up if enemy is about to fire at close range (preemptive shield scenario)
221
+ const enemyFiringSoon =
222
+ distance < preemptiveShieldRange &&
223
+ enemy.state === 'casting' &&
224
+ enemy.castingSpell === 'missile' &&
225
+ (enemy.castDuration ?? 0) -
226
+ (enemy.castProgress ?? 0) +
227
+ distance / flightTimeDivisor <
228
+ enemyFiringSoonThreshold;
229
+ if (
230
+ (!urgentThreat && !enemyFiringSoon) ||
231
+ (urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
232
+ )
233
+ {
234
+ return {move, cancel: true};
235
+ }
236
+ return {move: {x: 0, y: 0}};
237
+ }
238
+
239
+ // === DEFENSE: Cancel missile cast for LETHAL incoming damage ===
240
+ // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
241
+ if (
242
+ state.state === 'casting' &&
243
+ state.castingSpell === 'missile' &&
244
+ urgentThreat &&
245
+ urgentThreat.projectile.damage >= state.health
246
+ )
247
+ {
248
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
249
+ if (
250
+ urgentThreat.ticksToImpact <=
251
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
252
+ )
253
+ {
254
+ return {move, cancel: true};
255
+ }
256
+ }
257
+
258
+ // === CANCEL: Abort melee cast if target escaped (non-homing missile won't reach) ===
259
+ // Melee missiles are non-homing with short range (~63u). If the enemy blinked away,
260
+ // continuing the cast is wasted time while we're vulnerable.
261
+ if (
262
+ state.state === 'casting' &&
263
+ state.castingSpell === 'missile' &&
264
+ distance > meleeAbortRange
265
+ )
266
+ {
267
+ return {move, cancel: true};
268
+ }
269
+
270
+ // === BLINK: Dodge missile + close gap simultaneously (first priority) ===
271
+ // Blink both dodges the incoming missile AND closes distance — better than shielding.
272
+ // Only shield when blink is on cooldown.
273
+ if (
274
+ state.state === 'idle' &&
275
+ urgentThreat &&
276
+ state.blinkCooldown === 0 &&
277
+ distance > stabRange &&
278
+ urgentThreat.ticksToImpact >= 10 &&
279
+ urgentThreat.ticksToImpact <= blinkWindowMax
280
+ )
281
+ {
282
+ const blinkTarget =
283
+ getBlinkToDecreaseDistance(
284
+ state.position,
285
+ enemy.position,
286
+ state.projectiles,
287
+ ) ?? enemy.position;
288
+ return {
289
+ move: {x: 0, y: 0},
290
+ startCast: {spell: 'blink', target: blinkTarget},
291
+ };
292
+ }
293
+
294
+ // === DEFENSE: Shield lethal/high-damage undodgeable threats — survival over offense ===
295
+ if (
296
+ state.state === 'idle' &&
297
+ urgentThreat &&
298
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
299
+ urgentThreat.projectile.damage >= state.health &&
300
+ urgentThreat.canBlockInTime
301
+ )
302
+ {
303
+ return {
304
+ move: {x: 0, y: 0},
305
+ startCast: {spell: 'shield'},
306
+ };
307
+ }
308
+
309
+ // === OFFENSE: Budget-based melee stab when close enough ===
310
+ // Tier 2 upgrade: fits the highest-damage stab into the available time window
311
+ // before needing to shield. Also stabs from farther when enemy is casting/channeling
312
+ // (they move at 0.5 or 0 speed, so the cast delay doesn't let them dodge).
313
+ const effectiveStabRange =
314
+ enemy.state === 'casting' || enemy.state === 'channeling'
315
+ ? stabRangeVsCasting
316
+ : stabRange;
317
+ // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
318
+ const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
319
+ const hasNearbyLethalMissile = state.projectiles.some(
320
+ (p) =>
321
+ !myProjectileIds.has(p.id) &&
322
+ p.damage >= state.health &&
323
+ distanceTo(state.position, p.position) / p.speed <
324
+ MAX_CAST_BUDGET + GCD_DURATION,
325
+ );
326
+ if (
327
+ state.state === 'idle' &&
328
+ distance < effectiveStabRange &&
329
+ !hasNearbyLethalMissile
330
+ )
331
+ {
332
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
333
+
334
+ // Account for enemy's active cast — their missile will arrive after cast completes + flight time
335
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
336
+ {
337
+ const remainingEnemyCast =
338
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
339
+ const estimatedFlightTime = distance / flightTimeDivisor;
340
+ nextThreatTime = Math.min(
341
+ nextThreatTime,
342
+ remainingEnemyCast + estimatedFlightTime,
343
+ );
344
+ }
345
+
346
+ const safeBudget =
347
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
348
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
349
+ const enemyHP = Math.ceil(enemy.health);
350
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
351
+ let config = fitMissileToBudget(budgetTicks, distance, {
352
+ minTurnRate,
353
+ maxDamage: enemyHP,
354
+ lastMissileConfig: state.lastMissileConfig,
355
+ });
356
+
357
+ // If budget too small for useful stab, fire bigger and accept the hit
358
+ // Only accept small hits (< 25% HP) — don't trade away large chunks of health
359
+ // Never accept when enemy is actively casting (their missile WILL arrive during our long cast)
360
+ const enemyCasting =
361
+ enemy.state === 'casting' && enemy.castingSpell === 'missile';
362
+ if (
363
+ (!config || config.damage < minDmg) &&
364
+ !enemyCasting &&
365
+ (!urgentThreat ||
366
+ urgentThreat.projectile.damage < state.health * acceptHitFraction)
367
+ )
368
+ {
369
+ config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
370
+ minTurnRate,
371
+ maxDamage: enemyHP,
372
+ lastMissileConfig: state.lastMissileConfig,
373
+ });
374
+ }
375
+
376
+ if (config)
377
+ {
378
+ return {
379
+ move,
380
+ startCast: {
381
+ spell: 'missile',
382
+ config,
383
+ missileAI: () => ({}),
384
+ direction: angleTo(state.position, enemy.position),
385
+ },
386
+ };
387
+ }
388
+ }
389
+
390
+ // === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
391
+ if (
392
+ state.state === 'idle' &&
393
+ urgentThreat &&
394
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
395
+ )
396
+ {
397
+ if (
398
+ urgentThreat.canBlockInTime &&
399
+ (state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
400
+ )
401
+ {
402
+ return {
403
+ move: {x: 0, y: 0},
404
+ startCast: {spell: 'shield'},
405
+ };
406
+ }
407
+ }
408
+
409
+ // === DEFENSE: Preemptive shield when enemy is casting a missile at close range ===
410
+ // At melee range, missiles arrive almost instantly after launch — too fast to react.
411
+ // Shield BEFORE the missile launches. Trigger when shield will finish BEFORE missile hits.
412
+ if (
413
+ state.state === 'idle' &&
414
+ distance < preemptiveShieldRange &&
415
+ enemy.state === 'casting' &&
416
+ enemy.castingSpell === 'missile'
417
+ )
418
+ {
419
+ const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
420
+ const estimatedFlightTicks = distance / flightTimeDivisor;
421
+ const ticksUntilHit = remainingCast + estimatedFlightTicks;
422
+ // Shield when hit is close enough that we MUST start now, but far enough that
423
+ // the shield finishes in time. The window: SHIELD_CAST_TIME <= ticksUntilHit < SHIELD_CAST_TIME+15
424
+ if (
425
+ ticksUntilHit >= SHIELD_CAST_TIME &&
426
+ ticksUntilHit < SHIELD_CAST_TIME + 15
427
+ )
428
+ {
429
+ return {
430
+ move: {x: 0, y: 0},
431
+ startCast: {spell: 'shield'},
432
+ };
433
+ }
434
+ }
435
+
436
+ return {move};
437
+ };