@vibemancer/core 1.0.10 → 1.0.11

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.
@@ -1,371 +1,379 @@
1
- /**
2
- * VIBEMANCER - COMBAT UTILITIES
3
- *
4
- * Utilities for combat calculations.
5
- */
6
-
7
- import {Position, Velocity, MissileConfig} from '../types.js';
8
- import {
9
- calculateMissileCastTime,
10
- calculateWarmupMultiplier,
11
- validateMissileConfig,
12
- TICKS_PER_SECOND,
13
- MOVEMENT_SPEED,
14
- CASTING_MOVEMENT_MULT,
15
- WIZARD_HEALTH,
16
- MISSILE_BASE_CAST,
17
- MISSILE_DAMAGE_SCALE,
18
- MISSILE_DAMAGE_POWER,
19
- MISSILE_HOMING_COEFF,
20
- MISSILE_TURN_DURATION_COEFF,
21
- MISSILE_SPEED_DURATION_COEFF,
22
- MISSILE_SPEED_DURATION_BASELINE,
23
- MISSILE_MIN_CAST_TIME,
24
- MISSILE_MIN_DURATION,
25
- MISSILE_MIN_DAMAGE,
26
- WIZARD_RADIUS,
27
- effectiveTurnRateCost,
28
- } from '../rules.js';
29
-
30
- /**
31
- * Get cast time in ticks for a missile configuration.
32
- * Applies the same clamps/validation as the engine before calculating,
33
- * so the result matches the actual cast time that will be used in-game.
34
- *
35
- * If lastMissileConfig is provided, includes warmup multiplier.
36
- * Pass undefined for first cast (full warmup) or null for base time only.
37
- */
38
- export function getMissileCastTime(config: MissileConfig, lastMissileConfig?: MissileConfig | null): number
39
- {
40
- const validated = validateMissileConfig(config);
41
- return Math.ceil(calculateMissileCastTime(validated, lastMissileConfig) * TICKS_PER_SECOND);
42
- }
43
-
44
- /**
45
- * Calculate the position to aim at to hit a moving target.
46
- * Returns the intercept point where a missile would hit the target.
47
- *
48
- * @param targetPos - Current target position
49
- * @param targetVel - Target velocity (units per tick)
50
- * @param missileSpeed - Missile speed (units per tick)
51
- * @param myPos - Shooter position
52
- * @returns The position to aim at
53
- */
54
- export function getLeadPosition(
55
- targetPos: Position,
56
- targetVel: Velocity,
57
- missileSpeed: number,
58
- myPos: Position,
59
- ): Position
60
- {
61
- // Vector from shooter to target
62
- const dx = targetPos.x - myPos.x;
63
- const dy = targetPos.y - myPos.y;
64
-
65
- // Target velocity
66
- const vx = targetVel.x;
67
- const vy = targetVel.y;
68
-
69
- // Quadratic coefficients for intercept time
70
- // |target + velocity * t - shooter|^2 = (missileSpeed * t)^2
71
- // Expands to: (vx^2 + vy^2 - speed^2) * t^2 + 2*(vx*dx + vy*dy) * t + (dx^2 + dy^2) = 0
72
- const a = vx * vx + vy * vy - missileSpeed * missileSpeed;
73
- const b = 2 * (vx * dx + vy * dy);
74
- const c = dx * dx + dy * dy;
75
-
76
- let t: number | null = null;
77
-
78
- // Handle the linear case (|velocity| === missileSpeed)
79
- if (Math.abs(a) < 1e-10)
80
- {
81
- if (Math.abs(b) > 1e-10)
82
- {
83
- const linearT = -c / b;
84
- if (linearT > 0)
85
- {
86
- t = linearT;
87
- }
88
- }
89
- }
90
- else
91
- {
92
- // Quadratic case
93
- const discriminant = b * b - 4 * a * c;
94
-
95
- if (discriminant >= 0)
96
- {
97
- const sqrtD = Math.sqrt(discriminant);
98
- const t1 = (-b + sqrtD) / (2 * a);
99
- const t2 = (-b - sqrtD) / (2 * a);
100
-
101
- if (t1 > 0 && t2 > 0)
102
- {
103
- t = Math.min(t1, t2);
104
- }
105
- else if (t1 > 0)
106
- {
107
- t = t1;
108
- }
109
- else if (t2 > 0)
110
- {
111
- t = t2;
112
- }
113
- }
114
- }
115
-
116
- // If no intercept solution, just aim at current position
117
- if (t === null)
118
- {
119
- return {x: targetPos.x, y: targetPos.y};
120
- }
121
-
122
- // Calculate intercept position
123
- return {
124
- x: targetPos.x + vx * t,
125
- y: targetPos.y + vy * t,
126
- };
127
- }
128
-
129
- /**
130
- * Calculate optimal missile configuration based on target behavior.
131
- */
132
- export function getAdaptiveMissileConfig(
133
- targetVelocity: Position,
134
- distance: number,
135
- ): {speed: number; turnRate: number; damage: number; duration: number}
136
- {
137
- const targetSpeed = Math.sqrt(targetVelocity.x ** 2 + targetVelocity.y ** 2);
138
-
139
- // Fast moving target = more homing
140
- // Slow/stationary target = faster missile, light homing for correction
141
- if (targetSpeed > 0.8)
142
- {
143
- // Moving target - use significant homing
144
- const turnRate = Math.min(3, 1 + targetSpeed * 1.5);
145
- return {
146
- damage: 10,
147
- speed: 5,
148
- turnRate,
149
- duration: Math.min(250, 100 + distance * 0.5),
150
- };
151
- }
152
- else
153
- {
154
- // Stationary/slow target - faster missile with light homing for accuracy
155
- return {
156
- damage: 10,
157
- speed: 6,
158
- turnRate: 1, // Light homing to correct any aiming errors
159
- duration: 180,
160
- };
161
- }
162
- }
163
-
164
- /**
165
- * Given a cast-time budget (in ticks) and a target distance, find the best
166
- * missile config that fits. Maximizes damage while ensuring the missile
167
- * can reach the target and finishes casting in time.
168
- *
169
- * Returns null if no useful missile fits in the budget.
170
- *
171
- * How it works: tries several speed/turnRate templates. For each, calculates
172
- * the minimum duration to reach `distance`, then solves the cast-time formula
173
- * for the maximum damage that fits within `budgetTicks`.
174
- *
175
- * If `lastMissileConfig` is provided, accounts for warmup bonus: similar
176
- * missiles cast faster, so more damage can fit in the same budget.
177
- */
178
- /**
179
- * Simulate a missile trajectory to find the minimum duration (ticks) needed
180
- * to reach a target at the given distance. Works for all turnRate values:
181
- * positive (homing) and zero (straight).
182
- *
183
- * The simulation starts the missile aimed directly at the target and steps
184
- * through the trajectory tick by tick. For homing, the missile tracks the
185
- * target each tick matching the engine's steering physics.
186
- */
187
- /**
188
- * Simulate a missile trajectory to find the minimum duration (ticks) needed
189
- * to reach a target at the given distance. Works for straight (turnRate=0)
190
- * and homing (turnRate>0) missiles.
191
- *
192
- * The missile starts aimed directly at the target at (dist, 0) and steps
193
- * through the trajectory tick by tick. For homing, the missile tracks the
194
- * target each tick matching the engine's steering physics.
195
- *
196
- * Returns 500 if the missile cannot reach the target within 500 ticks.
197
- */
198
- export function simulateMinDuration(speed: number, turnRateDeg: number, dist: number, collisionRadius?: number): number
199
- {
200
- if (dist <= 0) return 1;
201
- const hitR = collisionRadius ?? (WIZARD_RADIUS + 8);
202
- if (turnRateDeg === 0)
203
- {
204
- return Math.max(1, Math.ceil((dist - hitR) / speed));
205
- }
206
- const maxTurn = Math.abs(turnRateDeg) * Math.PI / 180;
207
- let angle = 0;
208
- let x = 0;
209
- let y = 0;
210
- for (let t = 1; t <= 500; t++)
211
- {
212
- const targetAngle = Math.atan2(-y, dist - x);
213
- let diff = targetAngle - angle;
214
- while (diff > Math.PI) diff -= 2 * Math.PI;
215
- while (diff < -Math.PI) diff += 2 * Math.PI;
216
- const turn = Math.max(-maxTurn, Math.min(maxTurn, diff));
217
- angle += turn;
218
- x += Math.cos(angle) * speed;
219
- y += Math.sin(angle) * speed;
220
- const d = Math.sqrt((x - dist) * (x - dist) + y * y);
221
- if (d <= hitR) return t;
222
- }
223
- return 500;
224
- }
225
-
226
- export function fitMissileToBudget(
227
- budgetTicks: number,
228
- distance: number,
229
- options?: {minTurnRate?: number; maxDamage?: number; lastMissileConfig?: MissileConfig},
230
- ): MissileConfig | null
231
- {
232
-
233
- const budgetSec = budgetTicks / TICKS_PER_SECOND;
234
- const minTurnRate = options?.minTurnRate ?? 0;
235
- const maxDamage = options?.maxDamage ?? WIZARD_HEALTH;
236
- // Warmup is active when caller explicitly passes lastMissileConfig in options.
237
- // Value can be undefined (first cast = full warmup) or MissileConfig (similarity-based).
238
- // When key is absent (old callers), no warmup is applied.
239
- const warmupActive = options !== undefined && 'lastMissileConfig' in options;
240
- const lastMissile = options?.lastMissileConfig;
241
-
242
- // Minimum useful cast time is 10 ticks (0.1s)
243
- if (budgetSec < MISSILE_MIN_CAST_TIME)
244
- {
245
- return null;
246
- }
247
-
248
- // Templates: [speed, turnRate] combos to try, ordered by preference.
249
- // Lower turnRate = cheaper cast. Higher speed = shorter duration needed.
250
- const templates: [number, number][] = [
251
- [7, 0], // Fast straight: cheapest cast, only hits stationary
252
- [5, 0], // Medium straight
253
- [6, 0.5], // Fast with light tracking
254
- [5, 1], // Medium with tracking
255
- [4, 1], // Slow with tracking (long range)
256
- [4, 1.5], // Slow with strong tracking
257
- [3, 2], // Very slow with heavy tracking (for aggressive homing)
258
- ];
259
-
260
- let best: MissileConfig | null = null;
261
- let bestDamage = 0;
262
-
263
- for (const [speed, turnRate] of templates)
264
- {
265
- if (turnRate < minTurnRate) continue;
266
- const minDurationTicks = Math.max(
267
- MISSILE_MIN_DURATION,
268
- simulateMinDuration(speed, turnRate, distance),
269
- );
270
- const durationSec = minDurationTicks / TICKS_PER_SECOND;
271
-
272
- // Solve cast formula for damage:
273
- // budgetSec = BASE + SCALE * d^POWER + delivery costs
274
- // d = ((budgetSec - fixedCost) / SCALE)^(1/POWER)
275
- const turnCost = effectiveTurnRateCost(turnRate);
276
- const fixedCost = MISSILE_BASE_CAST
277
- + MISSILE_HOMING_COEFF * turnCost
278
- + MISSILE_TURN_DURATION_COEFF * (turnCost * durationSec)
279
- + MISSILE_SPEED_DURATION_COEFF * (speed * durationSec - MISSILE_SPEED_DURATION_BASELINE);
280
-
281
- // Estimate warmup multiplier for this template (depends on speed/turnRate/duration,
282
- // not damage, so we can estimate before solving for damage)
283
- let effectiveBudgetSec = budgetSec;
284
- if (warmupActive && lastMissile)
285
- {
286
- const probe: MissileConfig = {damage: lastMissile.damage, speed, turnRate, duration: minDurationTicks};
287
- const multiplier = calculateWarmupMultiplier(lastMissile, probe);
288
- // multiplier < 1 = faster (more budget), > 1 = slower (less budget)
289
- effectiveBudgetSec = budgetSec / multiplier;
290
- }
291
-
292
- const remaining = effectiveBudgetSec - fixedCost;
293
- if (remaining <= 0)
294
- {
295
- continue;
296
- }
297
-
298
- const maxDamageFloat = Math.pow(remaining / MISSILE_DAMAGE_SCALE, 1 / MISSILE_DAMAGE_POWER);
299
- const damage = Math.min(maxDamage, Math.floor(maxDamageFloat));
300
-
301
- if (damage < MISSILE_MIN_DAMAGE)
302
- {
303
- continue;
304
- }
305
-
306
- // Verify actual cast time with real warmup (using final damage)
307
- const candidate: MissileConfig = {damage, speed, turnRate, duration: minDurationTicks};
308
- const actualCastSec = (warmupActive && lastMissile)
309
- ? calculateMissileCastTime(candidate, lastMissile)
310
- : calculateMissileCastTime(candidate);
311
- const actualCastTicks = Math.max(1, Math.ceil(actualCastSec * TICKS_PER_SECOND));
312
-
313
- if (actualCastTicks > budgetTicks)
314
- {
315
- continue;
316
- }
317
-
318
- if (damage > bestDamage)
319
- {
320
- bestDamage = damage;
321
- best = candidate;
322
- }
323
- }
324
-
325
- return best;
326
- }
327
-
328
- /**
329
- * Fit a missile config that accounts for the enemy escaping during cast time.
330
- *
331
- * During casting, the caster moves at CASTING_MOVEMENT_MULT speed while
332
- * the enemy moves at full MOVEMENT_SPEED. This means the effective distance
333
- * at launch is larger than the current distance. This function iteratively
334
- * converges on a missile config whose range covers the escape distance.
335
- *
336
- * @param currentDistance - Current distance to enemy
337
- * @param budgetTicks - Maximum cast time budget in ticks
338
- * @param options - Same options as fitMissileToBudget, plus:
339
- * - enemyApproaching: if true, enemy is moving toward caster (reduces escape)
340
- * - distanceBuffer: flat units added to target distance for safety margin (default 20)
341
- * - maxIterations: convergence iterations (default 5)
342
- */
343
- export function fitMissileForEscapingTarget(
344
- currentDistance: number,
345
- budgetTicks: number,
346
- options?: {minTurnRate?: number; maxDamage?: number; lastMissileConfig?: MissileConfig; enemyApproaching?: boolean; distanceBuffer?: number; maxIterations?: number},
347
- ): MissileConfig | null
348
- {
349
- const maxIter = options?.maxIterations ?? 5;
350
- const buffer = options?.distanceBuffer ?? 20;
351
- const escapeRate = options?.enemyApproaching
352
- ? 0
353
- : MOVEMENT_SPEED - MOVEMENT_SPEED * CASTING_MOVEMENT_MULT;
354
-
355
- let targetDistance = currentDistance + buffer;
356
- let lastCastTicks = 0;
357
-
358
- for (let i = 0; i < maxIter; i++)
359
- {
360
- const config = fitMissileToBudget(budgetTicks, targetDistance, options);
361
- if (!config) return null;
362
-
363
- const castTicks = getMissileCastTime(config, options?.lastMissileConfig);
364
- if (castTicks === lastCastTicks) return config;
365
- lastCastTicks = castTicks;
366
-
367
- targetDistance = currentDistance + castTicks * escapeRate + buffer;
368
- }
369
-
370
- return fitMissileToBudget(budgetTicks, targetDistance, options);
371
- }
1
+ /**
2
+ * VIBEMANCER - COMBAT UTILITIES
3
+ *
4
+ * Utilities for combat calculations.
5
+ */
6
+
7
+ import {Position, Velocity, MissileConfig} from '../types.js';
8
+ import {
9
+ calculateMissileCastTime,
10
+ calculateWarmupMultiplier,
11
+ validateMissileConfig,
12
+ TICKS_PER_SECOND,
13
+ MOVEMENT_SPEED,
14
+ CASTING_MOVEMENT_MULT,
15
+ WIZARD_HEALTH,
16
+ MISSILE_BASE_CAST,
17
+ MISSILE_DAMAGE_SCALE,
18
+ MISSILE_DAMAGE_POWER,
19
+ MISSILE_HOMING_COEFF,
20
+ MISSILE_TURN_DURATION_COEFF,
21
+ MISSILE_SPEED_DURATION_COEFF,
22
+ MISSILE_SPEED_DURATION_BASELINE,
23
+ MISSILE_MIN_CAST_TIME,
24
+ MISSILE_MIN_DURATION,
25
+ MISSILE_MIN_DAMAGE,
26
+ WIZARD_RADIUS,
27
+ effectiveTurnRateCost,
28
+ } from '../rules.js';
29
+
30
+ /**
31
+ * Get cast time in ticks for a missile configuration.
32
+ * Applies the same clamps/validation as the engine before calculating,
33
+ * so the result matches the actual cast time that will be used in-game.
34
+ *
35
+ * If lastMissileConfig is provided, includes warmup multiplier.
36
+ * Pass undefined for first cast (full warmup) or null for base time only.
37
+ *
38
+ * @returns cast time in TICKS.
39
+ *
40
+ * Note the unit, because there are two of these: `calculateMissileCastTime` in rules.ts has
41
+ * the identical signature and returns SECONDS. vibemancer_api lists both as
42
+ * `(config, last?) => number`, and the whole guide speaks in ticks — so a player budgeting
43
+ * ticks from the seconds one reads 2.747 as "about three ticks" when it is 275.
44
+ * This one is the one a bot wants.
45
+ */
46
+ export function getMissileCastTime(config: MissileConfig, lastMissileConfig?: MissileConfig | null): number
47
+ {
48
+ const validated = validateMissileConfig(config);
49
+ return Math.ceil(calculateMissileCastTime(validated, lastMissileConfig) * TICKS_PER_SECOND);
50
+ }
51
+
52
+ /**
53
+ * Calculate the position to aim at to hit a moving target.
54
+ * Returns the intercept point where a missile would hit the target.
55
+ *
56
+ * @param targetPos - Current target position
57
+ * @param targetVel - Target velocity (units per tick)
58
+ * @param missileSpeed - Missile speed (units per tick)
59
+ * @param myPos - Shooter position
60
+ * @returns The position to aim at
61
+ */
62
+ export function getLeadPosition(
63
+ targetPos: Position,
64
+ targetVel: Velocity,
65
+ missileSpeed: number,
66
+ myPos: Position,
67
+ ): Position
68
+ {
69
+ // Vector from shooter to target
70
+ const dx = targetPos.x - myPos.x;
71
+ const dy = targetPos.y - myPos.y;
72
+
73
+ // Target velocity
74
+ const vx = targetVel.x;
75
+ const vy = targetVel.y;
76
+
77
+ // Quadratic coefficients for intercept time
78
+ // |target + velocity * t - shooter|^2 = (missileSpeed * t)^2
79
+ // Expands to: (vx^2 + vy^2 - speed^2) * t^2 + 2*(vx*dx + vy*dy) * t + (dx^2 + dy^2) = 0
80
+ const a = vx * vx + vy * vy - missileSpeed * missileSpeed;
81
+ const b = 2 * (vx * dx + vy * dy);
82
+ const c = dx * dx + dy * dy;
83
+
84
+ let t: number | null = null;
85
+
86
+ // Handle the linear case (|velocity| === missileSpeed)
87
+ if (Math.abs(a) < 1e-10)
88
+ {
89
+ if (Math.abs(b) > 1e-10)
90
+ {
91
+ const linearT = -c / b;
92
+ if (linearT > 0)
93
+ {
94
+ t = linearT;
95
+ }
96
+ }
97
+ }
98
+ else
99
+ {
100
+ // Quadratic case
101
+ const discriminant = b * b - 4 * a * c;
102
+
103
+ if (discriminant >= 0)
104
+ {
105
+ const sqrtD = Math.sqrt(discriminant);
106
+ const t1 = (-b + sqrtD) / (2 * a);
107
+ const t2 = (-b - sqrtD) / (2 * a);
108
+
109
+ if (t1 > 0 && t2 > 0)
110
+ {
111
+ t = Math.min(t1, t2);
112
+ }
113
+ else if (t1 > 0)
114
+ {
115
+ t = t1;
116
+ }
117
+ else if (t2 > 0)
118
+ {
119
+ t = t2;
120
+ }
121
+ }
122
+ }
123
+
124
+ // If no intercept solution, just aim at current position
125
+ if (t === null)
126
+ {
127
+ return {x: targetPos.x, y: targetPos.y};
128
+ }
129
+
130
+ // Calculate intercept position
131
+ return {
132
+ x: targetPos.x + vx * t,
133
+ y: targetPos.y + vy * t,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Calculate optimal missile configuration based on target behavior.
139
+ */
140
+ export function getAdaptiveMissileConfig(
141
+ targetVelocity: Position,
142
+ distance: number,
143
+ ): {speed: number; turnRate: number; damage: number; duration: number}
144
+ {
145
+ const targetSpeed = Math.sqrt(targetVelocity.x ** 2 + targetVelocity.y ** 2);
146
+
147
+ // Fast moving target = more homing
148
+ // Slow/stationary target = faster missile, light homing for correction
149
+ if (targetSpeed > 0.8)
150
+ {
151
+ // Moving target - use significant homing
152
+ const turnRate = Math.min(3, 1 + targetSpeed * 1.5);
153
+ return {
154
+ damage: 10,
155
+ speed: 5,
156
+ turnRate,
157
+ duration: Math.min(250, 100 + distance * 0.5),
158
+ };
159
+ }
160
+ else
161
+ {
162
+ // Stationary/slow target - faster missile with light homing for accuracy
163
+ return {
164
+ damage: 10,
165
+ speed: 6,
166
+ turnRate: 1, // Light homing to correct any aiming errors
167
+ duration: 180,
168
+ };
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Given a cast-time budget (in ticks) and a target distance, find the best
174
+ * missile config that fits. Maximizes damage while ensuring the missile
175
+ * can reach the target and finishes casting in time.
176
+ *
177
+ * Returns null if no useful missile fits in the budget.
178
+ *
179
+ * How it works: tries several speed/turnRate templates. For each, calculates
180
+ * the minimum duration to reach `distance`, then solves the cast-time formula
181
+ * for the maximum damage that fits within `budgetTicks`.
182
+ *
183
+ * If `lastMissileConfig` is provided, accounts for warmup bonus: similar
184
+ * missiles cast faster, so more damage can fit in the same budget.
185
+ */
186
+ /**
187
+ * Simulate a missile trajectory to find the minimum duration (ticks) needed
188
+ * to reach a target at the given distance. Works for all turnRate values:
189
+ * positive (homing) and zero (straight).
190
+ *
191
+ * The simulation starts the missile aimed directly at the target and steps
192
+ * through the trajectory tick by tick. For homing, the missile tracks the
193
+ * target each tick matching the engine's steering physics.
194
+ */
195
+ /**
196
+ * Simulate a missile trajectory to find the minimum duration (ticks) needed
197
+ * to reach a target at the given distance. Works for straight (turnRate=0)
198
+ * and homing (turnRate>0) missiles.
199
+ *
200
+ * The missile starts aimed directly at the target at (dist, 0) and steps
201
+ * through the trajectory tick by tick. For homing, the missile tracks the
202
+ * target each tick matching the engine's steering physics.
203
+ *
204
+ * Returns 500 if the missile cannot reach the target within 500 ticks.
205
+ */
206
+ export function simulateMinDuration(speed: number, turnRateDeg: number, dist: number, collisionRadius?: number): number
207
+ {
208
+ if (dist <= 0) return 1;
209
+ const hitR = collisionRadius ?? (WIZARD_RADIUS + 8);
210
+ if (turnRateDeg === 0)
211
+ {
212
+ return Math.max(1, Math.ceil((dist - hitR) / speed));
213
+ }
214
+ const maxTurn = Math.abs(turnRateDeg) * Math.PI / 180;
215
+ let angle = 0;
216
+ let x = 0;
217
+ let y = 0;
218
+ for (let t = 1; t <= 500; t++)
219
+ {
220
+ const targetAngle = Math.atan2(-y, dist - x);
221
+ let diff = targetAngle - angle;
222
+ while (diff > Math.PI) diff -= 2 * Math.PI;
223
+ while (diff < -Math.PI) diff += 2 * Math.PI;
224
+ const turn = Math.max(-maxTurn, Math.min(maxTurn, diff));
225
+ angle += turn;
226
+ x += Math.cos(angle) * speed;
227
+ y += Math.sin(angle) * speed;
228
+ const d = Math.sqrt((x - dist) * (x - dist) + y * y);
229
+ if (d <= hitR) return t;
230
+ }
231
+ return 500;
232
+ }
233
+
234
+ export function fitMissileToBudget(
235
+ budgetTicks: number,
236
+ distance: number,
237
+ options?: {minTurnRate?: number; maxDamage?: number; lastMissileConfig?: MissileConfig},
238
+ ): MissileConfig | null
239
+ {
240
+
241
+ const budgetSec = budgetTicks / TICKS_PER_SECOND;
242
+ const minTurnRate = options?.minTurnRate ?? 0;
243
+ const maxDamage = options?.maxDamage ?? WIZARD_HEALTH;
244
+ // Warmup is active when caller explicitly passes lastMissileConfig in options.
245
+ // Value can be undefined (first cast = full warmup) or MissileConfig (similarity-based).
246
+ // When key is absent (old callers), no warmup is applied.
247
+ const warmupActive = options !== undefined && 'lastMissileConfig' in options;
248
+ const lastMissile = options?.lastMissileConfig;
249
+
250
+ // Minimum useful cast time is 10 ticks (0.1s)
251
+ if (budgetSec < MISSILE_MIN_CAST_TIME)
252
+ {
253
+ return null;
254
+ }
255
+
256
+ // Templates: [speed, turnRate] combos to try, ordered by preference.
257
+ // Lower turnRate = cheaper cast. Higher speed = shorter duration needed.
258
+ const templates: [number, number][] = [
259
+ [7, 0], // Fast straight: cheapest cast, only hits stationary
260
+ [5, 0], // Medium straight
261
+ [6, 0.5], // Fast with light tracking
262
+ [5, 1], // Medium with tracking
263
+ [4, 1], // Slow with tracking (long range)
264
+ [4, 1.5], // Slow with strong tracking
265
+ [3, 2], // Very slow with heavy tracking (for aggressive homing)
266
+ ];
267
+
268
+ let best: MissileConfig | null = null;
269
+ let bestDamage = 0;
270
+
271
+ for (const [speed, turnRate] of templates)
272
+ {
273
+ if (turnRate < minTurnRate) continue;
274
+ const minDurationTicks = Math.max(
275
+ MISSILE_MIN_DURATION,
276
+ simulateMinDuration(speed, turnRate, distance),
277
+ );
278
+ const durationSec = minDurationTicks / TICKS_PER_SECOND;
279
+
280
+ // Solve cast formula for damage:
281
+ // budgetSec = BASE + SCALE * d^POWER + delivery costs
282
+ // d = ((budgetSec - fixedCost) / SCALE)^(1/POWER)
283
+ const turnCost = effectiveTurnRateCost(turnRate);
284
+ const fixedCost = MISSILE_BASE_CAST
285
+ + MISSILE_HOMING_COEFF * turnCost
286
+ + MISSILE_TURN_DURATION_COEFF * (turnCost * durationSec)
287
+ + MISSILE_SPEED_DURATION_COEFF * (speed * durationSec - MISSILE_SPEED_DURATION_BASELINE);
288
+
289
+ // Estimate warmup multiplier for this template (depends on speed/turnRate/duration,
290
+ // not damage, so we can estimate before solving for damage)
291
+ let effectiveBudgetSec = budgetSec;
292
+ if (warmupActive && lastMissile)
293
+ {
294
+ const probe: MissileConfig = {damage: lastMissile.damage, speed, turnRate, duration: minDurationTicks};
295
+ const multiplier = calculateWarmupMultiplier(lastMissile, probe);
296
+ // multiplier < 1 = faster (more budget), > 1 = slower (less budget)
297
+ effectiveBudgetSec = budgetSec / multiplier;
298
+ }
299
+
300
+ const remaining = effectiveBudgetSec - fixedCost;
301
+ if (remaining <= 0)
302
+ {
303
+ continue;
304
+ }
305
+
306
+ const maxDamageFloat = Math.pow(remaining / MISSILE_DAMAGE_SCALE, 1 / MISSILE_DAMAGE_POWER);
307
+ const damage = Math.min(maxDamage, Math.floor(maxDamageFloat));
308
+
309
+ if (damage < MISSILE_MIN_DAMAGE)
310
+ {
311
+ continue;
312
+ }
313
+
314
+ // Verify actual cast time with real warmup (using final damage)
315
+ const candidate: MissileConfig = {damage, speed, turnRate, duration: minDurationTicks};
316
+ const actualCastSec = (warmupActive && lastMissile)
317
+ ? calculateMissileCastTime(candidate, lastMissile)
318
+ : calculateMissileCastTime(candidate);
319
+ const actualCastTicks = Math.max(1, Math.ceil(actualCastSec * TICKS_PER_SECOND));
320
+
321
+ if (actualCastTicks > budgetTicks)
322
+ {
323
+ continue;
324
+ }
325
+
326
+ if (damage > bestDamage)
327
+ {
328
+ bestDamage = damage;
329
+ best = candidate;
330
+ }
331
+ }
332
+
333
+ return best;
334
+ }
335
+
336
+ /**
337
+ * Fit a missile config that accounts for the enemy escaping during cast time.
338
+ *
339
+ * During casting, the caster moves at CASTING_MOVEMENT_MULT speed while
340
+ * the enemy moves at full MOVEMENT_SPEED. This means the effective distance
341
+ * at launch is larger than the current distance. This function iteratively
342
+ * converges on a missile config whose range covers the escape distance.
343
+ *
344
+ * @param currentDistance - Current distance to enemy
345
+ * @param budgetTicks - Maximum cast time budget in ticks
346
+ * @param options - Same options as fitMissileToBudget, plus:
347
+ * - enemyApproaching: if true, enemy is moving toward caster (reduces escape)
348
+ * - distanceBuffer: flat units added to target distance for safety margin (default 20)
349
+ * - maxIterations: convergence iterations (default 5)
350
+ */
351
+ export function fitMissileForEscapingTarget(
352
+ currentDistance: number,
353
+ budgetTicks: number,
354
+ options?: {minTurnRate?: number; maxDamage?: number; lastMissileConfig?: MissileConfig; enemyApproaching?: boolean; distanceBuffer?: number; maxIterations?: number},
355
+ ): MissileConfig | null
356
+ {
357
+ const maxIter = options?.maxIterations ?? 5;
358
+ const buffer = options?.distanceBuffer ?? 20;
359
+ const escapeRate = options?.enemyApproaching
360
+ ? 0
361
+ : MOVEMENT_SPEED - MOVEMENT_SPEED * CASTING_MOVEMENT_MULT;
362
+
363
+ let targetDistance = currentDistance + buffer;
364
+ let lastCastTicks = 0;
365
+
366
+ for (let i = 0; i < maxIter; i++)
367
+ {
368
+ const config = fitMissileToBudget(budgetTicks, targetDistance, options);
369
+ if (!config) return null;
370
+
371
+ const castTicks = getMissileCastTime(config, options?.lastMissileConfig);
372
+ if (castTicks === lastCastTicks) return config;
373
+ lastCastTicks = castTicks;
374
+
375
+ targetDistance = currentDistance + castTicks * escapeRate + buffer;
376
+ }
377
+
378
+ return fitMissileToBudget(budgetTicks, targetDistance, options);
379
+ }