@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,289 @@
1
+ /**
2
+ * VIBEMANCER - MANUAL MATCH
3
+ *
4
+ * Tick-by-tick match runner used by manual play mode. Wraps the same
5
+ * `tick()` primitive that `simulate()` uses, so stepping forward
6
+ * produces the same trajectory as a batched `simulate()` with the
7
+ * same seed.
8
+ *
9
+ * Extra capabilities over `simulate()`:
10
+ * - step(N) advances N ticks at a time (default 1) — caller controls pacing
11
+ * - replaceMissileAI(id, ai) hot-swaps a missile's AI mid-flight (used by
12
+ * the missile-hijack feature in manual play)
13
+ * - setInvincible(wizardIndex, on) toggles damage immunity per wizard
14
+ */
15
+
16
+ import {
17
+ ARENA_HEIGHT,
18
+ ARENA_WIDTH,
19
+ MATCH_DURATION,
20
+ SPAWN_DISTANCE,
21
+ TICKS_PER_SECOND,
22
+ WIZARD_HEALTH,
23
+ } from '../rules.js';
24
+ import type {
25
+ GameConfig,
26
+ GameState,
27
+ MissileAIFunction,
28
+ ProjectileState,
29
+ WizardFunction,
30
+ } from '../types.js';
31
+ import {clearHooks} from './hooks-runtime.js';
32
+ import {
33
+ type BotError,
34
+ type InternalWizardState,
35
+ type MatchWinner,
36
+ type SimulateResult,
37
+ getPlayerState,
38
+ tick,
39
+ } from './simulation.js';
40
+
41
+ export interface ManualMatchOptions
42
+ {
43
+ seed?: number;
44
+ spawnDistance?: number;
45
+ maxTicks?: number;
46
+ }
47
+
48
+ export interface StepResult
49
+ {
50
+ gameState: GameState;
51
+ errors: BotError[];
52
+ done: boolean;
53
+ }
54
+
55
+ /**
56
+ * Build the initial pair of internal wizards for a match. Mirrors the
57
+ * inline setup in simulate() so both code paths produce identical state.
58
+ */
59
+ function createInitialWizards(spawnDistance: number): InternalWizardState[]
60
+ {
61
+ return [
62
+ {
63
+ id: 'wizard-1',
64
+ position: {x: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
65
+ rotation: 0,
66
+ health: WIZARD_HEALTH,
67
+ maxHealth: WIZARD_HEALTH,
68
+ state: 'idle',
69
+ blinkCooldown: 0,
70
+ velocity: {x: 0, y: 0},
71
+ damageDealt: 0,
72
+ damageTaken: 0,
73
+ lastHitTick: 0,
74
+ },
75
+ {
76
+ id: 'wizard-2',
77
+ position: {x: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
78
+ rotation: 180,
79
+ health: WIZARD_HEALTH,
80
+ maxHealth: WIZARD_HEALTH,
81
+ state: 'idle',
82
+ blinkCooldown: 0,
83
+ velocity: {x: 0, y: 0},
84
+ damageDealt: 0,
85
+ damageTaken: 0,
86
+ lastHitTick: 0,
87
+ },
88
+ ];
89
+ }
90
+
91
+ export class ManualMatch
92
+ {
93
+ private readonly wizard1AI: WizardFunction;
94
+ private readonly wizard2AI: WizardFunction;
95
+ private readonly seed: number;
96
+ private readonly maxTicks: number;
97
+ private readonly config: GameConfig;
98
+
99
+ private wizards: InternalWizardState[];
100
+ private projectiles: ProjectileState[] = [];
101
+ private missileAIs: Map<string, MissileAIFunction> = new Map();
102
+ /** First-replacement originals for hijacked missiles. Used by restoreMissileAI. */
103
+ private originalMissileAIs: Map<string, MissileAIFunction> = new Map();
104
+ private currentTick = 0;
105
+ private done = false;
106
+ private allErrors: BotError[] = [];
107
+ private history: GameState[];
108
+
109
+ constructor(wizard1AI: WizardFunction, wizard2AI: WizardFunction, options: ManualMatchOptions = {})
110
+ {
111
+ if (typeof wizard1AI !== 'function')
112
+ {
113
+ throw new Error('wizard1AI must be a function');
114
+ }
115
+ if (typeof wizard2AI !== 'function')
116
+ {
117
+ throw new Error('wizard2AI must be a function');
118
+ }
119
+
120
+ this.wizard1AI = wizard1AI;
121
+ this.wizard2AI = wizard2AI;
122
+
123
+ const {
124
+ seed: rawSeed = Date.now(),
125
+ spawnDistance: rawSpawn,
126
+ maxTicks: rawMaxTicks = MATCH_DURATION,
127
+ } = options;
128
+
129
+ this.seed = Number.isFinite(rawSeed) ? Math.floor(rawSeed) & 0x7FFFFFFF : Date.now() & 0x7FFFFFFF;
130
+ this.maxTicks = Number.isFinite(rawMaxTicks) && rawMaxTicks > 0
131
+ ? Math.min(Math.floor(rawMaxTicks), 100000)
132
+ : MATCH_DURATION;
133
+ const spawnDistance = Number.isFinite(rawSpawn) && rawSpawn! > 0
134
+ ? Math.min(rawSpawn!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100)
135
+ : SPAWN_DISTANCE;
136
+
137
+ this.config = Object.freeze({
138
+ arenaSize: Object.freeze({width: ARENA_WIDTH, height: ARENA_HEIGHT}),
139
+ tickRate: TICKS_PER_SECOND,
140
+ maxTicks: this.maxTicks,
141
+ });
142
+
143
+ // Hook state must start clean — bot useState/useRef from a previous
144
+ // match would otherwise leak in.
145
+ clearHooks('wizard-1');
146
+ clearHooks('wizard-2');
147
+
148
+ this.wizards = createInitialWizards(spawnDistance);
149
+ this.history = [getPlayerState(0, this.wizards, this.projectiles, 0)];
150
+ }
151
+
152
+ /**
153
+ * Advance the match by `count` ticks (default 1). Stops early if the
154
+ * match completes mid-batch.
155
+ */
156
+ step(count = 1): StepResult
157
+ {
158
+ const errorsThisCall: BotError[] = [];
159
+ for (let i = 0; i < count; i++)
160
+ {
161
+ if (this.done) break;
162
+ const result = tick(
163
+ this.currentTick,
164
+ this.wizard1AI,
165
+ this.wizard2AI,
166
+ this.config,
167
+ this.wizards,
168
+ this.projectiles,
169
+ this.missileAIs,
170
+ this.seed,
171
+ );
172
+ this.currentTick = result.nextTick;
173
+ this.wizards = result.wizards;
174
+ this.projectiles = result.projectiles;
175
+ if (result.errors.length > 0)
176
+ {
177
+ this.allErrors.push(...result.errors);
178
+ errorsThisCall.push(...result.errors);
179
+ }
180
+
181
+ this.history.push(getPlayerState(0, this.wizards, this.projectiles, this.currentTick));
182
+
183
+ if (
184
+ this.wizards[0]!.health <= 0
185
+ || this.wizards[1]!.health <= 0
186
+ || this.currentTick >= this.maxTicks
187
+ )
188
+ {
189
+ this.done = true;
190
+ }
191
+ }
192
+
193
+ return {
194
+ gameState: this.getGameState(),
195
+ errors: errorsThisCall,
196
+ done: this.done,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Hot-swap a missile's AI function. Used by the missile-hijack feature
202
+ * in manual play. The first replacement remembers the original AI so
203
+ * `restoreMissileAI` can put it back. Subsequent replacements update
204
+ * the active AI but leave the remembered original alone.
205
+ *
206
+ * No-op if the projectile id doesn't exist.
207
+ */
208
+ replaceMissileAI(projectileId: string, ai: MissileAIFunction): void
209
+ {
210
+ const existing = this.missileAIs.get(projectileId);
211
+ if (!existing) return;
212
+ if (!this.originalMissileAIs.has(projectileId))
213
+ {
214
+ this.originalMissileAIs.set(projectileId, existing);
215
+ }
216
+ this.missileAIs.set(projectileId, ai);
217
+ }
218
+
219
+ /**
220
+ * Restore a previously hijacked missile's original AI function.
221
+ * No-op if the projectile id doesn't exist or was never hijacked.
222
+ */
223
+ restoreMissileAI(projectileId: string): void
224
+ {
225
+ const original = this.originalMissileAIs.get(projectileId);
226
+ if (!original) return;
227
+ if (!this.missileAIs.has(projectileId)) return;
228
+ this.missileAIs.set(projectileId, original);
229
+ this.originalMissileAIs.delete(projectileId);
230
+ }
231
+
232
+ /**
233
+ * Set the invincibility flag for a wizard. Invincible wizards take 0
234
+ * damage from all sources.
235
+ */
236
+ setInvincible(wizardIndex: 0 | 1, on: boolean): void
237
+ {
238
+ const wizard = this.wizards[wizardIndex];
239
+ if (!wizard) return;
240
+ wizard.invincible = on;
241
+ }
242
+
243
+ /**
244
+ * Get the current game state from wizard-1's perspective.
245
+ * Returned object is a deep clone — safe to mutate.
246
+ */
247
+ getGameState(): GameState
248
+ {
249
+ return getPlayerState(0, this.wizards, this.projectiles, this.currentTick);
250
+ }
251
+
252
+ getCurrentTick(): number
253
+ {
254
+ return this.currentTick;
255
+ }
256
+
257
+ isComplete(): {done: boolean; winner: MatchWinner}
258
+ {
259
+ return {done: this.done, winner: this.done ? this.computeWinner() : null};
260
+ }
261
+
262
+ getResult(): SimulateResult
263
+ {
264
+ return {
265
+ winner: this.computeWinner(),
266
+ ticks: this.currentTick,
267
+ finalState: this.getGameState(),
268
+ history: this.history,
269
+ errors: this.allErrors,
270
+ };
271
+ }
272
+
273
+ dispose(): void
274
+ {
275
+ clearHooks('wizard-1');
276
+ clearHooks('wizard-2');
277
+ this.history = [];
278
+ }
279
+
280
+ private computeWinner(): MatchWinner
281
+ {
282
+ const w1 = this.wizards[0]!;
283
+ const w2 = this.wizards[1]!;
284
+ if (w1.health <= 0 && w2.health <= 0) return 'draw';
285
+ if (w2.health <= 0) return 'wizard-1';
286
+ if (w1.health <= 0) return 'wizard-2';
287
+ return null;
288
+ }
289
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * VIBEMANCER - MISSILE TEMPLATES
3
+ *
4
+ * Reusable missile config + AI factories for manual play and bot examples.
5
+ * Each factory clamps its inputs to rules.ts minimums and returns a pure
6
+ * AI function that's safe to register with the engine.
7
+ */
8
+
9
+ import {
10
+ MISSILE_MIN_DAMAGE,
11
+ MISSILE_MIN_SPEED,
12
+ MISSILE_MIN_DURATION,
13
+ } from '../rules.js';
14
+ import type {MissileAIFunction, MissileConfig} from '../types.js';
15
+
16
+ interface BaseParams
17
+ {
18
+ damage: number;
19
+ speed: number;
20
+ duration: number;
21
+ }
22
+
23
+ interface TurningParams extends BaseParams
24
+ {
25
+ turnRate: number;
26
+ }
27
+
28
+ export type StraightParams = BaseParams;
29
+
30
+ export type HomingParams = TurningParams;
31
+
32
+ export type AntiHomingParams = TurningParams;
33
+
34
+ export interface SpiralParams extends BaseParams
35
+ {
36
+ spiralRadius: number;
37
+ spiralFreq: number;
38
+ }
39
+
40
+ export interface SeekerParams extends TurningParams
41
+ {
42
+ minLockDistance: number;
43
+ }
44
+
45
+ export interface MissileTemplate
46
+ {
47
+ config: MissileConfig;
48
+ ai: MissileAIFunction;
49
+ }
50
+
51
+ function clampBase(p: BaseParams): {damage: number; speed: number; duration: number}
52
+ {
53
+ return {
54
+ damage: Math.max(MISSILE_MIN_DAMAGE, p.damage),
55
+ speed: Math.max(MISSILE_MIN_SPEED, p.speed),
56
+ duration: Math.max(MISSILE_MIN_DURATION, Math.floor(p.duration)),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Fire-and-forget missile. No steering — flies in a straight line.
62
+ */
63
+ export function straightMissile(p: StraightParams): MissileTemplate
64
+ {
65
+ const base = clampBase(p);
66
+ const config: MissileConfig = {...base, turnRate: 0};
67
+ const ai: MissileAIFunction = () => ({});
68
+ return {config, ai};
69
+ }
70
+
71
+ /**
72
+ * Homing missile. Steers toward the enemy each tick.
73
+ */
74
+ export function homingMissile(p: HomingParams): MissileTemplate
75
+ {
76
+ const base = clampBase(p);
77
+ const config: MissileConfig = {...base, turnRate: p.turnRate};
78
+ const ai: MissileAIFunction = ({worldState}) =>
79
+ {
80
+ const enemy = worldState.enemies[0];
81
+ if (!enemy) return {};
82
+ return {turnToward: enemy.position};
83
+ };
84
+ return {config, ai};
85
+ }
86
+
87
+ /**
88
+ * Anti-homing missile. Steers AWAY from the enemy — useful as a feint or
89
+ * area-denial pattern.
90
+ */
91
+ export function antiHomingMissile(p: AntiHomingParams): MissileTemplate
92
+ {
93
+ const base = clampBase(p);
94
+ const config: MissileConfig = {...base, turnRate: p.turnRate};
95
+ const ai: MissileAIFunction = ({missileState, worldState}) =>
96
+ {
97
+ const enemy = worldState.enemies[0];
98
+ if (!enemy) return {};
99
+ // Reflect the enemy position through the missile to get an "away" target.
100
+ const target = {
101
+ x: missileState.position.x * 2 - enemy.position.x,
102
+ y: missileState.position.y * 2 - enemy.position.y,
103
+ };
104
+ return {turnToward: target};
105
+ };
106
+ return {config, ai};
107
+ }
108
+
109
+ /**
110
+ * Spiral missile. Continuously orbits its current heading while advancing.
111
+ * Uses missileState.remainingTicks as a deterministic phase counter so the
112
+ * AI is fully pure (no closure state).
113
+ */
114
+ export function spiralMissile(p: SpiralParams): MissileTemplate
115
+ {
116
+ const base = clampBase(p);
117
+ // turnRate must be high enough for the spiral to manifest visibly
118
+ const config: MissileConfig = {...base, turnRate: 12};
119
+ const radius = Math.max(1, p.spiralRadius);
120
+ const freq = p.spiralFreq;
121
+ const ai: MissileAIFunction = ({missileState}) =>
122
+ {
123
+ const phase = -missileState.remainingTicks * freq;
124
+ const target = {
125
+ x: missileState.position.x + Math.cos(phase) * radius,
126
+ y: missileState.position.y + Math.sin(phase) * radius,
127
+ };
128
+ return {turnToward: target};
129
+ };
130
+ return {config, ai};
131
+ }
132
+
133
+ /**
134
+ * Seeker missile. Homes toward the enemy, but only when farther away than
135
+ * minLockDistance — closer than that, the missile coasts straight (so it
136
+ * doesn't whirl around a target it's about to hit).
137
+ */
138
+ export function seekerMissile(p: SeekerParams): MissileTemplate
139
+ {
140
+ const base = clampBase(p);
141
+ const config: MissileConfig = {...base, turnRate: p.turnRate};
142
+ const minDist = Math.max(0, p.minLockDistance);
143
+ const minDistSq = minDist * minDist;
144
+ const ai: MissileAIFunction = ({missileState, worldState}) =>
145
+ {
146
+ const enemy = worldState.enemies[0];
147
+ if (!enemy) return {};
148
+ const dx = enemy.position.x - missileState.position.x;
149
+ const dy = enemy.position.y - missileState.position.y;
150
+ const distSq = dx * dx + dy * dy;
151
+ if (distSq < minDistSq) return {};
152
+ return {turnToward: enemy.position};
153
+ };
154
+ return {config, ai};
155
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * VIBEMANCER - OPTIMIZER UTILITIES
3
+ *
4
+ * Core functions used by the offline parameter optimizer script.
5
+ * These handle combo generation, range narrowing, and fight scoring.
6
+ *
7
+ * The optimizer uses multi-pass coordinate descent:
8
+ * - Pass 1: Coarse search across the effective range for each param
9
+ * - Pass 2+: Fine search zoomed into the neighborhood of the best result
10
+ *
11
+ * Parameters can define their search window two ways:
12
+ * - `range`: sliding window centered on current value (value ± range)
13
+ * - `min`/`max`: fixed bounds (old-style)
14
+ *
15
+ * Both can be combined: range defines the search radius, min/max clamp it.
16
+ */
17
+
18
+ import type {ParamDeclaration} from './params-runtime.js';
19
+ import type {FightResult} from './simulation.js';
20
+ import {WIZARD_HEALTH} from '../rules.js';
21
+
22
+ /**
23
+ * Compute the effective min/max search window for a parameter.
24
+ *
25
+ * - If `range` is set: window is `[value - range, value + range]`, clamped by optional min/max
26
+ * - If only `min`/`max` are set: window is `[min, max]` directly
27
+ * - If neither: returns `[value, value]` (no search)
28
+ */
29
+ export function getEffectiveRange(p: ParamDeclaration): {min: number; max: number}
30
+ {
31
+ if (p.range != null)
32
+ {
33
+ return {
34
+ min: Math.max(p.value - p.range, p.min ?? -Infinity),
35
+ max: Math.min(p.value + p.range, p.max ?? Infinity),
36
+ };
37
+ }
38
+ return {
39
+ min: p.min ?? p.value,
40
+ max: p.max ?? p.value,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Generate evenly-spaced candidate values for a single parameter.
46
+ *
47
+ * Divides [min, max] into `steps` evenly-spaced values. For example,
48
+ * min=0, max=100, steps=5 produces [0, 25, 50, 75, 100].
49
+ *
50
+ * @param min - Lower bound of search range
51
+ * @param max - Upper bound of search range
52
+ * @param steps - Number of evenly-spaced values to generate (minimum 2)
53
+ * @returns Array of candidate values, sorted ascending
54
+ */
55
+ export function generateCandidates(min: number, max: number, steps: number): number[]
56
+ {
57
+ if (steps < 2) steps = 2;
58
+ const values: number[] = [];
59
+ for (let i = 0; i < steps; i++)
60
+ {
61
+ values.push(min + (max - min) * i / (steps - 1));
62
+ }
63
+ return values;
64
+ }
65
+
66
+ /**
67
+ * Generate all combinations (cartesian product) of candidate values for multiple params.
68
+ *
69
+ * For N params with S1, S2, ... SN steps each, produces S1 × S2 × ... × SN combinations.
70
+ * Each combination is a Record<string, number> mapping param name to value.
71
+ *
72
+ * @param params - Parameter declarations with search ranges and step counts
73
+ * @returns Array of all parameter combinations to evaluate
74
+ */
75
+ export function generateCombos(params: ParamDeclaration[]): Record<string, number>[]
76
+ {
77
+ if (params.length === 0) return [{}];
78
+
79
+ // Generate candidates per param
80
+ const candidateArrays = params.map((p) =>
81
+ {
82
+ const {min, max} = getEffectiveRange(p);
83
+ return {
84
+ name: p.name,
85
+ values: generateCandidates(min, max, p.steps),
86
+ };
87
+ });
88
+
89
+ // Cartesian product
90
+ let combos: Record<string, number>[] = [{}];
91
+ for (const {name, values} of candidateArrays)
92
+ {
93
+ const next: Record<string, number>[] = [];
94
+ for (const combo of combos)
95
+ {
96
+ for (const value of values)
97
+ {
98
+ next.push({...combo, [name]: value});
99
+ }
100
+ }
101
+ combos = next;
102
+ }
103
+
104
+ return combos;
105
+ }
106
+
107
+ /**
108
+ * Narrow parameter ranges around the best combo found in the previous pass.
109
+ *
110
+ * Centers each param on its best value and shrinks the search range to one gap width.
111
+ * This provides finer resolution in subsequent passes. Hard min/max constraints are preserved.
112
+ *
113
+ * @param params - Original parameter declarations
114
+ * @param best - Best parameter combination from the previous pass
115
+ * @returns New parameter declarations with narrowed ranges for the next pass
116
+ */
117
+ export function narrowRange(params: ParamDeclaration[], best: Record<string, number>): ParamDeclaration[]
118
+ {
119
+ return params.map((p) =>
120
+ {
121
+ const bestVal = best[p.name] ?? p.value;
122
+ const effectiveSubsteps = p.substeps ?? p.steps;
123
+
124
+ // substeps: 0 — freeze this param at its best value (skip in subsequent passes)
125
+ if (effectiveSubsteps === 0)
126
+ {
127
+ return {...p, value: bestVal, min: bestVal, max: bestVal, range: undefined, steps: 2};
128
+ }
129
+
130
+ const {min: effMin, max: effMax} = getEffectiveRange(p);
131
+ const gap = (effMax - effMin) / (p.steps - 1);
132
+ const narrowedSteps = effectiveSubsteps;
133
+
134
+ if (p.range != null)
135
+ {
136
+ // Range mode: center on best, shrink range to one gap
137
+ return {...p, value: bestVal, range: gap, steps: narrowedSteps};
138
+ }
139
+ // Fixed min/max mode: narrow bounds around best, clamped to original
140
+ return {
141
+ ...p,
142
+ min: Math.max(p.min ?? bestVal, bestVal - gap),
143
+ max: Math.min(p.max ?? bestVal, bestVal + gap),
144
+ steps: narrowedSteps,
145
+ };
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Score a FightResult from one bot's perspective.
151
+ *
152
+ * Returns a continuous score that provides gradient information beyond binary win/loss:
153
+ * - Win: 3.0 base + up to 0.5 HP bonus (higher remaining HP = better)
154
+ * - Draw: 1.0
155
+ * - Loss: 0.0 base + up to 0.5 bonus for low enemy HP (closer fights = better)
156
+ *
157
+ * For a mirrored fight set (bot as wizard-1 AND wizard-2), call this twice
158
+ * and sum the scores for a balanced evaluation.
159
+ *
160
+ * @param result - The fight result to score
161
+ * @returns Continuous score in range [0, 3.5] per match
162
+ */
163
+ export function scoreFight(result: FightResult): number
164
+ {
165
+ let score = 0;
166
+
167
+ for (const match of result.matches)
168
+ {
169
+ if (match.winner === 'wizard-1')
170
+ {
171
+ // Won: 3.0 base + HP bonus
172
+ score += 3.0 + (match.finalState.health / WIZARD_HEALTH) * 0.5;
173
+ }
174
+ else if (match.winner === 'draw' || match.winner === null)
175
+ {
176
+ score += 1.0;
177
+ }
178
+ else
179
+ {
180
+ // Lost: small bonus for low enemy HP
181
+ const enemyHP = match.finalState.enemies[0]?.health ?? WIZARD_HEALTH;
182
+ score += (1 - enemyHP / WIZARD_HEALTH) * 0.5;
183
+ }
184
+ }
185
+
186
+ return score;
187
+ }
188
+
189
+ /**
190
+ * Score a FightResult from wizard-2's perspective.
191
+ * Same scoring logic as scoreFight but with roles reversed.
192
+ *
193
+ * Use this for tournaments where both sides of a pairing need scoring.
194
+ */
195
+ export function scoreFightAsWizard2(result: FightResult): number
196
+ {
197
+ let score = 0;
198
+
199
+ for (const match of result.matches)
200
+ {
201
+ if (match.winner === 'wizard-2')
202
+ {
203
+ // Won: 3.0 base + HP bonus (wizard-2's remaining HP)
204
+ const hp = match.finalState.enemies[0]?.health ?? WIZARD_HEALTH;
205
+ score += 3.0 + (hp / WIZARD_HEALTH) * 0.5;
206
+ }
207
+ else if (match.winner === 'draw' || match.winner === null)
208
+ {
209
+ score += 1.0;
210
+ }
211
+ else
212
+ {
213
+ // Lost: small bonus for low wizard-1 HP
214
+ const enemyHP = match.finalState.health;
215
+ score += (1 - enemyHP / WIZARD_HEALTH) * 0.5;
216
+ }
217
+ }
218
+
219
+ return score;
220
+ }