@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.
- package/README.md +28 -0
- package/dist/chunk-L7Z7OFXD.js +9140 -0
- package/dist/chunk-L7Z7OFXD.js.map +1 -0
- package/dist/index-browser.d.ts +2602 -0
- package/dist/index-browser.js +407 -0
- package/dist/index-browser.js.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +750 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
- package/src/bots/berserker/01_Stormchaser.ts +457 -0
- package/src/bots/berserker/02_Stormcaller.ts +417 -0
- package/src/bots/berserker/03_Stormforger.ts +481 -0
- package/src/bots/caster/01_Flamecaller.ts +286 -0
- package/src/bots/caster/02_Pyromancer.ts +350 -0
- package/src/bots/caster/03_Infernalist.ts +492 -0
- package/src/bots/defensive/01_Turtle.ts +151 -0
- package/src/bots/defensive/02_Sentinel.ts +134 -0
- package/src/bots/defensive/03_Golem.ts +357 -0
- package/src/bots/duelist/01_Battlemage.ts +433 -0
- package/src/bots/duelist/02_Warmage.ts +438 -0
- package/src/bots/duelist/03_Archmage.ts +588 -0
- package/src/bots/homing/01_Bonemancer.ts +67 -0
- package/src/bots/homing/02_Lich.ts +356 -0
- package/src/bots/homing/03_Archlich.ts +220 -0
- package/src/bots/index.ts +30 -0
- package/src/bots/kiter/01_Spellspinner.ts +398 -0
- package/src/bots/kiter/02_Spellweaver.ts +378 -0
- package/src/bots/kiter/03_Spellbinder.ts +448 -0
- package/src/bots/melee/01_Shadowblade.ts +270 -0
- package/src/bots/melee/02_Nightblade.ts +437 -0
- package/src/bots/melee/03_Voidblade.ts +582 -0
- package/src/bots/registry.ts +207 -0
- package/src/bots/shared.ts +472 -0
- package/src/bots/sniper/01_Spellshot.ts +385 -0
- package/src/bots/sniper/02_Spelltracer.ts +441 -0
- package/src/bots/sniper/03_Spellseeker.ts +546 -0
- package/src/bots/standalone/Critter.ts +89 -0
- package/src/bots/standalone/Doombringer.ts +91 -0
- package/src/bots/standalone/Hogger.ts +228 -0
- package/src/bots/standalone/Rookie.ts +50 -0
- package/src/bots/standalone/TargetDummy.ts +21 -0
- package/src/bots/test/cheater.ts +405 -0
- package/src/bots/test/crasher.ts +81 -0
- package/src/engine/hooks-runtime.ts +394 -0
- package/src/engine/manual-match.ts +289 -0
- package/src/engine/missile-templates.ts +155 -0
- package/src/engine/optimizer.ts +220 -0
- package/src/engine/params-runtime.ts +189 -0
- package/src/engine/physics.ts +143 -0
- package/src/engine/sandbox-browser.ts +671 -0
- package/src/engine/sandbox-compile.ts +197 -0
- package/src/engine/sandbox-harness.ts +367 -0
- package/src/engine/sandbox.ts +332 -0
- package/src/engine/simulation.ts +828 -0
- package/src/engine/spells.ts +128 -0
- package/src/engine-version.ts +11 -0
- package/src/hooks/action-builders.ts +210 -0
- package/src/hooks/bot-wrapper.ts +84 -0
- package/src/hooks/index.ts +75 -0
- package/src/hooks/state-hooks.ts +354 -0
- package/src/hooks/threat-analysis.ts +365 -0
- package/src/hooks/types.ts +142 -0
- package/src/index-browser.ts +30 -0
- package/src/index.ts +24 -0
- package/src/rules.ts +254 -0
- package/src/stats.ts +262 -0
- package/src/testing.ts +207 -0
- package/src/trace.ts +430 -0
- package/src/types.ts +193 -0
- package/src/utils/angles.ts +47 -0
- package/src/utils/combat.ts +279 -0
- package/src/utils/distance.ts +21 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/movement.ts +108 -0
- package/src/utils/random.ts +65 -0
- package/src/utils/spatial.ts +63 -0
- 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
|
+
}
|