@vibemancer/core 0.1.0

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 (78) hide show
  1. package/README.md +28 -0
  2. package/dist/chunk-L7Z7OFXD.js +9140 -0
  3. package/dist/chunk-L7Z7OFXD.js.map +1 -0
  4. package/dist/index-browser.d.ts +2602 -0
  5. package/dist/index-browser.js +407 -0
  6. package/dist/index-browser.js.map +1 -0
  7. package/dist/index.d.ts +150 -0
  8. package/dist/index.js +750 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +79 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +457 -0
  12. package/src/bots/berserker/02_Stormcaller.ts +417 -0
  13. package/src/bots/berserker/03_Stormforger.ts +481 -0
  14. package/src/bots/caster/01_Flamecaller.ts +286 -0
  15. package/src/bots/caster/02_Pyromancer.ts +350 -0
  16. package/src/bots/caster/03_Infernalist.ts +492 -0
  17. package/src/bots/defensive/01_Turtle.ts +151 -0
  18. package/src/bots/defensive/02_Sentinel.ts +134 -0
  19. package/src/bots/defensive/03_Golem.ts +357 -0
  20. package/src/bots/duelist/01_Battlemage.ts +433 -0
  21. package/src/bots/duelist/02_Warmage.ts +438 -0
  22. package/src/bots/duelist/03_Archmage.ts +588 -0
  23. package/src/bots/homing/01_Bonemancer.ts +67 -0
  24. package/src/bots/homing/02_Lich.ts +356 -0
  25. package/src/bots/homing/03_Archlich.ts +220 -0
  26. package/src/bots/index.ts +30 -0
  27. package/src/bots/kiter/01_Spellspinner.ts +398 -0
  28. package/src/bots/kiter/02_Spellweaver.ts +378 -0
  29. package/src/bots/kiter/03_Spellbinder.ts +448 -0
  30. package/src/bots/melee/01_Shadowblade.ts +270 -0
  31. package/src/bots/melee/02_Nightblade.ts +437 -0
  32. package/src/bots/melee/03_Voidblade.ts +582 -0
  33. package/src/bots/registry.ts +207 -0
  34. package/src/bots/shared.ts +472 -0
  35. package/src/bots/sniper/01_Spellshot.ts +385 -0
  36. package/src/bots/sniper/02_Spelltracer.ts +441 -0
  37. package/src/bots/sniper/03_Spellseeker.ts +546 -0
  38. package/src/bots/standalone/Critter.ts +89 -0
  39. package/src/bots/standalone/Doombringer.ts +91 -0
  40. package/src/bots/standalone/Hogger.ts +228 -0
  41. package/src/bots/standalone/Rookie.ts +50 -0
  42. package/src/bots/standalone/TargetDummy.ts +21 -0
  43. package/src/bots/test/cheater.ts +405 -0
  44. package/src/bots/test/crasher.ts +81 -0
  45. package/src/engine/hooks-runtime.ts +394 -0
  46. package/src/engine/manual-match.ts +289 -0
  47. package/src/engine/missile-templates.ts +155 -0
  48. package/src/engine/optimizer.ts +220 -0
  49. package/src/engine/params-runtime.ts +189 -0
  50. package/src/engine/physics.ts +143 -0
  51. package/src/engine/sandbox-browser.ts +671 -0
  52. package/src/engine/sandbox-compile.ts +197 -0
  53. package/src/engine/sandbox-harness.ts +367 -0
  54. package/src/engine/sandbox.ts +332 -0
  55. package/src/engine/simulation.ts +828 -0
  56. package/src/engine/spells.ts +128 -0
  57. package/src/engine-version.ts +11 -0
  58. package/src/hooks/action-builders.ts +210 -0
  59. package/src/hooks/bot-wrapper.ts +84 -0
  60. package/src/hooks/index.ts +75 -0
  61. package/src/hooks/state-hooks.ts +354 -0
  62. package/src/hooks/threat-analysis.ts +365 -0
  63. package/src/hooks/types.ts +142 -0
  64. package/src/index-browser.ts +30 -0
  65. package/src/index.ts +24 -0
  66. package/src/rules.ts +254 -0
  67. package/src/stats.ts +262 -0
  68. package/src/testing.ts +207 -0
  69. package/src/trace.ts +430 -0
  70. package/src/types.ts +193 -0
  71. package/src/utils/angles.ts +47 -0
  72. package/src/utils/combat.ts +279 -0
  73. package/src/utils/distance.ts +21 -0
  74. package/src/utils/index.ts +7 -0
  75. package/src/utils/movement.ts +108 -0
  76. package/src/utils/random.ts +65 -0
  77. package/src/utils/spatial.ts +63 -0
  78. package/src/utils/targeting.ts +45 -0
