@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
package/src/stats.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * VIBEMANCER - FIGHT STATISTICS
3
+ *
4
+ * Extracts detailed per-bot statistics from a SimulateResult history.
5
+ * Used by testBot().simulate() and vibemancer trace for debugging.
6
+ */
7
+
8
+ import type {GameState} from './types.js';
9
+ import type {SimulateResult} from './engine/simulation.js';
10
+ import {TICKS_PER_SECOND, SHIELD_MAX_BLOCK, SHIELD_DECAY_PER_SECOND, SHIELD_MIN_BLOCK} from './rules.js';
11
+
12
+ /** Detailed statistics for one bot in a single match. */
13
+ export interface FightStats
14
+ {
15
+ /** Total damage dealt to the opponent. */
16
+ damageDealt: number;
17
+ /** Total damage taken from the opponent. */
18
+ damageTaken: number;
19
+ /** Number of missiles launched. */
20
+ missilesLaunched: number;
21
+ /** Number of missiles that dealt damage (hit the opponent). */
22
+ missileHits: number;
23
+ /** Hit rate (0-1). NaN if no missiles fired. */
24
+ hitRate: number;
25
+ /** Number of our missiles that hit while enemy was shielding. */
26
+ hitsEnemyShielded: number;
27
+ /** Number of our missiles that hit while enemy had no shield. */
28
+ hitsEnemyUnshielded: number;
29
+ /** Total raw damage our missiles would have dealt without shields. */
30
+ rawDamageDealt: number;
31
+ /** Damage blocked by enemy shields. */
32
+ damageBlockedByEnemy: number;
33
+ /** Percentage of our raw damage blocked by enemy shields (0-1). */
34
+ enemyBlockRate: number;
35
+ /** Number of times we were hit by enemy missiles. */
36
+ hitsReceived: number;
37
+ /** Number of hits received while shield was channeling. */
38
+ hitsShielded: number;
39
+ /** Number of hits received without shield. */
40
+ hitsUnshielded: number;
41
+ /** Total raw damage that hit us (before shield reduction). */
42
+ rawDamageReceived: number;
43
+ /** Total damage blocked by shields. */
44
+ damageBlocked: number;
45
+ /** Percentage of incoming raw damage that was blocked (0-1). */
46
+ blockRate: number;
47
+ /** Ticks spent in 'casting' state. */
48
+ castingTicks: number;
49
+ /** Ticks spent channeling shield. */
50
+ shieldTicks: number;
51
+ /** Ticks spent in GCD lockout. */
52
+ gcdTicks: number;
53
+ /** Ticks spent idle (not casting, channeling, or in GCD). */
54
+ idleTicks: number;
55
+ /** Number of times shield was activated. */
56
+ shieldCount: number;
57
+ /** Number of times blink was used. */
58
+ blinkCount: number;
59
+ /** Total match duration in ticks. */
60
+ totalTicks: number;
61
+ }
62
+
63
+ /**
64
+ * Compute the shield block percentage at a given channel duration.
65
+ * Same formula as the engine: starts at 90%, decays 20%/sec, min 30%.
66
+ */
67
+ function shieldBlockPct(channelDurationTicks: number): number
68
+ {
69
+ const channelSec = channelDurationTicks / TICKS_PER_SECOND;
70
+ return Math.max(SHIELD_MIN_BLOCK, SHIELD_MAX_BLOCK - SHIELD_DECAY_PER_SECOND * channelSec);
71
+ }
72
+
73
+ /**
74
+ * Extract fight statistics from a SimulateResult.
75
+ * Returns stats for wizard-1 (the bot under test).
76
+ */
77
+ export function extractStats(result: SimulateResult): FightStats
78
+ {
79
+ const history = result.history;
80
+ if (history.length === 0)
81
+ {
82
+ return emptyStats(result.ticks);
83
+ }
84
+
85
+ let damageDealt = 0;
86
+ let damageTaken = 0;
87
+ let missilesLaunched = 0;
88
+ let missileHits = 0;
89
+ let hitsEnemyShielded = 0;
90
+ let hitsEnemyUnshielded = 0;
91
+ let rawDamageDealt = 0;
92
+ let damageBlockedByEnemy = 0;
93
+ let hitsReceived = 0;
94
+ let hitsShielded = 0;
95
+ let hitsUnshielded = 0;
96
+ let rawDamageReceived = 0;
97
+ let damageBlocked = 0;
98
+ let castingTicks = 0;
99
+ let shieldTicks = 0;
100
+ let gcdTicks = 0;
101
+ let idleTicks = 0;
102
+ let shieldCount = 0;
103
+ let blinkCount = 0;
104
+
105
+ let prevState: GameState | null = null;
106
+
107
+ for (const state of history)
108
+ {
109
+ if (prevState)
110
+ {
111
+ // Damage dealt (enemy HP decreased)
112
+ const prevEnemy = prevState.enemies?.[0];
113
+ const curEnemy = state.enemies?.[0];
114
+ const prevEnemyHp = prevEnemy?.health ?? 0;
115
+ const curEnemyHp = curEnemy?.health ?? 0;
116
+ if (curEnemyHp < prevEnemyHp)
117
+ {
118
+ const dmg = prevEnemyHp - curEnemyHp;
119
+ damageDealt += dmg;
120
+ missileHits++;
121
+
122
+ if (prevEnemy?.channelingSpell === 'shield')
123
+ {
124
+ // Our missile hit while enemy was shielding
125
+ hitsEnemyShielded++;
126
+ const enemyBlockPct = shieldBlockPct(prevEnemy.channelDuration ?? 0);
127
+ const rawDmg = dmg / (1 - enemyBlockPct);
128
+ rawDamageDealt += rawDmg;
129
+ damageBlockedByEnemy += rawDmg - dmg;
130
+ }
131
+ else
132
+ {
133
+ // Hit without shield — raw = actual
134
+ hitsEnemyUnshielded++;
135
+ rawDamageDealt += dmg;
136
+ }
137
+ }
138
+
139
+ // Damage taken (our HP decreased)
140
+ if (state.health < prevState.health)
141
+ {
142
+ const dmg = prevState.health - state.health;
143
+ damageTaken += dmg;
144
+ hitsReceived++;
145
+
146
+ if (prevState.channelingSpell === 'shield')
147
+ {
148
+ // Hit while shielding — compute actual block amount
149
+ hitsShielded++;
150
+ const blockPct = shieldBlockPct(prevState.channelDuration ?? 0);
151
+ // damageTaken = rawDamage * (1 - blockPct)
152
+ // rawDamage = damageTaken / (1 - blockPct)
153
+ const rawDmg = dmg / (1 - blockPct);
154
+ const blocked = rawDmg - dmg;
155
+ rawDamageReceived += rawDmg;
156
+ damageBlocked += blocked;
157
+ }
158
+ else
159
+ {
160
+ // Hit without shield — raw damage = damage taken
161
+ hitsUnshielded++;
162
+ rawDamageReceived += dmg;
163
+ }
164
+ }
165
+
166
+ // Missile launches (new projectiles appearing)
167
+ const prevMyProj = prevState.myProjectiles?.length ?? 0;
168
+ const curMyProj = state.myProjectiles?.length ?? 0;
169
+ if (curMyProj > prevMyProj)
170
+ {
171
+ missilesLaunched += curMyProj - prevMyProj;
172
+ }
173
+
174
+ // State tracking
175
+ if (state.castingSpell) castingTicks++;
176
+ else if (state.channelingSpell === 'shield') shieldTicks++;
177
+ else if (state.gcdRemaining && state.gcdRemaining > 0) gcdTicks++;
178
+ else idleTicks++;
179
+
180
+ // Shield activations
181
+ if (state.channelingSpell === 'shield' && prevState.channelingSpell !== 'shield')
182
+ {
183
+ shieldCount++;
184
+ }
185
+
186
+ // Blink usage
187
+ if (prevState.castingSpell === 'blink' && state.castingSpell !== 'blink' && state.gcdRemaining && state.gcdRemaining > 0)
188
+ {
189
+ blinkCount++;
190
+ }
191
+ }
192
+
193
+ prevState = state;
194
+ }
195
+
196
+ const totalTicks = result.ticks;
197
+
198
+ return {
199
+ damageDealt,
200
+ damageTaken,
201
+ missilesLaunched,
202
+ missileHits,
203
+ hitRate: missilesLaunched > 0 ? missileHits / missilesLaunched : NaN,
204
+ hitsEnemyShielded,
205
+ hitsEnemyUnshielded,
206
+ rawDamageDealt,
207
+ damageBlockedByEnemy,
208
+ enemyBlockRate: rawDamageDealt > 0 ? damageBlockedByEnemy / rawDamageDealt : 0,
209
+ hitsReceived,
210
+ hitsShielded,
211
+ hitsUnshielded,
212
+ rawDamageReceived,
213
+ damageBlocked,
214
+ blockRate: rawDamageReceived > 0 ? damageBlocked / rawDamageReceived : 0,
215
+ castingTicks,
216
+ shieldTicks,
217
+ gcdTicks,
218
+ idleTicks,
219
+ shieldCount,
220
+ blinkCount,
221
+ totalTicks,
222
+ };
223
+ }
224
+
225
+ function emptyStats(totalTicks: number): FightStats
226
+ {
227
+ return {
228
+ damageDealt: 0, damageTaken: 0,
229
+ missilesLaunched: 0, missileHits: 0, hitRate: NaN,
230
+ hitsEnemyShielded: 0, hitsEnemyUnshielded: 0,
231
+ rawDamageDealt: 0, damageBlockedByEnemy: 0, enemyBlockRate: 0,
232
+ hitsReceived: 0, hitsShielded: 0, hitsUnshielded: 0,
233
+ rawDamageReceived: 0, damageBlocked: 0, blockRate: 0,
234
+ castingTicks: 0, shieldTicks: 0, gcdTicks: 0, idleTicks: totalTicks,
235
+ shieldCount: 0, blinkCount: 0, totalTicks,
236
+ };
237
+ }
238
+
239
+ /** Format stats as a human-readable summary string. */
240
+ export function formatStats(stats: FightStats, botName: string): string
241
+ {
242
+ const lines: string[] = [];
243
+ const pct = (n: number): string => ((n / stats.totalTicks) * 100).toFixed(1);
244
+ const sec = (n: number): string => (n / TICKS_PER_SECOND).toFixed(1);
245
+
246
+ lines.push(` ${botName} stats:`);
247
+ lines.push(' Offense:');
248
+ lines.push(` Missiles: ${stats.missilesLaunched} fired, ${stats.missileHits} hit (${isNaN(stats.hitRate) ? 'N/A' : (stats.hitRate * 100).toFixed(0) + '%'})`);
249
+ lines.push(` Damage: ${stats.damageDealt.toFixed(1)} dealt${stats.damageBlockedByEnemy > 0 ? ` (${stats.rawDamageDealt.toFixed(1)} raw, ${stats.damageBlockedByEnemy.toFixed(1)} blocked by enemy = ${(stats.enemyBlockRate * 100).toFixed(0)}%)` : ''}`);
250
+ if (stats.missileHits > 0)
251
+ {
252
+ lines.push(` Hits: ${stats.hitsEnemyUnshielded} clean, ${stats.hitsEnemyShielded} shielded`);
253
+ }
254
+ lines.push(' Defense:');
255
+ lines.push(` Hits taken: ${stats.hitsReceived} — ${stats.hitsUnshielded} unshielded, ${stats.hitsShielded} shielded`);
256
+ lines.push(` Damage: ${stats.damageTaken.toFixed(1)} taken${stats.damageBlocked > 0 ? ` (${stats.rawDamageReceived.toFixed(1)} raw, ${stats.damageBlocked.toFixed(1)} blocked = ${(stats.blockRate * 100).toFixed(0)}%)` : ''}`);
257
+ lines.push(` Shield: ${stats.shieldCount} activations, ${sec(stats.shieldTicks)}s channeled`);
258
+ lines.push(` Blinks: ${stats.blinkCount}`);
259
+ lines.push(` Time: ${pct(stats.castingTicks)}% casting, ${pct(stats.shieldTicks)}% shielding, ${pct(stats.gcdTicks)}% GCD, ${pct(stats.idleTicks)}% idle`);
260
+
261
+ return lines.join('\n');
262
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * VIBEMANCER - TESTING UTILITIES
3
+ *
4
+ * Helpers for writing automated tests for your bot.
5
+ * Import from '@vibemancer/core' in your test files.
6
+ *
7
+ * @example
8
+ * import {testBot} from '@vibemancer/core';
9
+ * import {MyWizard} from '../src/bot';
10
+ *
11
+ * test('beats TargetDummy', async () => {
12
+ * const result = await testBot(MyWizard).fight('TargetDummy');
13
+ * expect(result.won).toBe(true);
14
+ * });
15
+ */
16
+
17
+ import type {BotFunction} from './hooks/types.js';
18
+ import type {WizardFunction} from './types.js';
19
+ import type {FightResult, SimulateResult, MatchWinner, FightWinner} from './engine/simulation.js';
20
+ import {fight, simulate, FIGHT_SPAWN_DISTANCES} from './engine/simulation.js';
21
+ import {isNewStyleBot, wrapNewBot} from './hooks/bot-wrapper.js';
22
+ import {extractStats} from './stats.js';
23
+ import type {FightStats} from './stats.js';
24
+
25
+ // Import all built-in bots
26
+ import * as allBots from './bots/index.js';
27
+
28
+ /** Result of a testBot().fight() call with convenience accessors. */
29
+ export interface TestFightResult
30
+ {
31
+ /** The raw FightResult from the simulation engine. */
32
+ raw: FightResult;
33
+ /** Overall winner: 'wizard-1' | 'wizard-2' | 'draw'. */
34
+ winner: FightWinner;
35
+ /** True if your bot won the fight. */
36
+ won: boolean;
37
+ /** True if your bot lost the fight. */
38
+ lost: boolean;
39
+ /** True if the fight was a draw. */
40
+ drawn: boolean;
41
+ /** Number of individual matches your bot won (out of 10). */
42
+ wins: number;
43
+ /** Number of individual matches your bot lost (out of 10). */
44
+ losses: number;
45
+ /** Number of individual matches that were draws. */
46
+ draws: number;
47
+ }
48
+
49
+ /** Result of a testBot().simulate() call with convenience accessors. */
50
+ export interface TestSimulateResult
51
+ {
52
+ /** The raw SimulateResult from the simulation engine. */
53
+ raw: SimulateResult;
54
+ /** Match winner. */
55
+ winner: MatchWinner;
56
+ /** True if your bot won. */
57
+ won: boolean;
58
+ /** True if your bot lost. */
59
+ lost: boolean;
60
+ /** True if the match was a draw or timeout. */
61
+ drawn: boolean;
62
+ /** Number of ticks the match lasted. */
63
+ ticks: number;
64
+ /** Your bot's remaining HP. */
65
+ myHealth: number;
66
+ /** Enemy's remaining HP. */
67
+ enemyHealth: number;
68
+ /** Detailed fight statistics (missiles, damage, shield usage, time breakdown). */
69
+ stats: FightStats;
70
+ /** Runtime errors thrown by bot or missile AI (empty if none). */
71
+ errors: Array<{tick: number; entityId: string; message: string}>;
72
+ }
73
+
74
+ /** Builder returned by testBot(). */
75
+ export interface TestBotBuilder
76
+ {
77
+ /**
78
+ * Run a full fight (10 matches) against a named built-in bot.
79
+ * @param opponent - Built-in bot name (e.g., 'Battlemage', 'TargetDummy').
80
+ */
81
+ fight(opponent: string, options?: {seed?: number}): TestFightResult;
82
+
83
+ /**
84
+ * Run a single match against a named built-in bot.
85
+ * @param opponent - Built-in bot name.
86
+ */
87
+ simulate(opponent: string, options?: {
88
+ seed?: number;
89
+ spawnDistance?: number;
90
+ maxTicks?: number;
91
+ }): TestSimulateResult;
92
+ }
93
+
94
+ /**
95
+ * Resolve a built-in bot name to a WizardFunction.
96
+ */
97
+ function resolveBuiltinBot(name: string): WizardFunction
98
+ {
99
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- dynamic bot lookup
100
+ const botMap = allBots as unknown as Record<string, WizardFunction | BotFunction>;
101
+ const bot = botMap[name];
102
+ if (!bot)
103
+ {
104
+ const available = Object.keys(botMap).filter((k) => typeof botMap[k] === 'function').join(', ');
105
+ throw new Error(`Unknown opponent "${name}". Available bots: ${available}`);
106
+ }
107
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- branching on length+shape detection
108
+ return typeof bot === 'function' && bot.length === 0 && isNewStyleBot(bot as BotFunction)
109
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by isNewStyleBot above
110
+ ? wrapNewBot(bot as BotFunction)
111
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- legacy style bot after guard
112
+ : bot as WizardFunction;
113
+ }
114
+
115
+ /**
116
+ * Wrap a bot function for use with the simulation engine.
117
+ */
118
+ function wrapBot(bot: WizardFunction | BotFunction): WizardFunction
119
+ {
120
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- runtime shape detection
121
+ if (isNewStyleBot(bot as BotFunction))
122
+ {
123
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by isNewStyleBot above
124
+ return wrapNewBot(bot as BotFunction);
125
+ }
126
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- legacy style bot after guard
127
+ return bot as WizardFunction;
128
+ }
129
+
130
+ /**
131
+ * Create a test builder for your bot.
132
+ *
133
+ * @param bot - Your bot function (the same function you export from src/bot.ts).
134
+ * @returns A builder with .fight() and .simulate() methods.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * import {testBot} from '@vibemancer/core';
139
+ * import {MyWizard} from '../src/bot';
140
+ *
141
+ * test('beats TargetDummy', () => {
142
+ * const result = testBot(MyWizard).fight('TargetDummy');
143
+ * expect(result.won).toBe(true);
144
+ * });
145
+ *
146
+ * test('survives 10 seconds against Battlemage', () => {
147
+ * const result = testBot(MyWizard).simulate('Battlemage', {maxTicks: 1000});
148
+ * expect(result.myHealth).toBeGreaterThan(0);
149
+ * });
150
+ *
151
+ * test('kills at close range', () => {
152
+ * const result = testBot(MyWizard).simulate('TargetDummy', {spawnDistance: 200});
153
+ * expect(result.won).toBe(true);
154
+ * expect(result.ticks).toBeLessThan(1000);
155
+ * });
156
+ * ```
157
+ */
158
+ export function testBot(bot: WizardFunction | BotFunction): TestBotBuilder
159
+ {
160
+ const wrappedBot = wrapBot(bot);
161
+
162
+ return {
163
+ fight(opponent: string, options?: {seed?: number}): TestFightResult
164
+ {
165
+ const opponentBot = resolveBuiltinBot(opponent);
166
+ const raw = fight(wrappedBot, opponentBot, {seed: options?.seed});
167
+
168
+ return {
169
+ raw,
170
+ winner: raw.winner,
171
+ won: raw.winner === 'wizard-1',
172
+ lost: raw.winner === 'wizard-2',
173
+ drawn: raw.winner === 'draw',
174
+ wins: raw.wizard1Wins,
175
+ losses: raw.wizard2Wins,
176
+ draws: raw.draws,
177
+ };
178
+ },
179
+
180
+ simulate(opponent: string, options?: {
181
+ seed?: number;
182
+ spawnDistance?: number;
183
+ maxTicks?: number;
184
+ }): TestSimulateResult
185
+ {
186
+ const opponentBot = resolveBuiltinBot(opponent);
187
+ const raw = simulate(wrappedBot, opponentBot, {
188
+ seed: options?.seed,
189
+ spawnDistance: options?.spawnDistance ?? FIGHT_SPAWN_DISTANCES[2], // 600 (middle distance)
190
+ maxTicks: options?.maxTicks,
191
+ });
192
+
193
+ return {
194
+ raw,
195
+ winner: raw.winner,
196
+ won: raw.winner === 'wizard-1',
197
+ lost: raw.winner === 'wizard-2',
198
+ drawn: raw.winner === null || raw.winner === 'draw',
199
+ ticks: raw.ticks,
200
+ myHealth: raw.finalState.health,
201
+ enemyHealth: raw.finalState.enemies?.[0]?.health ?? 0,
202
+ stats: extractStats(raw),
203
+ errors: raw.errors,
204
+ };
205
+ },
206
+ };
207
+ }