@vibemancer/core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +28 -28
  2. package/dist/{chunk-L7Z7OFXD.js → chunk-7VDJHO22.js} +3 -3
  3. package/dist/chunk-7VDJHO22.js.map +1 -0
  4. package/dist/index-browser.d.ts +1 -1
  5. package/dist/index-browser.js +1 -1
  6. package/dist/index.js +1 -1
  7. package/package.json +79 -78
  8. package/src/bots/berserker/01_Stormchaser.ts +457 -457
  9. package/src/bots/berserker/02_Stormcaller.ts +417 -417
  10. package/src/bots/berserker/03_Stormforger.ts +481 -481
  11. package/src/bots/caster/01_Flamecaller.ts +286 -286
  12. package/src/bots/caster/02_Pyromancer.ts +350 -350
  13. package/src/bots/caster/03_Infernalist.ts +492 -492
  14. package/src/bots/defensive/01_Turtle.ts +151 -151
  15. package/src/bots/defensive/02_Sentinel.ts +134 -134
  16. package/src/bots/defensive/03_Golem.ts +357 -357
  17. package/src/bots/duelist/01_Battlemage.ts +433 -433
  18. package/src/bots/duelist/02_Warmage.ts +438 -438
  19. package/src/bots/duelist/03_Archmage.ts +588 -588
  20. package/src/bots/homing/01_Bonemancer.ts +67 -67
  21. package/src/bots/homing/02_Lich.ts +356 -356
  22. package/src/bots/homing/03_Archlich.ts +220 -220
  23. package/src/bots/kiter/01_Spellspinner.ts +398 -398
  24. package/src/bots/kiter/02_Spellweaver.ts +378 -378
  25. package/src/bots/kiter/03_Spellbinder.ts +448 -448
  26. package/src/bots/melee/01_Shadowblade.ts +270 -270
  27. package/src/bots/melee/02_Nightblade.ts +437 -437
  28. package/src/bots/melee/03_Voidblade.ts +582 -582
  29. package/src/bots/sniper/01_Spellshot.ts +385 -385
  30. package/src/bots/sniper/02_Spelltracer.ts +441 -441
  31. package/src/bots/sniper/03_Spellseeker.ts +546 -546
  32. package/src/bots/standalone/Critter.ts +89 -89
  33. package/src/bots/standalone/Doombringer.ts +91 -91
  34. package/src/bots/standalone/Hogger.ts +228 -228
  35. package/src/bots/standalone/Rookie.ts +50 -50
  36. package/src/bots/standalone/TargetDummy.ts +21 -21
  37. package/src/bots/test/cheater.ts +405 -405
  38. package/src/bots/test/crasher.ts +81 -81
  39. package/src/engine/hooks-runtime.ts +394 -394
  40. package/src/engine/manual-match.ts +289 -289
  41. package/src/engine/missile-templates.ts +155 -155
  42. package/src/engine/physics.ts +143 -143
  43. package/src/engine/simulation.ts +828 -828
  44. package/src/engine/spells.ts +128 -128
  45. package/src/engine-version.ts +1 -1
  46. package/src/index.ts +23 -23
  47. package/src/rules.ts +254 -254
  48. package/src/types.ts +193 -193
  49. package/src/utils/index.ts +6 -6
  50. package/dist/chunk-L7Z7OFXD.js.map +0 -1