@@ -0,0 +1,279 @@
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
+ WIZARD_HEALTH,
14
+ MISSILE_BASE_CAST,
15
+ MISSILE_DAMAGE_SCALE,
16
+ MISSILE_DAMAGE_POWER,
17
+ MISSILE_HOMING_COEFF,
18
+ MISSILE_TURN_DURATION_COEFF,
19
+ MISSILE_SPEED_DURATION_COEFF,
20
+ MISSILE_SPEED_DURATION_BASELINE,
21
+ MISSILE_MIN_CAST_TIME,
22
+ MISSILE_MIN_DURATION,
23
+ MISSILE_MIN_DAMAGE,
24
+ effectiveTurnRateCost,
25
+ } from '../rules.js';
26
+
27
+ /**
28
+ * Get cast time in ticks for a missile configuration.
29
+ * Applies the same clamps/validation as the engine before calculating,
30
+ * so the result matches the actual cast time that will be used in-game.
31
+ *
32
+ * If lastMissileConfig is provided, includes warmup multiplier.
33
+ * Pass undefined for first cast (full warmup) or null for base time only.
34
+ */
35
+ export function getMissileCastTime(config: MissileConfig, lastMissileConfig?: MissileConfig | null): number
36
+ {
37
+ const validated = validateMissileConfig(config);
38
+ return Math.ceil(calculateMissileCastTime(validated, lastMissileConfig) * TICKS_PER_SECOND);
39
+ }
40
+
41
+ /**
42
+ * Calculate the position to aim at to hit a moving target.
43
+ * Returns the intercept point where a missile would hit the target.
44
+ *
45
+ * @param targetPos - Current target position
46
+ * @param targetVel - Target velocity (units per tick)
47
+ * @param missileSpeed - Missile speed (units per tick)
48
+ * @param myPos - Shooter position
49
+ * @returns The position to aim at
50
+ */
51
+ export function getLeadPosition(
52
+ targetPos: Position,
53
+ targetVel: Velocity,
54
+ missileSpeed: number,
55
+ myPos: Position,
56
+ ): Position
57
+ {
58
+ // Vector from shooter to target
59
+ const dx = targetPos.x - myPos.x;
60
+ const dy = targetPos.y - myPos.y;
61
+
62
+ // Target velocity
63
+ const vx = targetVel.x;
64
+ const vy = targetVel.y;
65
+
66
+ // Quadratic coefficients for intercept time
67
+ // |target + velocity * t - shooter|^2 = (missileSpeed * t)^2
68
+ // Expands to: (vx^2 + vy^2 - speed^2) * t^2 + 2*(vx*dx + vy*dy) * t + (dx^2 + dy^2) = 0
69
+ const a = vx * vx + vy * vy - missileSpeed * missileSpeed;
70
+ const b = 2 * (vx * dx + vy * dy);
71
+ const c = dx * dx + dy * dy;
72
+
73
+ let t: number | null = null;
74
+
75
+ // Handle the linear case (|velocity| === missileSpeed)
76
+ if (Math.abs(a) < 1e-10)
77
+ {
78
+ if (Math.abs(b) > 1e-10)
79
+ {
80
+ const linearT = -c / b;
81
+ if (linearT > 0)
82
+ {
83
+ t = linearT;
84
+ }
85
+ }
86
+ }
87
+ else
88
+ {
89
+ // Quadratic case
90
+ const discriminant = b * b - 4 * a * c;
91
+
92
+ if (discriminant >= 0)
93
+ {
94
+ const sqrtD = Math.sqrt(discriminant);
95
+ const t1 = (-b + sqrtD) / (2 * a);
96
+ const t2 = (-b - sqrtD) / (2 * a);
97
+
98
+ if (t1 > 0 && t2 > 0)
99
+ {
100
+ t = Math.min(t1, t2);
101
+ }
102
+ else if (t1 > 0)
103
+ {
104
+ t = t1;
105
+ }
106
+ else if (t2 > 0)
107
+ {
108
+ t = t2;
109
+ }
110
+ }
111
+ }
112
+
113
+ // If no intercept solution, just aim at current position
114
+ if (t === null)
115
+ {
116
+ return {x: targetPos.x, y: targetPos.y};
117
+ }
118
+
119
+ // Calculate intercept position
120
+ return {
121
+ x: targetPos.x + vx * t,
122
+ y: targetPos.y + vy * t,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Calculate optimal missile configuration based on target behavior.
128
+ */
129
+ export function getAdaptiveMissileConfig(
130
+ targetVelocity: Position,
131
+ distance: number,
132
+ ): {speed: number; turnRate: number; damage: number; duration: number}
133
+ {
134
+ const targetSpeed = Math.sqrt(targetVelocity.x ** 2 + targetVelocity.y ** 2);
135
+
136
+ // Fast moving target = more homing
137
+ // Slow/stationary target = faster missile, light homing for correction
138
+ if (targetSpeed > 0.8)
139
+ {
140
+ // Moving target - use significant homing
141
+ const turnRate = Math.min(3, 1 + targetSpeed * 1.5);
142
+ return {
143
+ damage: 10,
144
+ speed: 5,
145
+ turnRate,
146
+ duration: Math.min(250, 100 + distance * 0.5),
147
+ };
148
+ }
149
+ else
150
+ {
151
+ // Stationary/slow target - faster missile with light homing for accuracy
152
+ return {
153
+ damage: 10,
154
+ speed: 6,
155
+ turnRate: 1, // Light homing to correct any aiming errors
156
+ duration: 180,
157
+ };
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Given a cast-time budget (in ticks) and a target distance, find the best
163
+ * missile config that fits. Maximizes damage while ensuring the missile
164
+ * can reach the target and finishes casting in time.
165
+ *
166
+ * Returns null if no useful missile fits in the budget.
167
+ *
168
+ * How it works: tries several speed/turnRate templates. For each, calculates
169
+ * the minimum duration to reach `distance`, then solves the cast-time formula
170
+ * for the maximum damage that fits within `budgetTicks`.
171
+ *
172
+ * If `lastMissileConfig` is provided, accounts for warmup bonus: similar
173
+ * missiles cast faster, so more damage can fit in the same budget.
174
+ */
175
+ export function fitMissileToBudget(
176
+ budgetTicks: number,
177
+ distance: number,
178
+ options?: {minTurnRate?: number; maxDamage?: number; lastMissileConfig?: MissileConfig},
179
+ ): MissileConfig | null
180
+ {
181
+ const budgetSec = budgetTicks / TICKS_PER_SECOND;
182
+ const minTurnRate = options?.minTurnRate ?? 0;
183
+ const maxDamage = options?.maxDamage ?? WIZARD_HEALTH;
184
+ // Warmup is active when caller explicitly passes lastMissileConfig in options.
185
+ // Value can be undefined (first cast = full warmup) or MissileConfig (similarity-based).
186
+ // When key is absent (old callers), no warmup is applied.
187
+ const warmupActive = options !== undefined && 'lastMissileConfig' in options;
188
+ const lastMissile = options?.lastMissileConfig;
189
+
190
+ // Minimum useful cast time is 10 ticks (0.1s)
191
+ if (budgetSec < MISSILE_MIN_CAST_TIME)
192
+ {
193
+ return null;
194
+ }
195
+
196
+ // Templates: [speed, turnRate] combos to try, ordered by preference.
197
+ // Lower turnRate = cheaper cast. Higher speed = shorter duration needed.
198
+ // Negative turnRate = anti-homing (turns away from target) but cheaper cast time.
199
+ const templates: [number, number][] = [
200
+ [7, -1], // Fast anti-homing: cheapest cast, curves away from target
201
+ [7, -0.5],// Fast light anti-homing
202
+ [7, 0], // Fast straight: cheapest cast, only hits stationary
203
+ [5, 0], // Medium straight
204
+ [6, 0.5], // Fast with light tracking
205
+ [5, 1], // Medium with tracking
206
+ [4, 1], // Slow with tracking (long range)
207
+ [4, 1.5], // Slow with strong tracking
208
+ [3, 2], // Very slow with heavy tracking (for aggressive homing)
209
+ ];
210
+
211
+ let best: MissileConfig | null = null;
212
+ let bestDamage = 0;
213
+
214
+ for (const [speed, turnRate] of templates)
215
+ {
216
+ if (turnRate < minTurnRate) continue;
217
+ // Minimum duration to reach target (with 20% margin for homing curve)
218
+ const margin = turnRate > 0 ? 1.2 : 1.05;
219
+ const minDurationTicks = Math.max(
220
+ MISSILE_MIN_DURATION,
221
+ Math.ceil(distance * margin / speed),
222
+ );
223
+ const durationSec = minDurationTicks / TICKS_PER_SECOND;
224
+
225
+ // Solve cast formula for damage:
226
+ // budgetSec = BASE + SCALE * d^POWER + delivery costs
227
+ // → d = ((budgetSec - fixedCost) / SCALE)^(1/POWER)
228
+ const turnCost = effectiveTurnRateCost(turnRate);
229
+ const fixedCost = MISSILE_BASE_CAST
230
+ + MISSILE_HOMING_COEFF * turnCost
231
+ + MISSILE_TURN_DURATION_COEFF * (turnCost * durationSec)
232
+ + MISSILE_SPEED_DURATION_COEFF * (speed * durationSec - MISSILE_SPEED_DURATION_BASELINE);
233
+
234
+ // Estimate warmup multiplier for this template (depends on speed/turnRate/duration,
235
+ // not damage, so we can estimate before solving for damage)
236
+ let effectiveBudgetSec = budgetSec;
237
+ if (warmupActive && lastMissile)
238
+ {
239
+ const probe: MissileConfig = {damage: lastMissile.damage, speed, turnRate, duration: minDurationTicks};
240
+ const multiplier = calculateWarmupMultiplier(lastMissile, probe);
241
+ // multiplier < 1 = faster (more budget), > 1 = slower (less budget)
242
+ effectiveBudgetSec = budgetSec / multiplier;
243
+ }
244
+
245
+ const remaining = effectiveBudgetSec - fixedCost;
246
+ if (remaining <= 0)
247
+ {
248
+ continue;
249
+ }
250
+
251
+ const maxDamageFloat = Math.pow(remaining / MISSILE_DAMAGE_SCALE, 1 / MISSILE_DAMAGE_POWER);
252
+ const damage = Math.min(maxDamage, Math.floor(maxDamageFloat));
253
+
254
+ if (damage < MISSILE_MIN_DAMAGE)
255
+ {
256
+ continue;
257
+ }
258
+
259
+ // Verify actual cast time with real warmup (using final damage)
260
+ const candidate: MissileConfig = {damage, speed, turnRate, duration: minDurationTicks};
261
+ const actualCastSec = (warmupActive && lastMissile)
262
+ ? calculateMissileCastTime(candidate, lastMissile)
263
+ : calculateMissileCastTime(candidate);
264
+ const actualCastTicks = Math.max(1, Math.ceil(actualCastSec * TICKS_PER_SECOND));
265
+
266
+ if (actualCastTicks > budgetTicks)
267
+ {
268
+ continue;
269
+ }
270
+
271
+ if (damage > bestDamage)
272
+ {
273
+ bestDamage = damage;
274
+ best = candidate;
275
+ }
276
+ }
277
+
278
+ return best;
279
+ }
@@ -0,0 +1,21 @@
1
+ import {Position} from '../types.js';
2
+
3
+ /**
4
+ * Calculate the distance between two points.
5
+ */
6
+ export function distanceTo(a: Position, b: Position): number
7
+ {
8
+ const dx = b.x - a.x;
9
+ const dy = b.y - a.y;
10
+ return Math.sqrt(dx * dx + dy * dy);
11
+ }
12
+
13
+ /**
14
+ * Check if two points are within a certain range of each other.
15
+ */
16
+ export function inRange(a: Position, b: Position, range: number): boolean
17
+ {
18
+ const dx = b.x - a.x;
19
+ const dy = b.y - a.y;
20
+ return (dx * dx + dy * dy) <= range * range;
21
+ }
@@ -0,0 +1,7 @@
1
+ export * from './distance.js';
2
+ export * from './angles.js';
3
+ export * from './movement.js';
4
+ export * from './targeting.js';
5
+ export * from './random.js';
6
+ export * from './spatial.js';
7
+ export * from './combat.js';
@@ -0,0 +1,108 @@
1
+ import {Position, Velocity} from '../types.js';
2
+ import {normalizeAngle} from './angles.js';
3
+
4
+ /**
5
+ * Get the position after moving a certain distance in a direction.
6
+ */
7
+ export function moveInDirection(position: Position, angle: number, distance: number): Position
8
+ {
9
+ const radians = angle * (Math.PI / 180);
10
+ return {
11
+ x: position.x + Math.cos(radians) * distance,
12
+ y: position.y + Math.sin(radians) * distance,
13
+ };
14
+ }
15
+
16
+ /**
17
+ * Predict the position after N ticks given current velocity.
18
+ */
19
+ export function predictPosition(position: Position, velocity: Velocity, ticks: number): Position
20
+ {
21
+ return {
22
+ x: position.x + velocity.x * ticks,
23
+ y: position.y + velocity.y * ticks,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Calculate the intercept angle for a target moving at a certain velocity.
29
+ * Returns null if no intercept solution exists.
30
+ */
31
+ export function interceptAngle(
32
+ shooterPosition: Position,
33
+ targetPosition: Position,
34
+ targetVelocity: Velocity,
35
+ projectileSpeed: number,
36
+ ): number | null
37
+ {
38
+ const dx = targetPosition.x - shooterPosition.x;
39
+ const dy = targetPosition.y - shooterPosition.y;
40
+
41
+ const vx = targetVelocity.x;
42
+ const vy = targetVelocity.y;
43
+
44
+ const a = vx * vx + vy * vy - projectileSpeed * projectileSpeed;
45
+ const b = 2 * (vx * dx + vy * dy);
46
+ const c = dx * dx + dy * dy;
47
+
48
+ let t: number | null = null;
49
+
50
+ // Handle the linear case when a is approximately zero
51
+ // This happens when |targetVelocity| === projectileSpeed
52
+ if (Math.abs(a) < 1e-10)
53
+ {
54
+ // Linear equation: b*t + c = 0 => t = -c/b
55
+ if (Math.abs(b) < 1e-10)
56
+ {
57
+ // No solution possible
58
+ return null;
59
+ }
60
+ const linearT = -c / b;
61
+ if (linearT > 0)
62
+ {
63
+ t = linearT;
64
+ }
65
+ }
66
+ else
67
+ {
68
+ // Quadratic case
69
+ const discriminant = b * b - 4 * a * c;
70
+
71
+ if (discriminant < 0)
72
+ {
73
+ return null;
74
+ }
75
+
76
+ const sqrtD = Math.sqrt(discriminant);
77
+ const t1 = (-b + sqrtD) / (2 * a);
78
+ const t2 = (-b - sqrtD) / (2 * a);
79
+
80
+ if (t1 > 0 && t2 > 0)
81
+ {
82
+ t = Math.min(t1, t2);
83
+ }
84
+ else if (t1 > 0)
85
+ {
86
+ t = t1;
87
+ }
88
+ else if (t2 > 0)
89
+ {
90
+ t = t2;
91
+ }
92
+ }
93
+
94
+ if (t === null)
95
+ {
96
+ return null;
97
+ }
98
+
99
+ const interceptPos = {
100
+ x: targetPosition.x + vx * t,
101
+ y: targetPosition.y + vy * t,
102
+ };
103
+
104
+ const dx2 = interceptPos.x - shooterPosition.x;
105
+ const dy2 = interceptPos.y - shooterPosition.y;
106
+
107
+ return normalizeAngle(Math.atan2(dy2, dx2) * (180 / Math.PI));
108
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Seeded PRNG utilities for deterministic randomness.
3
+ *
4
+ * Each entity (wizard, missile) gets its own random sequence that:
5
+ * - Is deterministic: same seed = same sequence
6
+ * - Is isolated: one entity's calls don't affect another's
7
+ * - Advances state: each call produces a different value
8
+ */
9
+
10
+ const MAX_INT = 0x7fffffff;
11
+
12
+ /**
13
+ * Combine two seeds into one using a simple hash.
14
+ */
15
+ export function hashCombine(a: number, b: number): number
16
+ {
17
+ // Simple hash combining two integers
18
+ let hash = a;
19
+ hash = ((hash << 5) + hash) ^ b;
20
+ return hash & MAX_INT;
21
+ }
22
+
23
+ /**
24
+ * Advance the random state using a Linear Congruential Generator.
25
+ * Parameters from glibc (widely tested).
26
+ */
27
+ export function nextRandom(state: number): number
28
+ {
29
+ return (state * 1103515245 + 12345) & MAX_INT;
30
+ }
31
+
32
+ /**
33
+ * Create a seeded random number generator.
34
+ * Returns a function that produces values in [0, 1) and advances internal state.
35
+ */
36
+ export function createRandom(seed: number): () => number
37
+ {
38
+ let state = seed & MAX_INT;
39
+
40
+ return (): number =>
41
+ {
42
+ state = nextRandom(state);
43
+ return state / MAX_INT;
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Create a deterministic seed for an entity based on match seed, entity ID, and tick.
49
+ * This ensures reproducibility: same match + same entity + same tick = same random sequence.
50
+ */
51
+ export function createEntitySeed(matchSeed: number, entityId: string, tick: number): number
52
+ {
53
+ // Hash the entity ID string into a number
54
+ let entityHash = 0;
55
+ for (let i = 0; i < entityId.length; i++)
56
+ {
57
+ entityHash = ((entityHash << 5) + entityHash) ^ entityId.charCodeAt(i);
58
+ }
59
+
60
+ // Combine match seed, entity hash, and tick
61
+ let combined = hashCombine(matchSeed, entityHash & MAX_INT);
62
+ combined = hashCombine(combined, tick);
63
+
64
+ return combined;
65
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * VIBEMANCER - SPATIAL UTILITIES
3
+ *
4
+ * Direction vectors and arena bounds utilities.
5
+ */
6
+
7
+ import {Position} from '../types.js';
8
+ import {ARENA_WIDTH, ARENA_HEIGHT} from '../rules.js';
9
+
10
+ /**
11
+ * Normalize a vector to unit length.
12
+ * Returns {x: 0, y: 0} for zero-length vectors.
13
+ */
14
+ export function normalize(vector: Position): Position
15
+ {
16
+ const len = Math.sqrt(vector.x * vector.x + vector.y * vector.y);
17
+ if (len === 0)
18
+ {
19
+ return {x: 0, y: 0};
20
+ }
21
+ return {x: vector.x / len, y: vector.y / len};
22
+ }
23
+
24
+ /**
25
+ * Get normalized direction vector from one position toward another.
26
+ * Returns {x: 0, y: 0} if positions are identical.
27
+ */
28
+ export function directionTo(from: Position, to: Position): Position
29
+ {
30
+ const dx = to.x - from.x;
31
+ const dy = to.y - from.y;
32
+ return normalize({x: dx, y: dy});
33
+ }
34
+
35
+ /**
36
+ * Get normalized direction vector from one position away from another.
37
+ * Returns {x: 0, y: 0} if positions are identical.
38
+ */
39
+ export function directionAway(from: Position, to: Position): Position
40
+ {
41
+ const dir = directionTo(from, to);
42
+ return {x: -dir.x, y: -dir.y};
43
+ }
44
+
45
+ /**
46
+ * Clamp a position to valid arena bounds.
47
+ * Note: For wizard-specific clamping with radius, use clampToArena from physics.ts
48
+ */
49
+ export function clampPositionToArena(position: Position): Position
50
+ {
51
+ return {
52
+ x: Math.max(0, Math.min(ARENA_WIDTH, position.x)),
53
+ y: Math.max(0, Math.min(ARENA_HEIGHT, position.y)),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Get the length/magnitude of a vector.
59
+ */
60
+ export function magnitude(vector: Position): number
61
+ {
62
+ return Math.sqrt(vector.x * vector.x + vector.y * vector.y);
63
+ }
@@ -0,0 +1,45 @@
1
+ import {Position} from '../types.js';
2
+ import {distanceTo} from './distance.js';
3
+
4
+ /**
5
+ * Find the nearest entity from a list.
6
+ */
7
+ export function findNearest<T extends {position: Position}>(from: Position, entities: T[]): T | null
8
+ {
9
+ if (entities.length === 0)
10
+ {
11
+ return null;
12
+ }
13
+
14
+ let nearest = entities[0]!;
15
+ let minDistance = distanceTo(from, nearest.position);
16
+
17
+ for (let i = 1; i < entities.length; i++)
18
+ {
19
+ const entity = entities[i]!;
20
+ const distance = distanceTo(from, entity.position);
21
+ if (distance < minDistance)
22
+ {
23
+ minDistance = distance;
24
+ nearest = entity;
25
+ }
26
+ }
27
+
28
+ return nearest;
29
+ }
30
+
31
+ /**
32
+ * Find all entities within a certain range.
33
+ */
34
+ export function findInRange<T extends {position: Position}>(from: Position, entities: T[], range: number): T[]
35
+ {
36
+ return entities.filter((entity) => distanceTo(from, entity.position) <= range);
37
+ }
38
+
39
+ /**
40
+ * Sort entities by distance (closest first).
41
+ */
42
+ export function sortByDistance<T extends {position: Position}>(from: Position, entities: T[]): T[]
43
+ {
44
+ return [...entities].sort((a, b) => distanceTo(from, a.position) - distanceTo(from, b.position));
45
+ }