@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,220 +1,220 @@
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
- fitMissileToBudget,
12
- shouldForceShield,
13
- MAX_CAST_BUDGET,
14
- MIN_USEFUL_DAMAGE,
15
- getEnemyVulnerabilityTicks,
16
- } from '../shared.js';
17
- import {useParam} from '../../engine/params-runtime.js';
18
-
19
- const WALL_BUFFER = 60;
20
- const SHIELD_BUFFER = 3;
21
-
22
- const HomingAI: MissileAIFunction = ({worldState}) =>
23
- {
24
- const enemy = worldState.enemies[0];
25
- return {turnToward: enemy?.position};
26
- };
27
-
28
- const StraightAI: MissileAIFunction = () => ({});
29
-
30
- function isNearWall(position: {x: number; y: number}): boolean
31
- {
32
- return (
33
- position.x < WALL_BUFFER ||
34
- position.x > ARENA_SIZE - WALL_BUFFER ||
35
- position.y < WALL_BUFFER ||
36
- position.y > ARENA_SIZE - WALL_BUFFER
37
- );
38
- }
39
-
40
- function smartStrafe(
41
- position: {x: number; y: number},
42
- enemy: {position: {x: number; y: number}},
43
- dodgeDirection: {x: number; y: number} | null,
44
- tick: number,
45
- strafeCyclePeriod: number,
46
- strafeIntensity: number,
47
- ): {x: number; y: number}
48
- {
49
- const deltaX = enemy.position.x - position.x;
50
- const deltaY = enemy.position.y - position.y;
51
- const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
52
- if (normalizedDistance === 0) return {x: 0, y: 0};
53
-
54
- const cw = {x: -deltaY / normalizedDistance, y: deltaX / normalizedDistance};
55
- const ccw = {x: deltaY / normalizedDistance, y: -deltaX / normalizedDistance};
56
-
57
- let dir: {x: number; y: number};
58
-
59
- if (dodgeDirection)
60
- {
61
- dir = dodgeDirection;
62
- }
63
- else if (isNearWall(position))
64
- {
65
- const fCw = {x: position.x + cw.x * 100, y: position.y + cw.y * 100};
66
- const fCcw = {x: position.x + ccw.x * 100, y: position.y + ccw.y * 100};
67
- const sCw = Math.min(fCw.x, ARENA_SIZE - fCw.x, fCw.y, ARENA_SIZE - fCw.y);
68
- const sCcw = Math.min(fCcw.x, ARENA_SIZE - fCcw.x, fCcw.y, ARENA_SIZE - fCcw.y);
69
- dir = sCw > sCcw ? cw : ccw;
70
- }
71
- else
72
- {
73
- const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
74
- dir = cycle === 0 ? cw : ccw;
75
- }
76
-
77
- const intensity = dodgeDirection ? 100 : strafeIntensity;
78
- return {x: dir.x * intensity, y: dir.y * intensity};
79
- }
80
-
81
- /**
82
- * Bot: Archlich
83
- *
84
- * BEHAVIOR: Lich's proven core (mobile homing specialist) plus vulnerability
85
- * exploitation. Defense, movement, and standard offense are identical to Lich.
86
- * The T3 advantage: when the enemy is locked in GCD/cast, fires fast straight
87
- * punish missiles that land during the vulnerability window.
88
- *
89
- * PROGRESSION LINE: Bonemancer → Lich → Archlich
90
- * TIER: 3 (elite Homing line)
91
- */
92
- export const Archlich: WizardFunction = ({state}) =>
93
- {
94
- // Same params as Lich — Archlich is Lich + punish mode (disabled by default, optimizer tunes)
95
- const targetDistance = useParam('targetDistance', 737, {range: 175, min: 0});
96
- const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, steps: 5});
97
- const shieldHealthThreshold = useParam('shieldHealthThreshold', 99, {range: 23, min: 1, steps: 7});
98
- const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {range: 75, min: 80, max: 300});
99
- const strafeIntensity = useParam('strafeIntensity', 96, {range: 40, min: 20, max: 100, steps: 7});
100
- const approachSpeed = useParam('approachSpeed', 65, {range: 30, min: 10, max: 70, steps: 7});
101
- const distanceDeadZone = useParam('distanceDeadZone', 7, {range: 30, min: 5, max: 80, steps: 7});
102
- const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {range: 75, min: 50, max: 300});
103
- const blinkWindowMax = useParam('blinkWindowMax', 38, {range: 30, min: 15, max: 80, steps: 7});
104
- const flightTimeDivisor = useParam('flightTimeDivisor', 5.8, {range: 4, min: 2, max: 12, steps: 5});
105
- const punishMinVulnerability = useParam('punishMinVulnerability', 500, {range: 80, min: 30, steps: 7});
106
-
107
- const enemy = state.enemies[0];
108
- if (!enemy) return {move: {x: 0, y: 0}};
109
-
110
- const distance = distanceTo(state.position, enemy.position);
111
- const threats = getThreats(state);
112
- const urgentThreat = getMostUrgentThreat(threats);
113
- const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
114
- const strafe = smartStrafe(state.position, enemy, dodgeDirection, state.tick, strafeCyclePeriod, strafeIntensity);
115
-
116
- let moveX = strafe.x;
117
- let moveY = strafe.y;
118
- if (!dodgeDirection)
119
- {
120
- const dx = enemy.position.x - state.position.x;
121
- const dy = enemy.position.y - state.position.y;
122
- const nd = distance > 0 ? distance : 1;
123
- if (distance > targetDistance + distanceDeadZone)
124
- {
125
- moveX += (dx / nd) * approachSpeed;
126
- moveY += (dy / nd) * approachSpeed;
127
- }
128
- else if (distance < targetDistance - distanceDeadZone)
129
- {
130
- moveX -= (dx / nd) * approachSpeed;
131
- moveY -= (dy / nd) * approachSpeed;
132
- }
133
- }
134
- const mag = Math.sqrt(moveX * moveX + moveY * moveY);
135
- if (mag > 100)
136
- {
137
- moveX = (moveX / mag) * 100; moveY = (moveY / mag) * 100;
138
- }
139
- const move = {x: moveX, y: moveY};
140
-
141
- // === DEFENSE: identical to Lich ===
142
- if (state.state === 'channeling')
143
- {
144
- if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold) return {move, cancel: true};
145
- return {move: {x: 0, y: 0}};
146
- }
147
-
148
- if (state.state === 'casting' && state.castingSpell === 'missile' && urgentThreat && urgentThreat.projectile.damage >= state.health)
149
- {
150
- const rem = (state.castDuration ?? 0) - (state.castProgress ?? 0);
151
- if (urgentThreat.ticksToImpact <= rem + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER) return {move, cancel: true};
152
- }
153
-
154
- if (state.state === 'idle' && urgentThreat && state.blinkCooldown === 0 && urgentThreat.ticksToImpact >= 10 && urgentThreat.ticksToImpact <= blinkWindowMax)
155
- {
156
- return {move: {x: 0, y: 0}, startCast: {spell: 'blink', target: getBlinkToCenter(state.position)}};
157
- }
158
-
159
- if (state.state === 'idle' && urgentThreat)
160
- {
161
- const forceShield = shouldForceShield(urgentThreat);
162
- const undodgeable = !urgentThreat.bestDodgeDirection;
163
- const healthLow = state.health < shieldHealthThreshold;
164
- const healthCritical = state.health <= urgentThreat.projectile.damage * 2 + 1;
165
- if ((forceShield || (undodgeable && healthLow) || healthCritical) && urgentThreat.canBlockInTime && urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
166
- {
167
- return {move: {x: 0, y: 0}, startCast: {spell: 'shield'}};
168
- }
169
- }
170
-
171
- // === OFFENSE ===
172
- const myIds = new Set(state.myProjectiles.map((p) => p.id));
173
- const hasLethal = state.projectiles.some((p) => !myIds.has(p.id) && p.damage >= state.health && distanceTo(state.position, p.position) / p.speed < MAX_CAST_BUDGET + GCD_DURATION);
174
- if (state.state === 'idle' && !hasLethal)
175
- {
176
- const enemyHP = Math.ceil(enemy.health);
177
-
178
- // T3 PUNISH: fast straight missiles during vulnerability
179
- const vulnTicks = getEnemyVulnerabilityTicks(enemy);
180
- if (vulnTicks >= punishMinVulnerability && distance < 300)
181
- {
182
- const flight = distance / flightTimeDivisor;
183
- const pBudget = vulnTicks - flight;
184
- const ntt = urgentThreat?.ticksToImpact ?? Infinity;
185
- const sBudget = ntt - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
186
- const eBudget = Math.min(pBudget, sBudget, MAX_CAST_BUDGET);
187
- if (eBudget > 0)
188
- {
189
- const cfg = fitMissileToBudget(eBudget, distance, {minTurnRate: 0, maxDamage: enemyHP, lastMissileConfig: state.lastMissileConfig});
190
- if (cfg && cfg.damage >= MIN_USEFUL_DAMAGE)
191
- {
192
- const ct = getMissileCastTime(cfg, state.lastMissileConfig);
193
- if (ct + distance / cfg.speed < vulnTicks)
194
- {
195
- const aim = cfg.turnRate > 0 ? enemy.position : getLeadPosition(enemy.position, enemy.velocity, cfg.speed, state.position);
196
- return {move, startCast: {spell: 'missile', config: cfg, missileAI: cfg.turnRate > 0 ? HomingAI : StraightAI, direction: angleTo(state.position, aim)}};
197
- }
198
- }
199
- }
200
- }
201
-
202
- // STANDARD MODE: identical to Lich
203
- let ntt = urgentThreat?.ticksToImpact ?? Infinity;
204
- if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
205
- {
206
- const rem = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
207
- ntt = Math.min(ntt, rem + distance / flightTimeDivisor);
208
- }
209
- const sBudget = ntt - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
210
- const budget = Math.min(sBudget, MAX_CAST_BUDGET);
211
- const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
212
- const cfg = fitMissileToBudget(budget, distance, {minTurnRate, lastMissileConfig: state.lastMissileConfig});
213
- if (cfg && cfg.damage >= minDmg)
214
- {
215
- return {move, startCast: {spell: 'missile', config: cfg, missileAI: HomingAI, direction: angleTo(state.position, enemy.position)}};
216
- }
217
- }
218
-
219
- return {move};
220
- };
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
+ fitMissileToBudget,
12
+ shouldForceShield,
13
+ MAX_CAST_BUDGET,
14
+ MIN_USEFUL_DAMAGE,
15
+ getEnemyVulnerabilityTicks,
16
+ } from '../shared.js';
17
+ import {useParam} from '../../engine/params-runtime.js';
18
+
19
+ const WALL_BUFFER = 60;
20
+ const SHIELD_BUFFER = 3;
21
+
22
+ const HomingAI: MissileAIFunction = ({worldState}) =>
23
+ {
24
+ const enemy = worldState.enemies[0];
25
+ return {turnToward: enemy?.position};
26
+ };
27
+
28
+ const StraightAI: MissileAIFunction = () => ({});
29
+
30
+ function isNearWall(position: {x: number; y: number}): boolean
31
+ {
32
+ return (
33
+ position.x < WALL_BUFFER ||
34
+ position.x > ARENA_SIZE - WALL_BUFFER ||
35
+ position.y < WALL_BUFFER ||
36
+ position.y > ARENA_SIZE - WALL_BUFFER
37
+ );
38
+ }
39
+
40
+ function smartStrafe(
41
+ position: {x: number; y: number},
42
+ enemy: {position: {x: number; y: number}},
43
+ dodgeDirection: {x: number; y: number} | null,
44
+ tick: number,
45
+ strafeCyclePeriod: number,
46
+ strafeIntensity: number,
47
+ ): {x: number; y: number}
48
+ {
49
+ const deltaX = enemy.position.x - position.x;
50
+ const deltaY = enemy.position.y - position.y;
51
+ const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
52
+ if (normalizedDistance === 0) return {x: 0, y: 0};
53
+
54
+ const cw = {x: -deltaY / normalizedDistance, y: deltaX / normalizedDistance};
55
+ const ccw = {x: deltaY / normalizedDistance, y: -deltaX / normalizedDistance};
56
+
57
+ let dir: {x: number; y: number};
58
+
59
+ if (dodgeDirection)
60
+ {
61
+ dir = dodgeDirection;
62
+ }
63
+ else if (isNearWall(position))
64
+ {
65
+ const fCw = {x: position.x + cw.x * 100, y: position.y + cw.y * 100};
66
+ const fCcw = {x: position.x + ccw.x * 100, y: position.y + ccw.y * 100};
67
+ const sCw = Math.min(fCw.x, ARENA_SIZE - fCw.x, fCw.y, ARENA_SIZE - fCw.y);
68
+ const sCcw = Math.min(fCcw.x, ARENA_SIZE - fCcw.x, fCcw.y, ARENA_SIZE - fCcw.y);
69
+ dir = sCw > sCcw ? cw : ccw;
70
+ }
71
+ else
72
+ {
73
+ const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
74
+ dir = cycle === 0 ? cw : ccw;
75
+ }
76
+
77
+ const intensity = dodgeDirection ? 100 : strafeIntensity;
78
+ return {x: dir.x * intensity, y: dir.y * intensity};
79
+ }
80
+
81
+ /**
82
+ * Bot: Archlich
83
+ *
84
+ * BEHAVIOR: Lich's proven core (mobile homing specialist) plus vulnerability
85
+ * exploitation. Defense, movement, and standard offense are identical to Lich.
86
+ * The T3 advantage: when the enemy is locked in GCD/cast, fires fast straight
87
+ * punish missiles that land during the vulnerability window.
88
+ *
89
+ * PROGRESSION LINE: Bonemancer → Lich → Archlich
90
+ * TIER: 3 (elite Homing line)
91
+ */
92
+ export const Archlich: WizardFunction = ({state}) =>
93
+ {
94
+ // Same params as Lich — Archlich is Lich + punish mode (disabled by default, optimizer tunes)
95
+ const targetDistance = useParam('targetDistance', 737, {range: 175, min: 0});
96
+ const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, steps: 5});
97
+ const shieldHealthThreshold = useParam('shieldHealthThreshold', 99, {range: 23, min: 1, steps: 7});
98
+ const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {range: 75, min: 80, max: 300});
99
+ const strafeIntensity = useParam('strafeIntensity', 96, {range: 40, min: 20, max: 100, steps: 7});
100
+ const approachSpeed = useParam('approachSpeed', 65, {range: 30, min: 10, max: 70, steps: 7});
101
+ const distanceDeadZone = useParam('distanceDeadZone', 7, {range: 30, min: 5, max: 80, steps: 7});
102
+ const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {range: 75, min: 50, max: 300});
103
+ const blinkWindowMax = useParam('blinkWindowMax', 38, {range: 30, min: 15, max: 80, steps: 7});
104
+ const flightTimeDivisor = useParam('flightTimeDivisor', 5.8, {range: 4, min: 2, max: 12, steps: 5});
105
+ const punishMinVulnerability = useParam('punishMinVulnerability', 500, {range: 80, min: 30, steps: 7});
106
+
107
+ const enemy = state.enemies[0];
108
+ if (!enemy) return {move: {x: 0, y: 0}};
109
+
110
+ const distance = distanceTo(state.position, enemy.position);
111
+ const threats = getThreats(state);
112
+ const urgentThreat = getMostUrgentThreat(threats);
113
+ const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
114
+ const strafe = smartStrafe(state.position, enemy, dodgeDirection, state.tick, strafeCyclePeriod, strafeIntensity);
115
+
116
+ let moveX = strafe.x;
117
+ let moveY = strafe.y;
118
+ if (!dodgeDirection)
119
+ {
120
+ const dx = enemy.position.x - state.position.x;
121
+ const dy = enemy.position.y - state.position.y;
122
+ const nd = distance > 0 ? distance : 1;
123
+ if (distance > targetDistance + distanceDeadZone)
124
+ {
125
+ moveX += (dx / nd) * approachSpeed;
126
+ moveY += (dy / nd) * approachSpeed;
127
+ }
128
+ else if (distance < targetDistance - distanceDeadZone)
129
+ {
130
+ moveX -= (dx / nd) * approachSpeed;
131
+ moveY -= (dy / nd) * approachSpeed;
132
+ }
133
+ }
134
+ const mag = Math.sqrt(moveX * moveX + moveY * moveY);
135
+ if (mag > 100)
136
+ {
137
+ moveX = (moveX / mag) * 100; moveY = (moveY / mag) * 100;
138
+ }
139
+ const move = {x: moveX, y: moveY};
140
+
141
+ // === DEFENSE: identical to Lich ===
142
+ if (state.state === 'channeling')
143
+ {
144
+ if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold) return {move, cancel: true};
145
+ return {move: {x: 0, y: 0}};
146
+ }
147
+
148
+ if (state.state === 'casting' && state.castingSpell === 'missile' && urgentThreat && urgentThreat.projectile.damage >= state.health)
149
+ {
150
+ const rem = (state.castDuration ?? 0) - (state.castProgress ?? 0);
151
+ if (urgentThreat.ticksToImpact <= rem + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER) return {move, cancel: true};
152
+ }
153
+
154
+ if (state.state === 'idle' && urgentThreat && state.blinkCooldown === 0 && urgentThreat.ticksToImpact >= 10 && urgentThreat.ticksToImpact <= blinkWindowMax)
155
+ {
156
+ return {move: {x: 0, y: 0}, startCast: {spell: 'blink', target: getBlinkToCenter(state.position)}};
157
+ }
158
+
159
+ if (state.state === 'idle' && urgentThreat)
160
+ {
161
+ const forceShield = shouldForceShield(urgentThreat);
162
+ const undodgeable = !urgentThreat.bestDodgeDirection;
163
+ const healthLow = state.health < shieldHealthThreshold;
164
+ const healthCritical = state.health <= urgentThreat.projectile.damage * 2 + 1;
165
+ if ((forceShield || (undodgeable && healthLow) || healthCritical) && urgentThreat.canBlockInTime && urgentThreat.ticksToStartShield <= SHIELD_BUFFER)
166
+ {
167
+ return {move: {x: 0, y: 0}, startCast: {spell: 'shield'}};
168
+ }
169
+ }
170
+
171
+ // === OFFENSE ===
172
+ const myIds = new Set(state.myProjectiles.map((p) => p.id));
173
+ const hasLethal = state.projectiles.some((p) => !myIds.has(p.id) && p.damage >= state.health && distanceTo(state.position, p.position) / p.speed < MAX_CAST_BUDGET + GCD_DURATION);
174
+ if (state.state === 'idle' && !hasLethal)
175
+ {
176
+ const enemyHP = Math.ceil(enemy.health);
177
+
178
+ // T3 PUNISH: fast straight missiles during vulnerability
179
+ const vulnTicks = getEnemyVulnerabilityTicks(enemy);
180
+ if (vulnTicks >= punishMinVulnerability && distance < 300)
181
+ {
182
+ const flight = distance / flightTimeDivisor;
183
+ const pBudget = vulnTicks - flight;
184
+ const ntt = urgentThreat?.ticksToImpact ?? Infinity;
185
+ const sBudget = ntt - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
186
+ const eBudget = Math.min(pBudget, sBudget, MAX_CAST_BUDGET);
187
+ if (eBudget > 0)
188
+ {
189
+ const cfg = fitMissileToBudget(eBudget, distance, {minTurnRate: 0, maxDamage: enemyHP, lastMissileConfig: state.lastMissileConfig});
190
+ if (cfg && cfg.damage >= MIN_USEFUL_DAMAGE)
191
+ {
192
+ const ct = getMissileCastTime(cfg, state.lastMissileConfig);
193
+ if (ct + distance / cfg.speed < vulnTicks)
194
+ {
195
+ const aim = cfg.turnRate > 0 ? enemy.position : getLeadPosition(enemy.position, enemy.velocity, cfg.speed, state.position);
196
+ return {move, startCast: {spell: 'missile', config: cfg, missileAI: cfg.turnRate > 0 ? HomingAI : StraightAI, direction: angleTo(state.position, aim)}};
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ // STANDARD MODE: identical to Lich
203
+ let ntt = urgentThreat?.ticksToImpact ?? Infinity;
204
+ if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
205
+ {
206
+ const rem = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
207
+ ntt = Math.min(ntt, rem + distance / flightTimeDivisor);
208
+ }
209
+ const sBudget = ntt - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
210
+ const budget = Math.min(sBudget, MAX_CAST_BUDGET);
211
+ const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
212
+ const cfg = fitMissileToBudget(budget, distance, {minTurnRate, lastMissileConfig: state.lastMissileConfig});
213
+ if (cfg && cfg.damage >= minDmg)
214
+ {
215
+ return {move, startCast: {spell: 'missile', config: cfg, missileAI: HomingAI, direction: angleTo(state.position, enemy.position)}};
216
+ }
217
+ }
218
+
219
+ return {move};
220
+ };