@@ -1,378 +1,378 @@
1
- import {WizardFunction, MissileAIFunction} from '../../types.js';
2
- import {angleTo} from '../../utils/angles.js';
3
- import {distanceTo} from '../../utils/distance.js';
4
- import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
- import {
6
- getThreats,
7
- getMostUrgentThreat,
8
- getDodgeDirectionForMovement,
9
- getBlinkToCenter,
10
- getBlinkToIncreaseDistance,
11
- fitMissileToBudget,
12
- MIN_BLINK_ESCAPE_DISTANCE,
13
- shouldForceShield,
14
- MAX_CAST_BUDGET,
15
- MIN_USEFUL_DAMAGE,
16
- } from '../shared.js';
17
- import {useParam} from '../../engine/params-runtime.js';
18
- const WALL_BUFFER = 60;
19
-
20
- const SHIELD_BUFFER = 3;
21
-
22
- /**
23
- * Standard homing missile AI.
24
- */
25
- const HomingAI: MissileAIFunction = ({worldState}) =>
26
- {
27
- const enemy = worldState.enemies[0];
28
- return {
29
- turnToward: enemy?.position,
30
- };
31
- };
32
-
33
- /**
34
- * Check if near wall.
35
- */
36
- function isNearWall(position: {x: number; y: number}): boolean
37
- {
38
- return (
39
- position.x < WALL_BUFFER ||
40
- position.x > ARENA_SIZE - WALL_BUFFER ||
41
- position.y < WALL_BUFFER ||
42
- position.y > ARENA_SIZE - WALL_BUFFER
43
- );
44
- }
45
-
46
- /**
47
- * Smart strafe with threat-aware dodging and wall avoidance.
48
- */
49
- function smartStrafe(
50
- position: {x: number; y: number},
51
- enemy: {position: {x: number; y: number}},
52
- dodgeDirection: {x: number; y: number} | null,
53
- tick: number,
54
- strafeCyclePeriod: number,
55
- strafeIntensity: number,
56
- ): {x: number; y: number}
57
- {
58
- const deltaX = enemy.position.x - position.x;
59
- const deltaY = enemy.position.y - position.y;
60
- const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
61
- if (normalizedDistance === 0) return {x: 0, y: 0};
62
-
63
- const strafeClockwise = {
64
- x: -deltaY / normalizedDistance,
65
- y: deltaX / normalizedDistance,
66
- };
67
- const strafeCounterClockwise = {
68
- x: deltaY / normalizedDistance,
69
- y: -deltaX / normalizedDistance,
70
- };
71
-
72
- let strafeDir: {x: number; y: number};
73
-
74
- if (dodgeDirection)
75
- {
76
- strafeDir = dodgeDirection;
77
- }
78
- else if (isNearWall(position))
79
- {
80
- const futureClockwise = {
81
- x: position.x + strafeClockwise.x * 100,
82
- y: position.y + strafeClockwise.y * 100,
83
- };
84
- const futureCounterClockwise = {
85
- x: position.x + strafeCounterClockwise.x * 100,
86
- y: position.y + strafeCounterClockwise.y * 100,
87
- };
88
- const scoreClockwise = Math.min(
89
- futureClockwise.x,
90
- ARENA_SIZE - futureClockwise.x,
91
- futureClockwise.y,
92
- ARENA_SIZE - futureClockwise.y,
93
- );
94
- const scoreCounterClockwise = Math.min(
95
- futureCounterClockwise.x,
96
- ARENA_SIZE - futureCounterClockwise.x,
97
- futureCounterClockwise.y,
98
- ARENA_SIZE - futureCounterClockwise.y,
99
- );
100
- strafeDir =
101
- scoreClockwise > scoreCounterClockwise
102
- ? strafeClockwise
103
- : strafeCounterClockwise;
104
- }
105
- else
106
- {
107
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
108
- strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
109
- }
110
-
111
- const intensity = dodgeDirection ? 100 : strafeIntensity;
112
- return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
113
- }
114
-
115
- /**
116
- * Bot: Spellweaver
117
- *
118
- * BEHAVIOR: Enhanced Spellspinner with adaptive missile fitting. Same medium-range
119
- * kiting playstyle — maintains distance, strafes heavily — but uses fitMissileToBudget
120
- * to maximize damage within safe attack windows. Always uses homing missiles since
121
- * kiting means enemies are always moving. More sophisticated than Spellspinner's
122
- * fixed damage/speed/turnRate configuration.
123
- *
124
- * NAMING RATIONALE: A weaver creates intricate patterns from raw thread. Where the
125
- * Spellspinner produces raw threads of magic (fixed missiles), the Spellweaver
126
- * combines them into optimized patterns (adaptive fitting). The name suggests
127
- * craftsmanship and sophistication — the same kiting web, but deliberately woven
128
- * rather than chaotically spun.
129
- *
130
- * PROGRESSION LINE: Spellspinner → Spellweaver → Spellbinder
131
- * - Spellspinner (tier 1): Fixed homing missiles, constant strafe, basic defense
132
- * - Spellweaver (tier 2): + adaptive missile fitting, more sophisticated patterns
133
- * - Spellbinder (tier 3): Future — inescapable web, perfect distance control
134
- *
135
- * TIER: 2 (enhanced Spellspinner)
136
- */
137
- export const Spellweaver: WizardFunction = ({state}) =>
138
- {
139
- const targetDistance = useParam('targetDistance', 322, {
140
- range: 150,
141
- min: 0,
142
- });
143
- const dangerDistance = useParam('dangerDistance', 272, {
144
- range: 125,
145
- min: 0,
146
- });
147
- const minTurnRate = useParam('minTurnRate', 0.2, {range: 1.5, steps: 5});
148
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 296, {
149
- range: 75,
150
- min: 80,
151
- max: 300,
152
- });
153
- const strafeIntensity = useParam('strafeIntensity', 42, {
154
- range: 40,
155
- min: 30,
156
- max: 100,
157
- steps: 7,
158
- });
159
- const approachSpeed = useParam('approachSpeed', 68, {
160
- range: 30,
161
- min: 10,
162
- max: 70,
163
- steps: 7,
164
- });
165
- const distanceDeadZone = useParam('distanceDeadZone', 12, {
166
- range: 30,
167
- min: 5,
168
- max: 80,
169
- steps: 7,
170
- });
171
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
172
- range: 75,
173
- min: 50,
174
- max: 300,
175
- });
176
- const flightTimeDivisor = useParam('flightTimeDivisor', 2, {
177
- range: 4,
178
- min: 2,
179
- max: 12,
180
- steps: 5,
181
- });
182
-
183
- const enemy = state.enemies[0];
184
- if (!enemy)
185
- {
186
- return {move: {x: 0, y: 0}};
187
- }
188
-
189
- const distance = distanceTo(state.position, enemy.position);
190
-
191
- // Analyze threats using simulation
192
- const threats = getThreats(state);
193
- const urgentThreat = getMostUrgentThreat(threats);
194
-
195
- // Smart strafe
196
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
197
- const strafe = smartStrafe(
198
- state.position,
199
- enemy,
200
- dodgeDirection,
201
- state.tick,
202
- strafeCyclePeriod,
203
- strafeIntensity,
204
- );
205
-
206
- // Distance management (only when not dodging)
207
- let moveX = strafe.x;
208
- let moveY = strafe.y;
209
-
210
- if (!dodgeDirection)
211
- {
212
- const deltaX = enemy.position.x - state.position.x;
213
- const deltaY = enemy.position.y - state.position.y;
214
- const normalizedDistance = distance > 0 ? distance : 1;
215
-
216
- if (distance > targetDistance + distanceDeadZone)
217
- {
218
- moveX += (deltaX / normalizedDistance) * approachSpeed;
219
- moveY += (deltaY / normalizedDistance) * approachSpeed;
220
- }
221
- else if (distance < targetDistance - distanceDeadZone)
222
- {
223
- let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
224
- let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
225
- if (state.position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
226
- if (state.position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0)
227
- retreatX = 0;
228
- if (state.position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
229
- if (state.position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0)
230
- retreatY = 0;
231
- moveX += retreatX;
232
- moveY += retreatY;
233
- }
234
- }
235
-
236
- const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
237
- if (magnitude > 100)
238
- {
239
- moveX = (moveX / magnitude) * 100;
240
- moveY = (moveY / magnitude) * 100;
241
- }
242
-
243
- const move = {x: moveX, y: moveY};
244
-
245
- // === DEFENSE: Handle channeling ===
246
- if (state.state === 'channeling')
247
- {
248
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
249
- {
250
- return {move, cancel: true};
251
- }
252
- return {move: {x: 0, y: 0}};
253
- }
254
-
255
- // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
256
- // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
257
- if (
258
- state.state === 'casting' &&
259
- state.castingSpell === 'missile' &&
260
- urgentThreat &&
261
- urgentThreat.projectile.damage >= state.health
262
- )
263
- {
264
- const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
265
- if (
266
- urgentThreat.ticksToImpact <=
267
- remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
268
- )
269
- {
270
- return {move, cancel: true};
271
- }
272
- }
273
-
274
- // === DEFENSE: Shield undodgeable or high-damage homing threats ===
275
- if (
276
- state.state === 'idle' &&
277
- urgentThreat &&
278
- (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
279
- )
280
- {
281
- if (urgentThreat.canBlockInTime)
282
- {
283
- if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
284
- {
285
- return {
286
- move: {x: 0, y: 0},
287
- startCast: {spell: 'shield'},
288
- };
289
- }
290
- }
291
- else if (state.blinkCooldown === 0)
292
- {
293
- return {
294
- move: {x: 0, y: 0},
295
- startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
296
- };
297
- }
298
- }
299
-
300
- // === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
301
- if (
302
- state.state === 'idle' &&
303
- distance < dangerDistance &&
304
- state.blinkCooldown === 0 &&
305
- (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
306
- )
307
- {
308
- const blinkTarget = getBlinkToIncreaseDistance(
309
- state.position,
310
- enemy.position,
311
- state.projectiles,
312
- MIN_BLINK_ESCAPE_DISTANCE,
313
- );
314
- if (blinkTarget)
315
- {
316
- return {
317
- move: {x: 0, y: 0},
318
- startCast: {spell: 'blink', target: blinkTarget},
319
- };
320
- }
321
- }
322
-
323
- // === OFFENSE: Adaptive missile fitting ===
324
- // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
325
- const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
326
- const hasNearbyLethalMissile = state.projectiles.some(
327
- (p) =>
328
- !myProjectileIds.has(p.id) &&
329
- p.damage >= state.health &&
330
- distanceTo(state.position, p.position) / p.speed <
331
- MAX_CAST_BUDGET + GCD_DURATION,
332
- );
333
- if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
334
- {
335
- let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
336
-
337
- // Account for enemy's active cast
338
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
339
- {
340
- const remainingEnemyCast =
341
- (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
342
- const estimatedFlightTime = distance / flightTimeDivisor;
343
- nextThreatTime = Math.min(
344
- nextThreatTime,
345
- remainingEnemyCast + estimatedFlightTime,
346
- );
347
- }
348
- const safeBudget =
349
- nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
350
- const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
351
-
352
- // Spellweaver always uses homing (kiting means enemies are always moving)
353
- const enemyHP = Math.ceil(enemy.health);
354
- const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
355
- const config = fitMissileToBudget(budgetTicks, distance, {
356
- minTurnRate,
357
- maxDamage: enemyHP,
358
- lastMissileConfig: state.lastMissileConfig,
359
- });
360
-
361
- if (config && config.damage >= minDmg)
362
- {
363
- return {
364
- move,
365
- startCast: {
366
- spell: 'missile',
367
- config,
368
- missileAI: HomingAI,
369
- direction: angleTo(state.position, enemy.position),
370
- },
371
- };
372
- }
373
-
374
- // Budget too small for useful missile — wait for threat to pass, then fire
375
- }
376
-
377
- return {move};
378
- };
1
+ import {WizardFunction, MissileAIFunction} from '../../types.js';
2
+ import {angleTo} from '../../utils/angles.js';
3
+ import {distanceTo} from '../../utils/distance.js';
4
+ import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
5
+ import {
6
+ getThreats,
7
+ getMostUrgentThreat,
8
+ getDodgeDirectionForMovement,
9
+ getBlinkToCenter,
10
+ getBlinkToIncreaseDistance,
11
+ fitMissileToBudget,
12
+ MIN_BLINK_ESCAPE_DISTANCE,
13
+ shouldForceShield,
14
+ MAX_CAST_BUDGET,
15
+ MIN_USEFUL_DAMAGE,
16
+ } from '../shared.js';
17
+ import {useParam} from '../../engine/params-runtime.js';
18
+ const WALL_BUFFER = 60;
19
+
20
+ const SHIELD_BUFFER = 3;
21
+
22
+ /**
23
+ * Standard homing missile AI.
24
+ */
25
+ const HomingAI: MissileAIFunction = ({worldState}) =>
26
+ {
27
+ const enemy = worldState.enemies[0];
28
+ return {
29
+ turnToward: enemy?.position,
30
+ };
31
+ };
32
+
33
+ /**
34
+ * Check if near wall.
35
+ */
36
+ function isNearWall(position: {x: number; y: number}): boolean
37
+ {
38
+ return (
39
+ position.x < WALL_BUFFER ||
40
+ position.x > ARENA_SIZE - WALL_BUFFER ||
41
+ position.y < WALL_BUFFER ||
42
+ position.y > ARENA_SIZE - WALL_BUFFER
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Smart strafe with threat-aware dodging and wall avoidance.
48
+ */
49
+ function smartStrafe(
50
+ position: {x: number; y: number},
51
+ enemy: {position: {x: number; y: number}},
52
+ dodgeDirection: {x: number; y: number} | null,
53
+ tick: number,
54
+ strafeCyclePeriod: number,
55
+ strafeIntensity: number,
56
+ ): {x: number; y: number}
57
+ {
58
+ const deltaX = enemy.position.x - position.x;
59
+ const deltaY = enemy.position.y - position.y;
60
+ const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
61
+ if (normalizedDistance === 0) return {x: 0, y: 0};
62
+
63
+ const strafeClockwise = {
64
+ x: -deltaY / normalizedDistance,
65
+ y: deltaX / normalizedDistance,
66
+ };
67
+ const strafeCounterClockwise = {
68
+ x: deltaY / normalizedDistance,
69
+ y: -deltaX / normalizedDistance,
70
+ };
71
+
72
+ let strafeDir: {x: number; y: number};
73
+
74
+ if (dodgeDirection)
75
+ {
76
+ strafeDir = dodgeDirection;
77
+ }
78
+ else if (isNearWall(position))
79
+ {
80
+ const futureClockwise = {
81
+ x: position.x + strafeClockwise.x * 100,
82
+ y: position.y + strafeClockwise.y * 100,
83
+ };
84
+ const futureCounterClockwise = {
85
+ x: position.x + strafeCounterClockwise.x * 100,
86
+ y: position.y + strafeCounterClockwise.y * 100,
87
+ };
88
+ const scoreClockwise = Math.min(
89
+ futureClockwise.x,
90
+ ARENA_SIZE - futureClockwise.x,
91
+ futureClockwise.y,
92
+ ARENA_SIZE - futureClockwise.y,
93
+ );
94
+ const scoreCounterClockwise = Math.min(
95
+ futureCounterClockwise.x,
96
+ ARENA_SIZE - futureCounterClockwise.x,
97
+ futureCounterClockwise.y,
98
+ ARENA_SIZE - futureCounterClockwise.y,
99
+ );
100
+ strafeDir =
101
+ scoreClockwise > scoreCounterClockwise
102
+ ? strafeClockwise
103
+ : strafeCounterClockwise;
104
+ }
105
+ else
106
+ {
107
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
108
+ strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
109
+ }
110
+
111
+ const intensity = dodgeDirection ? 100 : strafeIntensity;
112
+ return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
113
+ }
114
+
115
+ /**
116
+ * Bot: Spellweaver
117
+ *
118
+ * BEHAVIOR: Enhanced Spellspinner with adaptive missile fitting. Same medium-range
119
+ * kiting playstyle — maintains distance, strafes heavily — but uses fitMissileToBudget
120
+ * to maximize damage within safe attack windows. Always uses homing missiles since
121
+ * kiting means enemies are always moving. More sophisticated than Spellspinner's
122
+ * fixed damage/speed/turnRate configuration.
123
+ *
124
+ * NAMING RATIONALE: A weaver creates intricate patterns from raw thread. Where the
125
+ * Spellspinner produces raw threads of magic (fixed missiles), the Spellweaver
126
+ * combines them into optimized patterns (adaptive fitting). The name suggests
127
+ * craftsmanship and sophistication — the same kiting web, but deliberately woven
128
+ * rather than chaotically spun.
129
+ *
130
+ * PROGRESSION LINE: Spellspinner → Spellweaver → Spellbinder
131
+ * - Spellspinner (tier 1): Fixed homing missiles, constant strafe, basic defense
132
+ * - Spellweaver (tier 2): + adaptive missile fitting, more sophisticated patterns
133
+ * - Spellbinder (tier 3): Future — inescapable web, perfect distance control
134
+ *
135
+ * TIER: 2 (enhanced Spellspinner)
136
+ */
137
+ export const Spellweaver: WizardFunction = ({state}) =>
138
+ {
139
+ const targetDistance = useParam('targetDistance', 322, {
140
+ range: 150,
141
+ min: 0,
142
+ });
143
+ const dangerDistance = useParam('dangerDistance', 272, {
144
+ range: 125,
145
+ min: 0,
146
+ });
147
+ const minTurnRate = useParam('minTurnRate', 0.2, {range: 1.5, steps: 5});
148
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 296, {
149
+ range: 75,
150
+ min: 80,
151
+ max: 300,
152
+ });
153
+ const strafeIntensity = useParam('strafeIntensity', 42, {
154
+ range: 40,
155
+ min: 30,
156
+ max: 100,
157
+ steps: 7,
158
+ });
159
+ const approachSpeed = useParam('approachSpeed', 68, {
160
+ range: 30,
161
+ min: 10,
162
+ max: 70,
163
+ steps: 7,
164
+ });
165
+ const distanceDeadZone = useParam('distanceDeadZone', 12, {
166
+ range: 30,
167
+ min: 5,
168
+ max: 80,
169
+ steps: 7,
170
+ });
171
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
172
+ range: 75,
173
+ min: 50,
174
+ max: 300,
175
+ });
176
+ const flightTimeDivisor = useParam('flightTimeDivisor', 2, {
177
+ range: 4,
178
+ min: 2,
179
+ max: 12,
180
+ steps: 5,
181
+ });
182
+
183
+ const enemy = state.enemies[0];
184
+ if (!enemy)
185
+ {
186
+ return {move: {x: 0, y: 0}};
187
+ }
188
+
189
+ const distance = distanceTo(state.position, enemy.position);
190
+
191
+ // Analyze threats using simulation
192
+ const threats = getThreats(state);
193
+ const urgentThreat = getMostUrgentThreat(threats);
194
+
195
+ // Smart strafe
196
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
197
+ const strafe = smartStrafe(
198
+ state.position,
199
+ enemy,
200
+ dodgeDirection,
201
+ state.tick,
202
+ strafeCyclePeriod,
203
+ strafeIntensity,
204
+ );
205
+
206
+ // Distance management (only when not dodging)
207
+ let moveX = strafe.x;
208
+ let moveY = strafe.y;
209
+
210
+ if (!dodgeDirection)
211
+ {
212
+ const deltaX = enemy.position.x - state.position.x;
213
+ const deltaY = enemy.position.y - state.position.y;
214
+ const normalizedDistance = distance > 0 ? distance : 1;
215
+
216
+ if (distance > targetDistance + distanceDeadZone)
217
+ {
218
+ moveX += (deltaX / normalizedDistance) * approachSpeed;
219
+ moveY += (deltaY / normalizedDistance) * approachSpeed;
220
+ }
221
+ else if (distance < targetDistance - distanceDeadZone)
222
+ {
223
+ let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
224
+ let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
225
+ if (state.position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
226
+ if (state.position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0)
227
+ retreatX = 0;
228
+ if (state.position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
229
+ if (state.position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0)
230
+ retreatY = 0;
231
+ moveX += retreatX;
232
+ moveY += retreatY;
233
+ }
234
+ }
235
+
236
+ const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
237
+ if (magnitude > 100)
238
+ {
239
+ moveX = (moveX / magnitude) * 100;
240
+ moveY = (moveY / magnitude) * 100;
241
+ }
242
+
243
+ const move = {x: moveX, y: moveY};
244
+
245
+ // === DEFENSE: Handle channeling ===
246
+ if (state.state === 'channeling')
247
+ {
248
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
249
+ {
250
+ return {move, cancel: true};
251
+ }
252
+ return {move: {x: 0, y: 0}};
253
+ }
254
+
255
+ // === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
256
+ // DPS trade: we accepted the hit when we started casting. Only cancel to survive.
257
+ if (
258
+ state.state === 'casting' &&
259
+ state.castingSpell === 'missile' &&
260
+ urgentThreat &&
261
+ urgentThreat.projectile.damage >= state.health
262
+ )
263
+ {
264
+ const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
265
+ if (
266
+ urgentThreat.ticksToImpact <=
267
+ remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
268
+ )
269
+ {
270
+ return {move, cancel: true};
271
+ }
272
+ }
273
+
274
+ // === DEFENSE: Shield undodgeable or high-damage homing threats ===
275
+ if (
276
+ state.state === 'idle' &&
277
+ urgentThreat &&
278
+ (!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
279
+ )
280
+ {
281
+ if (urgentThreat.canBlockInTime)
282
+ {
283
+ if (urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
284
+ {
285
+ return {
286
+ move: {x: 0, y: 0},
287
+ startCast: {spell: 'shield'},
288
+ };
289
+ }
290
+ }
291
+ else if (state.blinkCooldown === 0)
292
+ {
293
+ return {
294
+ move: {x: 0, y: 0},
295
+ startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
296
+ };
297
+ }
298
+ }
299
+
300
+ // === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
301
+ if (
302
+ state.state === 'idle' &&
303
+ distance < dangerDistance &&
304
+ state.blinkCooldown === 0 &&
305
+ (!urgentThreat || urgentThreat.bestDodgeDirection !== null)
306
+ )
307
+ {
308
+ const blinkTarget = getBlinkToIncreaseDistance(
309
+ state.position,
310
+ enemy.position,
311
+ state.projectiles,
312
+ MIN_BLINK_ESCAPE_DISTANCE,
313
+ );
314
+ if (blinkTarget)
315
+ {
316
+ return {
317
+ move: {x: 0, y: 0},
318
+ startCast: {spell: 'blink', target: blinkTarget},
319
+ };
320
+ }
321
+ }
322
+
323
+ // === OFFENSE: Adaptive missile fitting ===
324
+ // Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
325
+ const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
326
+ const hasNearbyLethalMissile = state.projectiles.some(
327
+ (p) =>
328
+ !myProjectileIds.has(p.id) &&
329
+ p.damage >= state.health &&
330
+ distanceTo(state.position, p.position) / p.speed <
331
+ MAX_CAST_BUDGET + GCD_DURATION,
332
+ );
333
+ if (state.state === 'idle' && distance < 600 && !hasNearbyLethalMissile)
334
+ {
335
+ let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
336
+
337
+ // Account for enemy's active cast
338
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
339
+ {
340
+ const remainingEnemyCast =
341
+ (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
342
+ const estimatedFlightTime = distance / flightTimeDivisor;
343
+ nextThreatTime = Math.min(
344
+ nextThreatTime,
345
+ remainingEnemyCast + estimatedFlightTime,
346
+ );
347
+ }
348
+ const safeBudget =
349
+ nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
350
+ const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
351
+
352
+ // Spellweaver always uses homing (kiting means enemies are always moving)
353
+ const enemyHP = Math.ceil(enemy.health);
354
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
355
+ const config = fitMissileToBudget(budgetTicks, distance, {
356
+ minTurnRate,
357
+ maxDamage: enemyHP,
358
+ lastMissileConfig: state.lastMissileConfig,
359
+ });
360
+
361
+ if (config && config.damage >= minDmg)
362
+ {
363
+ return {
364
+ move,
365
+ startCast: {
366
+ spell: 'missile',
367
+ config,
368
+ missileAI: HomingAI,
369
+ direction: angleTo(state.position, enemy.position),
370
+ },
371
+ };
372
+ }
373
+
374
+ // Budget too small for useful missile — wait for threat to pass, then fire
375
+ }
376
+
377
+ return {move};
378
+ };