@vibemancer/core 1.0.7 → 1.0.9

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,389 +1,396 @@
1
- /**
2
- * VIBEMANCER - THREAT ANALYSIS
3
- *
4
- * Pre-computes threat information for incoming projectiles.
5
- * This handles the "subconscious" perception of missile trajectories.
6
- */
7
-
8
- import {Position, ProjectileState} from '../types.js';
9
- import {
10
- RULES,
11
- COLLISION_RADIUS,
12
- MISSILE_BASE_RADIUS,
13
- MISSILE_RADIUS_PER_DAMAGE,
14
- ARENA_SIZE,
15
- ARENA_MIN,
16
- ARENA_MAX,
17
- ARENA_WATER_BUFFER,
18
- } from '../rules.js';
19
- import {distanceTo} from '../utils/distance.js';
20
- import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
21
- import type {AnalyzedThreat} from './types.js';
22
-
23
- // Maximum distance at which a missile is still tracked as a potential threat
24
- // (even if simulation says it will miss — homing missiles can change course)
25
- const THREAT_RELEVANCE_DISTANCE = 500;
26
-
27
- /**
28
- * Analyze all threats from enemy projectiles.
29
- *
30
- * @param myPos - Current position of the wizard
31
- * @param projectiles - All projectiles in the game
32
- * @param myProjectiles - Only the bot's own projectiles (used for filtering)
33
- * @param ticksUntilReady - Ticks until wizard can start a new action
34
- * @returns Array of analyzed threats sorted by ticksToImpact (soonest first)
35
- */
36
- export function analyzeThreats(
37
- myPos: Position,
38
- projectiles: ProjectileState[],
39
- myProjectiles: ProjectileState[],
40
- ticksUntilReady: number,
41
- ): AnalyzedThreat[]
42
- {
43
- // Filter to enemy projectiles by excluding our own
44
- const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
45
- const enemyProjectiles = projectiles.filter((p) => !myProjectileIds.has(p.id));
46
-
47
- // Analyze each projectile
48
- const threats: AnalyzedThreat[] = [];
49
-
50
- for (const projectile of enemyProjectiles)
51
- {
52
- const analysis = analyzeOneThreat(myPos, projectile, ticksUntilReady);
53
- // Only include if it will hit or missile is within reasonable distance
54
- const missileDistance = Math.sqrt(
55
- (projectile.position.x - myPos.x) ** 2 +
56
- (projectile.position.y - myPos.y) ** 2,
57
- );
58
- // Stryker disable next-line EqualityOperator: < vs <= equivalent (exact distance never equals constant in practice)
59
- if (analysis.willHit || missileDistance < THREAT_RELEVANCE_DISTANCE)
60
- {
61
- threats.push(analysis);
62
- }
63
- }
64
-
65
- // Sort by ticksToImpact (soonest first)
66
- threats.sort((a, b) => a.ticksToImpact - b.ticksToImpact);
67
-
68
- return threats;
69
- }
70
-
71
- /**
72
- * Analyze a single projectile threat.
73
- */
74
- function analyzeOneThreat(
75
- targetPos: Position,
76
- projectile: ProjectileState,
77
- ticksUntilReady: number,
78
- ): AnalyzedThreat
79
- {
80
- // Calculate collision radius
81
- const missileRadius = MISSILE_BASE_RADIUS + projectile.damage * MISSILE_RADIUS_PER_DAMAGE;
82
- const collisionDist = COLLISION_RADIUS + missileRadius;
83
-
84
- // Dodge simulation uses a larger collision distance to account for swept circle collision.
85
- // The real game checks the entire missile path each tick (swept), but the simulation only
86
- // checks endpoints. Adding half the missile speed compensates for this gap.
87
- // Stryker disable next-line ArithmeticOperator: +speed*0.5 vs -speed*0.5 or /0.5 — only affects tight-margin dodge outcomes which are inherently stochastic
88
- const dodgeCollisionDist = collisionDist + projectile.speed * 0.5;
89
-
90
- // Simulate missile trajectory toward stationary target
91
- const {willHit, ticksToImpact} = simulateMissileToTarget(
92
- projectile,
93
- targetPos,
94
- collisionDist,
95
- );
96
-
97
- // Only simulate dodges for missiles that will actually hit (saves ~75% simulation work)
98
- let canDodgeLeft = true;
99
- let canDodgeRight = true;
100
- let canOutrun = true;
101
- let bestDodgeDirection: Position | null = null;
102
-
103
- if (willHit)
104
- {
105
- canDodgeLeft = simulateDodge(projectile, targetPos, 'left', dodgeCollisionDist);
106
- canDodgeRight = simulateDodge(projectile, targetPos, 'right', dodgeCollisionDist);
107
- canOutrun = simulateDodge(projectile, targetPos, 'away', dodgeCollisionDist);
108
-
109
- bestDodgeDirection = calculateBestDodgeDirection(
110
- projectile,
111
- targetPos,
112
- canDodgeLeft,
113
- canDodgeRight,
114
- canOutrun,
115
- );
116
- }
117
-
118
- // Calculate shield timing
119
- // Can block if: ticksUntilReady + shield cast time < ticksToImpact
120
- // Stryker disable next-line EqualityOperator,ArithmeticOperator: ±1 tick boundary is intentional buffer, not precisely testable
121
- const canBlockInTime = ticksToImpact > ticksUntilReady + RULES.SHIELD_CAST_TIME + 1;
122
-
123
- // When to start casting shield (leave 1 tick buffer)
124
- const ticksToStartShield = Math.max(0, ticksToImpact - RULES.SHIELD_CAST_TIME - 1);
125
-
126
- return {
127
- id: projectile.id,
128
- projectile,
129
- ticksToImpact,
130
- willHit,
131
- canDodgeLeft,
132
- canDodgeRight,
133
- canOutrun,
134
- bestDodgeDirection,
135
- canBlockInTime,
136
- ticksToStartShield,
137
- };
138
- }
139
-
140
- /**
141
- * Simulate a missile moving toward a stationary target.
142
- * Accounts for homing (turn rate) and expiration.
143
- */
144
- function simulateMissileToTarget(
145
- projectile: ProjectileState,
146
- targetPos: Position,
147
- collisionDist: number,
148
- ): {willHit: boolean; ticksToImpact: number}
149
- {
150
- let pos = {...projectile.position};
151
- let rotation = projectile.rotation;
152
- const maxTicks = projectile.remainingTicks;
153
- const speed = projectile.speed;
154
- const turnRate = projectile.turnRate;
155
-
156
- // Stryker disable next-line EqualityOperator: <= vs < equivalent (off-by-one at max tick boundary is non-observable)
157
- for (let tick = 1; tick <= maxTicks; tick++)
158
- {
159
- // Stryker disable next-line ConditionalExpression,EqualityOperator: turnRate>=0 equivalent (homing with rate 0 is no-op)
160
- if (turnRate > 0)
161
- {
162
- const desiredAngle = angleTo(pos, targetPos);
163
- const diff = angleDiff(rotation, desiredAngle);
164
- const maxTurn = turnRate;
165
-
166
- // Stryker disable all: homing snap — equivalent mutations (removing snap or changing boundary doesn't affect hit outcome)
167
- if (Math.abs(diff) <= maxTurn)
168
- {
169
- rotation = desiredAngle;
170
- }
171
- // Stryker restore all
172
- else
173
- {
174
- rotation = normalizeAngle(rotation + Math.sign(diff) * maxTurn);
175
- }
176
- }
177
-
178
- // Move forward
179
- const rad = rotation * (Math.PI / 180);
180
- pos = {
181
- x: pos.x + Math.cos(rad) * speed,
182
- y: pos.y + Math.sin(rad) * speed,
183
- };
184
-
185
- // Check if missile is out of bounds (with water buffer)
186
- if (isOutOfBounds(pos))
187
- {
188
- return {willHit: false, ticksToImpact: Infinity};
189
- }
190
-
191
- // Check collision
192
- const dist = distanceTo(pos, targetPos);
193
- // Stryker disable next-line EqualityOperator: <= vs < equivalent (float distance never exactly equals collisionDist)
194
- if (dist <= collisionDist)
195
- {
196
- return {willHit: true, ticksToImpact: tick};
197
- }
198
- }
199
-
200
- // Missile expired without hitting
201
- return {willHit: false, ticksToImpact: Infinity};
202
- }
203
-
204
- /**
205
- * Simulate a dodge attempt.
206
- * Returns true if the dodge avoids the missile.
207
- */
208
- function simulateDodge(
209
- projectile: ProjectileState,
210
- startPos: Position,
211
- direction: 'left' | 'right' | 'away',
212
- collisionDist: number,
213
- ): boolean
214
- {
215
- // Calculate dodge direction vector
216
- const dodgeDir = getDodgeDirection(projectile, startPos, direction);
217
-
218
- // Stryker disable all: guard is equivalent zero vector means zero movement, missile still hits, returns false either way
219
- if (dodgeDir.x === 0 && dodgeDir.y === 0)
220
- {
221
- return false;
222
- }
223
- // Stryker restore all
224
-
225
- let targetPos = {...startPos};
226
- let missilePos = {...projectile.position};
227
- let missileRotation = projectile.rotation;
228
- const maxTicks = projectile.remainingTicks;
229
- const speed = projectile.speed;
230
- const turnRate = projectile.turnRate;
231
-
232
- // Stryker disable next-line EqualityOperator,UpdateOperator: <= vs < off-by-one equivalent; tick-- causes timeout not assertion failure
233
- for (let tick = 1; tick <= maxTicks; tick++)
234
- {
235
- // Stryker disable next-line ArithmeticOperator: RULES.MOVEMENT_SPEED=1 makes * and / identical; sign flip equivalent when wizard is wall-clamped
236
- const newX = targetPos.x + dodgeDir.x * RULES.MOVEMENT_SPEED;
237
- // Stryker disable next-line ArithmeticOperator: same as above
238
- const newY = targetPos.y + dodgeDir.y * RULES.MOVEMENT_SPEED;
239
-
240
- // Stryker disable all: arena clamp sign — +/- COLLISION_RADIUS equivalent (wizard rarely reaches exact wall boundary during dodge)
241
- targetPos = {
242
- x: Math.max(ARENA_MIN + COLLISION_RADIUS, Math.min(ARENA_MAX - COLLISION_RADIUS, newX)),
243
- y: Math.max(ARENA_MIN + COLLISION_RADIUS, Math.min(ARENA_MAX - COLLISION_RADIUS, newY)),
244
- };
245
- // Stryker restore all
246
-
247
- // Stryker disable next-line ConditionalExpression,EqualityOperator: turnRate>=0 equivalent (homing with rate 0 is no-op)
248
- if (turnRate > 0)
249
- {
250
- const desiredAngle = angleTo(missilePos, targetPos);
251
- const diff = angleDiff(missileRotation, desiredAngle);
252
- const maxTurn = turnRate;
253
-
254
- // Stryker disable all: homing snap/incremental — equivalent mutations (removing either branch doesn't change dodge outcome)
255
- if (Math.abs(diff) <= maxTurn)
256
- {
257
- missileRotation = desiredAngle;
258
- }
259
- else
260
- {
261
- missileRotation = normalizeAngle(missileRotation + Math.sign(diff) * maxTurn);
262
- }
263
- // Stryker restore all
264
- }
265
-
266
- // Move missile
267
- const rad = missileRotation * (Math.PI / 180);
268
- missilePos = {
269
- x: missilePos.x + Math.cos(rad) * speed,
270
- y: missilePos.y + Math.sin(rad) * speed,
271
- };
272
-
273
- // Stryker disable all: OOB early-return equivalent — removing it means missile expires instead, still returns true
274
- if (isOutOfBounds(missilePos))
275
- {
276
- return true;
277
- }
278
- // Stryker restore all
279
-
280
- // Check collision
281
- const dist = distanceTo(missilePos, targetPos);
282
- // Stryker disable next-line EqualityOperator: <= vs < equivalent (float dist never exactly equals collisionDist)
283
- if (dist <= collisionDist)
284
- {
285
- return false; // Got hit while dodging
286
- }
287
- }
288
-
289
- // Missile expired without hitting
290
- return true;
291
- }
292
-
293
- /**
294
- * Get the dodge direction vector based on direction type.
295
- */
296
- function getDodgeDirection(
297
- projectile: ProjectileState,
298
- targetPos: Position,
299
- direction: 'left' | 'right' | 'away',
300
- ): Position
301
- {
302
- // Get missile direction vector
303
- const missileAngleRad = projectile.rotation * (Math.PI / 180);
304
- const missileDir = {
305
- x: Math.cos(missileAngleRad),
306
- y: Math.sin(missileAngleRad),
307
- };
308
-
309
- switch (direction)
310
- {
311
- case 'left':
312
- // Perpendicular to missile direction (left = counter-clockwise)
313
- return {x: missileDir.y, y: -missileDir.x};
314
-
315
- case 'right':
316
- // Perpendicular to missile direction (right = clockwise)
317
- return {x: -missileDir.y, y: missileDir.x};
318
-
319
- case 'away':
320
- {
321
- // Away from missile current position
322
- const dx = targetPos.x - projectile.position.x;
323
- const dy = targetPos.y - projectile.position.y;
324
- const len = Math.sqrt(dx * dx + dy * dy);
325
- if (len === 0) return {x: 0, y: 0};
326
- return {x: dx / len, y: dy / len};
327
- }
328
-
329
- default:
330
- return {x: 0, y: 0};
331
- }
332
- }
333
-
334
- /**
335
- * Calculate the best dodge direction based on available options.
336
- */
337
- function calculateBestDodgeDirection(
338
- projectile: ProjectileState,
339
- targetPos: Position,
340
- canDodgeLeft: boolean,
341
- canDodgeRight: boolean,
342
- canOutrun: boolean,
343
- ): Position | null
344
- {
345
- // Stryker disable next-line ConditionalExpression: replacing with false just falls through to the `else if (canDodgeLeft)` which returns the same value
346
- if (canDodgeLeft && canDodgeRight)
347
- {
348
- // Both sides work - pick the one closer to where we want to be
349
- // For simplicity, just return left
350
- return getDodgeDirection(projectile, targetPos, 'left');
351
- }
352
- else if (canDodgeLeft)
353
- {
354
- return getDodgeDirection(projectile, targetPos, 'left');
355
- }
356
- else if (canDodgeRight)
357
- {
358
- return getDodgeDirection(projectile, targetPos, 'right');
359
- }
360
- else if (canOutrun)
361
- {
362
- return getDodgeDirection(projectile, targetPos, 'away');
363
- }
364
-
365
- // No dodge available
366
- return null;
367
- }
368
-
369
- /**
370
- * Check if a position is out of bounds (past the water buffer).
371
- */
372
- function isOutOfBounds(pos: Position): boolean
373
- {
374
- return (
375
- pos.x < -ARENA_WATER_BUFFER ||
376
- pos.x > ARENA_SIZE + ARENA_WATER_BUFFER ||
377
- pos.y < -ARENA_WATER_BUFFER ||
378
- pos.y > ARENA_SIZE + ARENA_WATER_BUFFER
379
- );
380
- }
381
-
382
- /** @internal Exposed for mutation testing only. */
383
- export const _testing = {
384
- simulateMissileToTarget,
385
- simulateDodge,
386
- getDodgeDirection,
387
- calculateBestDodgeDirection,
388
- isOutOfBounds,
389
- };
1
+ /**
2
+ * VIBEMANCER - THREAT ANALYSIS
3
+ *
4
+ * Pre-computes threat information for incoming projectiles.
5
+ * This handles the "subconscious" perception of missile trajectories.
6
+ */
7
+
8
+ import {Position, ProjectileState} from '../types.js';
9
+ import {
10
+ RULES,
11
+ COLLISION_RADIUS,
12
+ calculateMissileRadius,
13
+ ARENA_SIZE,
14
+ ARENA_MIN,
15
+ ARENA_MAX,
16
+ ARENA_WATER_BUFFER,
17
+ } from '../rules.js';
18
+ import {distanceTo} from '../utils/distance.js';
19
+ import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
20
+ import type {AnalyzedThreat} from './types.js';
21
+
22
+ // Maximum distance at which a missile is still tracked as a potential threat
23
+ // (even if simulation says it will miss homing missiles can change course)
24
+ const THREAT_RELEVANCE_DISTANCE = 500;
25
+
26
+ /**
27
+ * Analyze all threats from enemy projectiles.
28
+ *
29
+ * @param myPos - Current position of the wizard
30
+ * @param projectiles - All projectiles in the game
31
+ * @param myProjectiles - Only the bot's own projectiles (used for filtering)
32
+ * @param ticksUntilReady - Ticks until wizard can start a new action
33
+ * @returns Array of analyzed threats sorted by ticksToImpact (soonest first)
34
+ */
35
+ export function analyzeThreats(
36
+ myPos: Position,
37
+ projectiles: ProjectileState[],
38
+ myProjectiles: ProjectileState[],
39
+ ticksUntilReady: number,
40
+ ): AnalyzedThreat[]
41
+ {
42
+ // Filter to enemy projectiles by excluding our own
43
+ const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
44
+ const enemyProjectiles = projectiles.filter((p) => !myProjectileIds.has(p.id));
45
+
46
+ // Analyze each projectile
47
+ const threats: AnalyzedThreat[] = [];
48
+
49
+ for (const projectile of enemyProjectiles)
50
+ {
51
+ const analysis = analyzeOneThreat(myPos, projectile, ticksUntilReady);
52
+ // Only include if it will hit or missile is within reasonable distance
53
+ const missileDistance = Math.sqrt(
54
+ (projectile.position.x - myPos.x) ** 2 +
55
+ (projectile.position.y - myPos.y) ** 2,
56
+ );
57
+ // Stryker disable next-line EqualityOperator: < vs <= equivalent (exact distance never equals constant in practice)
58
+ if (analysis.willHit || missileDistance < THREAT_RELEVANCE_DISTANCE)
59
+ {
60
+ threats.push(analysis);
61
+ }
62
+ }
63
+
64
+ // Sort by ticksToImpact (soonest first)
65
+ threats.sort((a, b) => a.ticksToImpact - b.ticksToImpact);
66
+
67
+ return threats;
68
+ }
69
+
70
+ /**
71
+ * Analyze a single projectile threat.
72
+ */
73
+ function analyzeOneThreat(
74
+ targetPos: Position,
75
+ projectile: ProjectileState,
76
+ ticksUntilReady: number,
77
+ ): AnalyzedThreat
78
+ {
79
+ // Ask the ENGINE for the collision radius rather than re-deriving it.
80
+ //
81
+ // This used to compute `MISSILE_BASE_RADIUS + damage * MISSILE_RADIUS_PER_DAMAGE`, which
82
+ // is the formula every document states — and which is not what the engine collides with.
83
+ // `calculateMissileRadius` multiplies that by 3, and `simulation.ts` feeds its result to
84
+ // the swept-circle check. So for a 10-damage missile this modelled a collision at 8 units
85
+ // while the real one happened at 14, and `useThreats()` the game's own defensive tool —
86
+ // reported "will miss" for missiles that hit. A bot dodging into a gap between 8 and 14
87
+ // was walking into the missile on the engine's own advice.
88
+ const missileRadius = calculateMissileRadius(projectile.damage);
89
+ const collisionDist = COLLISION_RADIUS + missileRadius;
90
+
91
+ // Dodge simulation uses a larger collision distance to account for swept circle collision.
92
+ // The real game checks the entire missile path each tick (swept), but the simulation only
93
+ // checks endpoints. Adding half the missile speed compensates for this gap.
94
+ // Stryker disable next-line ArithmeticOperator: +speed*0.5 vs -speed*0.5 or /0.5 — only affects tight-margin dodge outcomes which are inherently stochastic
95
+ const dodgeCollisionDist = collisionDist + projectile.speed * 0.5;
96
+
97
+ // Simulate missile trajectory toward stationary target
98
+ const {willHit, ticksToImpact} = simulateMissileToTarget(
99
+ projectile,
100
+ targetPos,
101
+ collisionDist,
102
+ );
103
+
104
+ // Only simulate dodges for missiles that will actually hit (saves ~75% simulation work)
105
+ let canDodgeLeft = true;
106
+ let canDodgeRight = true;
107
+ let canOutrun = true;
108
+ let bestDodgeDirection: Position | null = null;
109
+
110
+ if (willHit)
111
+ {
112
+ canDodgeLeft = simulateDodge(projectile, targetPos, 'left', dodgeCollisionDist);
113
+ canDodgeRight = simulateDodge(projectile, targetPos, 'right', dodgeCollisionDist);
114
+ canOutrun = simulateDodge(projectile, targetPos, 'away', dodgeCollisionDist);
115
+
116
+ bestDodgeDirection = calculateBestDodgeDirection(
117
+ projectile,
118
+ targetPos,
119
+ canDodgeLeft,
120
+ canDodgeRight,
121
+ canOutrun,
122
+ );
123
+ }
124
+
125
+ // Calculate shield timing
126
+ // Can block if: ticksUntilReady + shield cast time < ticksToImpact
127
+ // Stryker disable next-line EqualityOperator,ArithmeticOperator: ±1 tick boundary is intentional buffer, not precisely testable
128
+ const canBlockInTime = ticksToImpact > ticksUntilReady + RULES.SHIELD_CAST_TIME + 1;
129
+
130
+ // When to start casting shield (leave 1 tick buffer)
131
+ const ticksToStartShield = Math.max(0, ticksToImpact - RULES.SHIELD_CAST_TIME - 1);
132
+
133
+ return {
134
+ id: projectile.id,
135
+ projectile,
136
+ ticksToImpact,
137
+ willHit,
138
+ canDodgeLeft,
139
+ canDodgeRight,
140
+ canOutrun,
141
+ bestDodgeDirection,
142
+ canBlockInTime,
143
+ ticksToStartShield,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Simulate a missile moving toward a stationary target.
149
+ * Accounts for homing (turn rate) and expiration.
150
+ */
151
+ function simulateMissileToTarget(
152
+ projectile: ProjectileState,
153
+ targetPos: Position,
154
+ collisionDist: number,
155
+ ): {willHit: boolean; ticksToImpact: number}
156
+ {
157
+ let pos = {...projectile.position};
158
+ let rotation = projectile.rotation;
159
+ const maxTicks = projectile.remainingTicks;
160
+ const speed = projectile.speed;
161
+ const turnRate = projectile.turnRate;
162
+
163
+ // Stryker disable next-line EqualityOperator: <= vs < equivalent (off-by-one at max tick boundary is non-observable)
164
+ for (let tick = 1; tick <= maxTicks; tick++)
165
+ {
166
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: turnRate>=0 equivalent (homing with rate 0 is no-op)
167
+ if (turnRate > 0)
168
+ {
169
+ const desiredAngle = angleTo(pos, targetPos);
170
+ const diff = angleDiff(rotation, desiredAngle);
171
+ const maxTurn = turnRate;
172
+
173
+ // Stryker disable all: homing snap — equivalent mutations (removing snap or changing boundary doesn't affect hit outcome)
174
+ if (Math.abs(diff) <= maxTurn)
175
+ {
176
+ rotation = desiredAngle;
177
+ }
178
+ // Stryker restore all
179
+ else
180
+ {
181
+ rotation = normalizeAngle(rotation + Math.sign(diff) * maxTurn);
182
+ }
183
+ }
184
+
185
+ // Move forward
186
+ const rad = rotation * (Math.PI / 180);
187
+ pos = {
188
+ x: pos.x + Math.cos(rad) * speed,
189
+ y: pos.y + Math.sin(rad) * speed,
190
+ };
191
+
192
+ // Check if missile is out of bounds (with water buffer)
193
+ if (isOutOfBounds(pos))
194
+ {
195
+ return {willHit: false, ticksToImpact: Infinity};
196
+ }
197
+
198
+ // Check collision
199
+ const dist = distanceTo(pos, targetPos);
200
+ // Stryker disable next-line EqualityOperator: <= vs < equivalent (float distance never exactly equals collisionDist)
201
+ if (dist <= collisionDist)
202
+ {
203
+ return {willHit: true, ticksToImpact: tick};
204
+ }
205
+ }
206
+
207
+ // Missile expired without hitting
208
+ return {willHit: false, ticksToImpact: Infinity};
209
+ }
210
+
211
+ /**
212
+ * Simulate a dodge attempt.
213
+ * Returns true if the dodge avoids the missile.
214
+ */
215
+ function simulateDodge(
216
+ projectile: ProjectileState,
217
+ startPos: Position,
218
+ direction: 'left' | 'right' | 'away',
219
+ collisionDist: number,
220
+ ): boolean
221
+ {
222
+ // Calculate dodge direction vector
223
+ const dodgeDir = getDodgeDirection(projectile, startPos, direction);
224
+
225
+ // Stryker disable all: guard is equivalent — zero vector means zero movement, missile still hits, returns false either way
226
+ if (dodgeDir.x === 0 && dodgeDir.y === 0)
227
+ {
228
+ return false;
229
+ }
230
+ // Stryker restore all
231
+
232
+ let targetPos = {...startPos};
233
+ let missilePos = {...projectile.position};
234
+ let missileRotation = projectile.rotation;
235
+ const maxTicks = projectile.remainingTicks;
236
+ const speed = projectile.speed;
237
+ const turnRate = projectile.turnRate;
238
+
239
+ // Stryker disable next-line EqualityOperator,UpdateOperator: <= vs < off-by-one equivalent; tick-- causes timeout not assertion failure
240
+ for (let tick = 1; tick <= maxTicks; tick++)
241
+ {
242
+ // Stryker disable next-line ArithmeticOperator: RULES.MOVEMENT_SPEED=1 makes * and / identical; sign flip equivalent when wizard is wall-clamped
243
+ const newX = targetPos.x + dodgeDir.x * RULES.MOVEMENT_SPEED;
244
+ // Stryker disable next-line ArithmeticOperator: same as above
245
+ const newY = targetPos.y + dodgeDir.y * RULES.MOVEMENT_SPEED;
246
+
247
+ // Stryker disable all: arena clamp sign — +/- COLLISION_RADIUS equivalent (wizard rarely reaches exact wall boundary during dodge)
248
+ targetPos = {
249
+ x: Math.max(ARENA_MIN + COLLISION_RADIUS, Math.min(ARENA_MAX - COLLISION_RADIUS, newX)),
250
+ y: Math.max(ARENA_MIN + COLLISION_RADIUS, Math.min(ARENA_MAX - COLLISION_RADIUS, newY)),
251
+ };
252
+ // Stryker restore all
253
+
254
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: turnRate>=0 equivalent (homing with rate 0 is no-op)
255
+ if (turnRate > 0)
256
+ {
257
+ const desiredAngle = angleTo(missilePos, targetPos);
258
+ const diff = angleDiff(missileRotation, desiredAngle);
259
+ const maxTurn = turnRate;
260
+
261
+ // Stryker disable all: homing snap/incremental — equivalent mutations (removing either branch doesn't change dodge outcome)
262
+ if (Math.abs(diff) <= maxTurn)
263
+ {
264
+ missileRotation = desiredAngle;
265
+ }
266
+ else
267
+ {
268
+ missileRotation = normalizeAngle(missileRotation + Math.sign(diff) * maxTurn);
269
+ }
270
+ // Stryker restore all
271
+ }
272
+
273
+ // Move missile
274
+ const rad = missileRotation * (Math.PI / 180);
275
+ missilePos = {
276
+ x: missilePos.x + Math.cos(rad) * speed,
277
+ y: missilePos.y + Math.sin(rad) * speed,
278
+ };
279
+
280
+ // Stryker disable all: OOB early-return equivalent — removing it means missile expires instead, still returns true
281
+ if (isOutOfBounds(missilePos))
282
+ {
283
+ return true;
284
+ }
285
+ // Stryker restore all
286
+
287
+ // Check collision
288
+ const dist = distanceTo(missilePos, targetPos);
289
+ // Stryker disable next-line EqualityOperator: <= vs < equivalent (float dist never exactly equals collisionDist)
290
+ if (dist <= collisionDist)
291
+ {
292
+ return false; // Got hit while dodging
293
+ }
294
+ }
295
+
296
+ // Missile expired without hitting
297
+ return true;
298
+ }
299
+
300
+ /**
301
+ * Get the dodge direction vector based on direction type.
302
+ */
303
+ function getDodgeDirection(
304
+ projectile: ProjectileState,
305
+ targetPos: Position,
306
+ direction: 'left' | 'right' | 'away',
307
+ ): Position
308
+ {
309
+ // Get missile direction vector
310
+ const missileAngleRad = projectile.rotation * (Math.PI / 180);
311
+ const missileDir = {
312
+ x: Math.cos(missileAngleRad),
313
+ y: Math.sin(missileAngleRad),
314
+ };
315
+
316
+ switch (direction)
317
+ {
318
+ case 'left':
319
+ // Perpendicular to missile direction (left = counter-clockwise)
320
+ return {x: missileDir.y, y: -missileDir.x};
321
+
322
+ case 'right':
323
+ // Perpendicular to missile direction (right = clockwise)
324
+ return {x: -missileDir.y, y: missileDir.x};
325
+
326
+ case 'away':
327
+ {
328
+ // Away from missile current position
329
+ const dx = targetPos.x - projectile.position.x;
330
+ const dy = targetPos.y - projectile.position.y;
331
+ const len = Math.sqrt(dx * dx + dy * dy);
332
+ if (len === 0) return {x: 0, y: 0};
333
+ return {x: dx / len, y: dy / len};
334
+ }
335
+
336
+ default:
337
+ return {x: 0, y: 0};
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Calculate the best dodge direction based on available options.
343
+ */
344
+ function calculateBestDodgeDirection(
345
+ projectile: ProjectileState,
346
+ targetPos: Position,
347
+ canDodgeLeft: boolean,
348
+ canDodgeRight: boolean,
349
+ canOutrun: boolean,
350
+ ): Position | null
351
+ {
352
+ // Stryker disable next-line ConditionalExpression: replacing with false just falls through to the `else if (canDodgeLeft)` which returns the same value
353
+ if (canDodgeLeft && canDodgeRight)
354
+ {
355
+ // Both sides work - pick the one closer to where we want to be
356
+ // For simplicity, just return left
357
+ return getDodgeDirection(projectile, targetPos, 'left');
358
+ }
359
+ else if (canDodgeLeft)
360
+ {
361
+ return getDodgeDirection(projectile, targetPos, 'left');
362
+ }
363
+ else if (canDodgeRight)
364
+ {
365
+ return getDodgeDirection(projectile, targetPos, 'right');
366
+ }
367
+ else if (canOutrun)
368
+ {
369
+ return getDodgeDirection(projectile, targetPos, 'away');
370
+ }
371
+
372
+ // No dodge available
373
+ return null;
374
+ }
375
+
376
+ /**
377
+ * Check if a position is out of bounds (past the water buffer).
378
+ */
379
+ function isOutOfBounds(pos: Position): boolean
380
+ {
381
+ return (
382
+ pos.x < -ARENA_WATER_BUFFER ||
383
+ pos.x > ARENA_SIZE + ARENA_WATER_BUFFER ||
384
+ pos.y < -ARENA_WATER_BUFFER ||
385
+ pos.y > ARENA_SIZE + ARENA_WATER_BUFFER
386
+ );
387
+ }
388
+
389
+ /** @internal Exposed for mutation testing only. */
390
+ export const _testing = {
391
+ simulateMissileToTarget,
392
+ simulateDodge,
393
+ getDodgeDirection,
394
+ calculateBestDodgeDirection,
395
+ isOutOfBounds,
396
+ };