@vibemancer/core 1.0.2 → 1.0.4

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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * VIBEMANCER — BOT COMPUTE BUDGET
3
+ *
4
+ * The real-time guard, moved off the whole fight and onto each bot.
5
+ *
6
+ * Decision 0002: fight LENGTH is simulation time (`maxTicks`) and always was; "stop a bot
7
+ * looping forever" genuinely needs real time. Conflating both into one wall-clock timeout
8
+ * around the entire match meant a slow SERVER was indistinguishable from a broken bot — and
9
+ * matchmaking blamed the player for it, up to auto-deactivating their bot.
10
+ *
11
+ * The constraint that shapes this module: the tick loop runs INSIDE the isolate, where the
12
+ * only clock is `Date.now()`. There is no way to measure a bot in machine-independent units
13
+ * from in there. So this guard is wall-clock, and wall clock on a shared server is exactly
14
+ * the thing that caused the original bug.
15
+ *
16
+ * The resolution is therefore in what going over budget COSTS:
17
+ *
18
+ * - A bot has ONE budget: total thinking time across a whole fight.
19
+ * - Spend it and the bot is not called again for the rest of the fight. It stands still.
20
+ * - That is never recorded as an error, so it cannot increment `consecutiveCrashes` and
21
+ * cannot auto-deactivate a bot.
22
+ *
23
+ * A bot too slow to answer loses the fight it was too slow for, which is the same thing that
24
+ * happens to a lagging player in any real-time game. A busy server can cost someone a match;
25
+ * it must never cost them their bot.
26
+ *
27
+ * ## Why there is no per-tick limit
28
+ *
29
+ * There was one, briefly: overrun 50ms in a single tick and that tick's action was
30
+ * discarded. It had to go, and the reason is the important part of this file.
31
+ *
32
+ * This engine is DETERMINISTIC by design. Spectate re-simulates a recorded match and gates
33
+ * on ENGINE_VERSION precisely so that the same version and seed reproduce the same fight. A
34
+ * per-tick threshold breaks that: under load a single call can cross 50ms through a GC pause
35
+ * or a scheduler hiccup, its action is discarded, and the fight diverges. Outcomes then
36
+ * depend on how busy the machine was — which is the original bug, reintroduced at finer
37
+ * granularity by the thing meant to fix it. It was caught by a test that passed alone and
38
+ * failed inside a loaded run.
39
+ *
40
+ * A single cumulative budget does not have that problem in practice. Measurement still
41
+ * varies with load, but it only changes BEHAVIOUR at one point — exhaustion — and an honest
42
+ * bot never approaches it: the worst real bot measured spends 5.6s of a 45s allowance. So
43
+ * every legitimate fight is bit-identical to an unbudgeted one, and determinism holds.
44
+ *
45
+ * A bot that stalls inside one tick is left to the outer wall-clock backstop, which is the
46
+ * only thing that could ever catch it anyway: a `while(true)` cannot be interrupted from
47
+ * inside a single-threaded isolate, no matter what the tick loop measures.
48
+ *
49
+ * Everything here is pure arithmetic so it can be tested exhaustively. The only impure part
50
+ * — reading the clock — stays in the tick loop.
51
+ *
52
+ * A note on precision, because it looks broken and is not. `Date.now()` resolves to 1ms here
53
+ * (measured), while a typical bot call is microseconds — so nearly every individual call
54
+ * measures 0ms and contributes nothing. That is fine, and deliberately not "fixed": a call
55
+ * lasting d milliseconds (d < 1) straddles a millisecond boundary with probability d, so it
56
+ * reads 1 exactly that often and 0 otherwise. The expected measurement equals the true
57
+ * duration, which over the thousands of calls in a fight is what a cumulative budget needs.
58
+ * Swapping in a higher-resolution clock is not an option anyway: inside the isolate there is
59
+ * no other clock, and a per-call `performance.now()` would cost more than it measures.
60
+ */
61
+
62
+ /** Limits applied to a single bot for one fight. */
63
+ export interface BudgetLimits
64
+ {
65
+ /** Milliseconds a bot may spend thinking, in total, before it stops being called. */
66
+ totalMs: number;
67
+ }
68
+
69
+ /** A single bot's running spend for one fight. */
70
+ export interface BotBudgetState
71
+ {
72
+ spentMs: number;
73
+ exhausted: boolean;
74
+ }
75
+
76
+ /**
77
+ * Default, sized from measurement (2026-08-30) — and resized once, after the first
78
+ * measurement turned out to be worthless.
79
+ *
80
+ * The first pass measured TargetDummy, a bot whose entire body is `return idle()`. Against
81
+ * that, a full-length 30,000-tick match costs ~196ms and any budget looks generous. Real
82
+ * built-in bots are nothing like it. One `fight()` — ten matches — between two of them:
83
+ *
84
+ * Bonemancer vs Turtle 18392ms
85
+ * Bonemancer vs Spellseeker 11251ms
86
+ * Turtle vs Hogger 10185ms
87
+ *
88
+ * and instrumenting the calls shows bot code is 70-78% of that, one honest bot spending
89
+ * 5611ms in a single fight. A 20s budget, which had looked like 200x headroom, was really
90
+ * about 2-4x — it would have fired on innocent play the first time the server was busy,
91
+ * which is the exact bug it exists to prevent.
92
+ *
93
+ * Hence 45s per bot per fight: roughly 8x the honest worst case measured here. That margin
94
+ * is also what keeps fights deterministic, since behaviour only changes if it is reached.
95
+ *
96
+ * This exists to catch runaway code, not to make anyone optimise.
97
+ */
98
+ export const DEFAULT_BUDGET: BudgetLimits = {
99
+ totalMs: 45_000,
100
+ };
101
+
102
+ /**
103
+ * Last-resort wall-clock backstop for a whole fight (ms).
104
+ *
105
+ * Lives here, next to the budget, because the two numbers only make sense together: the
106
+ * budget is what bounds bot compute, and this is only for the case no in-isolate guard can
107
+ * reach — a bot in `while(true)`, which cannot be interrupted from inside a single-threaded
108
+ * isolate no matter what the tick loop measures.
109
+ *
110
+ * One runaway bot (45s of budget) plus an honest opponent has to fit inside it, or the
111
+ * backstop fires first and the budget never gets to attribute anything. It is capped at 110s
112
+ * rather than raised further because the mcp Cloud Function's own `timeoutSeconds` is 120 —
113
+ * above that the platform kills the request first and nothing useful is reported.
114
+ *
115
+ * It is a single exported constant precisely because it was previously four separate
116
+ * literals (bundle-fight, sandbox twice, functions/fight-runner) that drifted apart.
117
+ */
118
+ export const DEFAULT_FIGHT_BACKSTOP_MS = 110_000;
119
+
120
+ /**
121
+ * Both bots' budgets for ONE FIGHT.
122
+ *
123
+ * The scope matters more than it looks. `fight()` is not one simulation, it is ten (five
124
+ * spawn distances, each played from both sides). A budget scoped to a single `simulate`
125
+ * would hand a runaway bot its whole total ten times over — 450 seconds of bot compute
126
+ * inside a 110-second backstop — and would look like it was bounding something while
127
+ * bounding nothing. So the budget belongs to the fight, and is carried across its matches.
128
+ *
129
+ * This is the one mutable thing in this module: `simulate` updates `states` in place as it
130
+ * runs so the spend survives from one match to the next.
131
+ */
132
+ export interface FightBudget
133
+ {
134
+ states: [BotBudgetState, BotBudgetState];
135
+ limits: BudgetLimits;
136
+ }
137
+
138
+ /** A fresh budget for one bot at the start of a fight. */
139
+ export function createBudgetState(): BotBudgetState
140
+ {
141
+ return {spentMs: 0, exhausted: false};
142
+ }
143
+
144
+ /** A fresh budget covering both bots for one whole fight (all of its matches). */
145
+ export function createFightBudget(limits: BudgetLimits = DEFAULT_BUDGET): FightBudget
146
+ {
147
+ return {states: [createBudgetState(), createBudgetState()], limits};
148
+ }
149
+
150
+ /** May this bot still be called at all? */
151
+ export function mayAct(state: BotBudgetState): boolean
152
+ {
153
+ return !state.exhausted;
154
+ }
155
+
156
+ /**
157
+ * Record what one call cost.
158
+ *
159
+ * A non-finite elapsed time is ignored rather than charged, and a negative one cannot refund
160
+ * budget — `Date.now()` can step backwards on an NTP correction or a VM migration, and that
161
+ * must not become a way to earn compute.
162
+ */
163
+ export function recordSpend(
164
+ state: BotBudgetState,
165
+ limits: BudgetLimits,
166
+ elapsedMs: number,
167
+ ): BotBudgetState
168
+ {
169
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return state;
170
+
171
+ const spentMs = state.spentMs + elapsedMs;
172
+ return {spentMs, exhausted: state.exhausted || spentMs > limits.totalMs};
173
+ }
@@ -19,11 +19,34 @@ import ivm from 'isolated-vm';
19
19
  import {build} from 'esbuild';
20
20
  import {getEngineDir} from './sandbox-compile.js';
21
21
  import type {FightResult, SimulateResult} from './simulation.js';
22
+ import {DEFAULT_FIGHT_BACKSTOP_MS} from './bot-compute-budget.js';
22
23
 
23
24
  /** Memory limit per sandbox isolate (MB). */
24
25
  const MEMORY_LIMIT_MB = 256;
25
- /** Timeout per fight (ms). */
26
- const FIGHT_TIMEOUT_MS = 60_000;
26
+ /**
27
+ * Default timeout per fight (ms).
28
+ *
29
+ * This is a real safety limit: it stops a malicious or looping bot burning server time in
30
+ * the matchmaker, so the DEFAULT must not move. It was previously hardcoded and unreachable
31
+ * from any option, which made the suite unusable on a busy machine — one e2e fight exceeded
32
+ * it and blocked seven consecutive commits, while a comparable fight run to the tick cap
33
+ * finished in 1.3s on the same machine at the same moment. The limit was not catching a
34
+ * runaway bot; it was catching load.
35
+ */
36
+ export const DEFAULT_FIGHT_TIMEOUT_MS = DEFAULT_FIGHT_BACKSTOP_MS;
37
+
38
+ /**
39
+ * Resolve the effective fight timeout.
40
+ *
41
+ * Only ever EXTENDS the default. A caller who knows the work is legitimate (a test on a
42
+ * slow machine) can ask for more; nobody can quietly ask for less, because weakening a
43
+ * safety limit by accident is the direction that turns it into a flaky one.
44
+ */
45
+ export function resolveFightTimeout(requested: number | undefined): number
46
+ {
47
+ if (typeof requested !== 'number' || !Number.isFinite(requested)) return DEFAULT_FIGHT_TIMEOUT_MS;
48
+ return Math.max(DEFAULT_FIGHT_TIMEOUT_MS, requested);
49
+ }
27
50
 
28
51
  const FREEZE_BANNER = `
29
52
  Object.freeze(Object.prototype);
@@ -74,6 +97,7 @@ export function buildMatchTemplate(): Promise<string>
74
97
  const entryPoint = `
75
98
  import {fight, simulate} from '${coreSrc}/engine/simulation.ts';
76
99
  import {wrapWithParams} from '${coreSrc}/engine/params-runtime.ts';
100
+ import {DEFAULT_BUDGET} from '${coreSrc}/engine/bot-compute-budget.ts';
77
101
 
78
102
  const __Bot1 = (globalThis as any).__injectedBot1;
79
103
  const __Bot2 = (globalThis as any).__injectedBot2;
@@ -82,14 +106,18 @@ if (!__Bot1) throw new Error('Bot 1 not injected (set globalThis.__injectedBot1)
82
106
  if (!__Bot2) throw new Error('Bot 2 not injected (set globalThis.__injectedBot2)');
83
107
 
84
108
  (globalThis as any).__fight = function __fight(options: any) {
85
- return fight(__Bot1, __Bot2, options);
109
+ // Same per-bot compute budget the server uses, so a runaway bot fails the same way in
110
+ // the CLI as it will on the ladder — and so it cannot hang someone's terminal.
111
+ return fight(__Bot1, __Bot2, {budgetLimits: DEFAULT_BUDGET, ...options});
86
112
  };
87
113
 
88
114
  (globalThis as any).__simulate = function __simulate(options: any) {
89
115
  const {params1, params2, ...simOptions} = options;
90
116
  const bot1 = params1 ? wrapWithParams(__Bot1, params1) : __Bot1;
91
117
  const bot2 = params2 ? wrapWithParams(__Bot2, params2) : __Bot2;
92
- return simulate(bot1, bot2, simOptions);
118
+ // Budgeted like __fight — this is the CLI's trace/optimize path, and its backstop went
119
+ // up to 110s with everything else, so leaving it out would make it worse than before.
120
+ return simulate(bot1, bot2, {budgetLimits: DEFAULT_BUDGET, ...simOptions});
93
121
  };
94
122
  `;
95
123
 
@@ -121,6 +149,8 @@ export interface RunBundleFightOptions
121
149
  seed: number;
122
150
  /** Prebuilt match template (e.g. the committed Cloud Functions one). Built lazily if omitted. */
123
151
  matchTemplate?: string;
152
+ /** Extend the per-fight isolate timeout. Can only raise it above the default. */
153
+ fightTimeoutMs?: number;
124
154
  /** Skip per-tick history in the result (smaller copy out of the isolate). Default true. */
125
155
  skipHistory?: boolean;
126
156
  }
@@ -139,6 +169,7 @@ export async function runBundleFight(
139
169
  ): Promise<FightResult>
140
170
  {
141
171
  const template = options.matchTemplate ?? await buildMatchTemplate();
172
+ const fightTimeoutMs = resolveFightTimeout(options.fightTimeoutMs);
142
173
  const skipHistory = options.skipHistory ?? true;
143
174
 
144
175
  // Freeze prototypes/globals BEFORE the untrusted bot bundles run. The bundles
@@ -165,13 +196,13 @@ export async function runBundleFight(
165
196
  await jail.set('global', jail.derefInto());
166
197
 
167
198
  const script = await isolate.compileScript(code);
168
- await script.run(context, {timeout: FIGHT_TIMEOUT_MS});
199
+ await script.run(context, {timeout: fightTimeoutMs});
169
200
 
170
201
  const fightFn = await jail.get('__fight');
171
202
  const result = await fightFn.apply(
172
203
  undefined,
173
204
  [new ivm.ExternalCopy({seed: options.seed, skipHistory}).copyInto()],
174
- {timeout: FIGHT_TIMEOUT_MS, result: {copy: true}},
205
+ {timeout: fightTimeoutMs, result: {copy: true}},
175
206
  );
176
207
 
177
208
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- isolated-vm returns unknown
@@ -189,6 +220,8 @@ export interface RunBundleSimulateOptions
189
220
  spawnDistance?: number;
190
221
  maxTicks?: number;
191
222
  matchTemplate?: string;
223
+ /** Extend the per-fight isolate timeout. Can only raise it above the default. */
224
+ fightTimeoutMs?: number;
192
225
  }
193
226
 
194
227
  /**
@@ -203,6 +236,7 @@ export async function runBundleSimulate(
203
236
  ): Promise<SimulateResult>
204
237
  {
205
238
  const template = options.matchTemplate ?? await buildMatchTemplate();
239
+ const fightTimeoutMs = resolveFightTimeout(options.fightTimeoutMs);
206
240
 
207
241
  // Freeze before the untrusted bundles run (see runBundleFight).
208
242
  const code = FREEZE_BANNER + '\n' + bundle1
@@ -224,7 +258,7 @@ export async function runBundleSimulate(
224
258
  await jail.set('global', jail.derefInto());
225
259
 
226
260
  const script = await isolate.compileScript(code);
227
- await script.run(context, {timeout: FIGHT_TIMEOUT_MS});
261
+ await script.run(context, {timeout: fightTimeoutMs});
228
262
 
229
263
  const simulateFn = await jail.get('__simulate');
230
264
  const simOptions = {
@@ -235,7 +269,7 @@ export async function runBundleSimulate(
235
269
  const result = await simulateFn.apply(
236
270
  undefined,
237
271
  [new ivm.ExternalCopy(simOptions).copyInto()],
238
- {timeout: FIGHT_TIMEOUT_MS, result: {copy: true}},
272
+ {timeout: fightTimeoutMs, result: {copy: true}},
239
273
  );
240
274
 
241
275
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- isolated-vm returns unknown
@@ -23,6 +23,7 @@ import type {GameState, ProjectileState, WizardActions} from '../types.js';
23
23
  import type {MissileFunction} from '../hooks/types.js';
24
24
  import type {BotError, FightResult, MatchWinner, SimulateResult} from './simulation.js';
25
25
  import type {StepResult} from './manual-match.js';
26
+ import {DEFAULT_FIGHT_BACKSTOP_MS} from './bot-compute-budget.js';
26
27
 
27
28
  // ============================================================
28
29
  // WORKER PROTOCOL
@@ -191,7 +192,12 @@ export class BrowserMatchSandbox
191
192
  options?: BrowserSandboxOptions,
192
193
  ): Promise<BrowserMatchSandbox>
193
194
  {
194
- const timeout = options?.timeoutMs ?? 30000;
195
+ // Was 30000, which the per-bot compute budget made incoherent: one bot may
196
+ // legitimately spend 45s before it stops being called, so a 30s cap here would kill
197
+ // fights the budget considers perfectly fine — and browser workers are slower than
198
+ // the server besides. Same backstop as every other path. This runs in a Worker, so a
199
+ // long fight does not freeze the page.
200
+ const timeout = options?.timeoutMs ?? DEFAULT_FIGHT_BACKSTOP_MS;
195
201
  const code = createWorkerScript(bundle);
196
202
 
197
203
  let worker: WorkerLike;
@@ -89,9 +89,14 @@ import {${bot1ExportName} as __Bot1} from '${bot1Path}';
89
89
  import {${bot2ExportName} as __Bot2} from '${bot2Path}';
90
90
  import {fight, simulate} from '${srcPath}/engine/simulation.ts';
91
91
  import {wrapWithParams} from '${srcPath}/engine/params-runtime.ts';
92
+ import {DEFAULT_BUDGET} from '${srcPath}/engine/bot-compute-budget.ts';
92
93
 
93
94
  globalThis.__fight = function __fight(options) {
94
- const result = fight(__Bot1, __Bot2, options);
95
+ // Budgeted like every other fight path. This one matters MORE than it looks: it is what
96
+ // sandboxFight uses, so it is the MCP fight tools and the CLI — the place a user's
97
+ // runaway bot most directly burns server time. Leaving it unbudgeted while the backstop
98
+ // moved from 60s to 110s would have made this path strictly worse than before.
99
+ const result = fight(__Bot1, __Bot2, {budgetLimits: DEFAULT_BUDGET, ...options});
95
100
  return result;
96
101
  };
97
102
 
@@ -99,7 +104,10 @@ globalThis.__simulate = function __simulate(options) {
99
104
  const {params1, params2, ...simOptions} = options;
100
105
  const bot1 = params1 ? wrapWithParams(__Bot1, params1) : __Bot1;
101
106
  const bot2 = params2 ? wrapWithParams(__Bot2, params2) : __Bot2;
102
- const result = simulate(bot1, bot2, simOptions);
107
+ // Budgeted like __fight. This path is trace and the optimizer — a single match rather
108
+ // than ten — and leaving it out would have made it strictly worse than before, since the
109
+ // same change raised its backstop from 60s to 110s.
110
+ const result = simulate(bot1, bot2, {budgetLimits: DEFAULT_BUDGET, ...simOptions});
103
111
  return result;
104
112
  };
105
113
  `;
@@ -280,10 +288,12 @@ import {${bot1ExportName} as __Bot1} from '${bot1Path}';
280
288
  import {${bot2ExportName} as __Bot2} from '${bot2Path}';
281
289
  import {fight, simulate} from '${srcPath}/engine/simulation.ts';
282
290
  import {wrapWithParams} from '${srcPath}/engine/params-runtime.ts';
291
+ import {DEFAULT_BUDGET} from '${srcPath}/engine/bot-compute-budget.ts';
283
292
 
284
293
  globalThis.__fightJSON = function __fightJSON(optionsJSON) {
285
294
  const options = JSON.parse(optionsJSON);
286
- const result = fight(__Bot1, __Bot2, options);
295
+ // Budgeted, same as the non-JSON variant — two generators, one contract.
296
+ const result = fight(__Bot1, __Bot2, {budgetLimits: DEFAULT_BUDGET, ...options});
287
297
  return JSON.stringify(result);
288
298
  };
289
299
 
@@ -292,7 +302,8 @@ globalThis.__simulateJSON = function __simulateJSON(optionsJSON) {
292
302
  const {params1, params2, ...simOptions} = options;
293
303
  const bot1 = params1 ? wrapWithParams(__Bot1, params1) : __Bot1;
294
304
  const bot2 = params2 ? wrapWithParams(__Bot2, params2) : __Bot2;
295
- const result = simulate(bot1, bot2, simOptions);
305
+ // Budgeted, same as the non-JSON variant — two generators, one contract.
306
+ const result = simulate(bot1, bot2, {budgetLimits: DEFAULT_BUDGET, ...simOptions});
296
307
  return JSON.stringify(result);
297
308
  };
298
309
  `;
@@ -20,6 +20,7 @@
20
20
  import ivm from 'isolated-vm';
21
21
  import type {FightResult, SimulateResult} from './simulation.js';
22
22
  import {BotBundle, compileMatchBundle} from './sandbox-compile.js';
23
+ import {DEFAULT_FIGHT_BACKSTOP_MS} from './bot-compute-budget.js';
23
24
  import type {CompileOptions} from './sandbox-compile.js';
24
25
 
25
26
  // Re-export BotBundle so existing imports from sandbox.ts keep working
@@ -86,7 +87,11 @@ export class MatchSandbox
86
87
  ): Promise<MatchSandbox>
87
88
  {
88
89
  const memoryLimitMB = options?.memoryLimitMB ?? 512;
89
- const timeoutMs = options?.timeoutMs ?? 60000;
90
+ // Was a bare 60000, which the per-bot compute budget made wrong: a budgeted fight can
91
+ // legitimately let one runaway bot spend 45s before it stops being called, and 60s
92
+ // here would kill the whole fight first — putting the wall clock back in charge of
93
+ // outcomes. Shared constant so this cannot drift from the other copies again.
94
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_FIGHT_BACKSTOP_MS;
90
95
 
91
96
  // 1. Compile the match bundle
92
97
  const bundle = await compileMatchBundle(bot1, bot2, options?.compileOptions);
@@ -133,7 +138,11 @@ export class MatchSandbox
133
138
  ): Promise<MatchSandbox>
134
139
  {
135
140
  const memoryLimitMB = options?.memoryLimitMB ?? 512;
136
- const timeoutMs = options?.timeoutMs ?? 60000;
141
+ // Was a bare 60000, which the per-bot compute budget made wrong: a budgeted fight can
142
+ // legitimately let one runaway bot spend 45s before it stops being called, and 60s
143
+ // here would kill the whole fight first — putting the wall clock back in charge of
144
+ // outcomes. Shared constant so this cannot drift from the other copies again.
145
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_FIGHT_BACKSTOP_MS;
137
146
 
138
147
  const isolate = new ivm.Isolate({memoryLimit: memoryLimitMB});
139
148
 
@@ -30,6 +30,7 @@ import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveW
30
30
  import {applyDamage, updateShield, startCast, completeCast} from './spells.js';
31
31
  import {runWithHooks, resetAllHooks, clearHooks} from './hooks-runtime.js';
32
32
  import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
33
+ import {createBudgetState, createFightBudget, recordSpend, mayAct, type BotBudgetState, type BudgetLimits, type FightBudget} from './bot-compute-budget.js';
33
34
  import {createRandom, createEntitySeed} from '../utils/random.js';
34
35
 
35
36
  export interface InternalWizardState extends WizardState
@@ -158,12 +159,14 @@ export function tick(
158
159
  projectiles: ProjectileState[],
159
160
  missileAIs: Map<string, MissileFunction>,
160
161
  matchSeed: number,
162
+ budgets?: {states: [BotBudgetState, BotBudgetState]; limits: BudgetLimits},
161
163
  ): {
162
164
  nextTick: number;
163
165
  wizards: InternalWizardState[];
164
166
  projectiles: ProjectileState[];
165
167
  events: SimEvent[];
166
168
  errors: BotError[];
169
+ budgets?: [BotBudgetState, BotBudgetState];
167
170
  }
168
171
  {
169
172
  const nextTick = currentTick + 1;
@@ -176,29 +179,68 @@ export function tick(
176
179
  const random2 = createRandom(createEntitySeed(matchSeed, wizards[1]!.id, nextTick));
177
180
 
178
181
  // Wrap AI calls in try-catch - if AI throws, wizard does nothing (Lesson #21)
179
- let actions1: WizardActions = {move: {x: 0, y: 0}};
180
- try
182
+ const IDLE_ACTION = (): WizardActions => ({move: {x: 0, y: 0}});
183
+
184
+ // Budget state is threaded through rather than mutated, so tick() stays as pure as it
185
+ // was. When no budget is supplied (manual play, most tests) this is all inert and the
186
+ // clock is never read.
187
+ const nextBudgets: [BotBudgetState, BotBudgetState] | undefined = budgets
188
+ ? [budgets.states[0], budgets.states[1]]
189
+ : undefined;
190
+
191
+ /**
192
+ * Call one bot under its compute budget.
193
+ *
194
+ * Measuring NEVER changes this tick's action. That is deliberate and load-bearing: this
195
+ * engine is deterministic, and an earlier version of this guard discarded the action of
196
+ * any call over 50ms, which made outcomes depend on GC pauses and machine load. The only
197
+ * thing the budget changes is whether a bot is called AT ALL, and only once it has spent
198
+ * its whole fight allowance — which an honest bot never approaches. See
199
+ * bot-compute-budget.ts.
200
+ *
201
+ * Exhaustion is never recorded as an error either. In-sim errors feed consecutiveCrashes
202
+ * and auto-deactivate a bot at three; being slow on a busy server must not do that.
203
+ */
204
+ const runBot = (index: 0 | 1, entityId: string, invoke: () => WizardActions): WizardActions =>
205
+ {
206
+ const state = nextBudgets?.[index];
207
+
208
+ // An exhausted bot is not called at all, so its remaining ticks are free.
209
+ if (state && !mayAct(state)) return IDLE_ACTION();
210
+
211
+ const startedAt = state ? Date.now() : 0;
212
+ let action: WizardActions;
213
+ try
214
+ {
215
+ action = invoke();
216
+ }
217
+ catch(e)
218
+ {
219
+ errors.push({tick: nextTick, entityId, message: e instanceof Error ? e.message : String(e)});
220
+ action = IDLE_ACTION();
221
+ }
222
+
223
+ if (state && budgets)
224
+ {
225
+ // A throw is charged too — it still consumed the time.
226
+ nextBudgets![index] = recordSpend(state, budgets.limits, Date.now() - startedAt);
227
+ }
228
+ return action;
229
+ };
230
+
231
+ const actions1 = runBot(0, 'wizard-1', () =>
181
232
  {
182
233
  const stateView1 = getPlayerStateView(0, wizards, projectiles, nextTick);
183
234
  const ctx1: WizardContext = buildWizardContext(stateView1, config, random1);
184
- actions1 = runWithHooks(wizards[0]!.id, () => extractAction(withWizardContext(ctx1, wizard1AI))) ?? {move: {x: 0, y: 0}};
185
- }
186
- catch(e)
187
- {
188
- errors.push({tick: nextTick, entityId: 'wizard-1', message: e instanceof Error ? e.message : String(e)});
189
- }
235
+ return runWithHooks(wizards[0]!.id, () => extractAction(withWizardContext(ctx1, wizard1AI))) ?? IDLE_ACTION();
236
+ });
190
237
 
191
- let actions2: WizardActions = {move: {x: 0, y: 0}};
192
- try
238
+ const actions2 = runBot(1, 'wizard-2', () =>
193
239
  {
194
240
  const stateView2 = getPlayerStateView(1, wizards, projectiles, nextTick);
195
241
  const ctx2: WizardContext = buildWizardContext(stateView2, config, random2);
196
- actions2 = runWithHooks(wizards[1]!.id, () => extractAction(withWizardContext(ctx2, wizard2AI))) ?? {move: {x: 0, y: 0}};
197
- }
198
- catch(e)
199
- {
200
- errors.push({tick: nextTick, entityId: 'wizard-2', message: e instanceof Error ? e.message : String(e)});
201
- }
242
+ return runWithHooks(wizards[1]!.id, () => extractAction(withWizardContext(ctx2, wizard2AI))) ?? IDLE_ACTION();
243
+ });
202
244
 
203
245
  const actions = [actions1, actions2];
204
246
 
@@ -602,6 +644,7 @@ export function tick(
602
644
  projectiles: remainingProjectiles,
603
645
  events,
604
646
  errors,
647
+ budgets: nextBudgets,
605
648
  };
606
649
  }
607
650
 
@@ -839,6 +882,11 @@ export function fight(
839
882
  options: {
840
883
  seed?: number;
841
884
  maxTicks?: number;
885
+ /**
886
+ * Per-bot compute budget for the WHOLE fight — all ten matches share it. Omitted
887
+ * means unbudgeted. See bot-compute-budget.ts.
888
+ */
889
+ budgetLimits?: BudgetLimits;
842
890
  } = {},
843
891
  ): FightResult
844
892
  {
@@ -847,6 +895,9 @@ export function fight(
847
895
  let wizard2Wins = 0;
848
896
  let draws = 0;
849
897
 
898
+ // One budget for the fight, not one per match — see FightBudget.
899
+ const budget = options.budgetLimits ? createFightBudget(options.budgetLimits) : undefined;
900
+
850
901
  for (const spawnDistance of FIGHT_SPAWN_DISTANCES)
851
902
  {
852
903
  // Normal side: wizard1 on left, wizard2 on right
@@ -854,6 +905,7 @@ export function fight(
854
905
  seed: options.seed,
855
906
  maxTicks: options.maxTicks,
856
907
  spawnDistance,
908
+ budget,
857
909
  });
858
910
 
859
911
  matches.push(result);
@@ -873,13 +925,27 @@ export function fight(
873
925
 
874
926
  // Swapped side: wizard2 on left, wizard1 on right
875
927
  // skipHistory: swapped matches are only for scoring, not visual playback
928
+ //
929
+ // Budget states are indexed by POSITION, so they must be swapped alongside the bots
930
+ // or each bot is charged for the other's time — which would let a runaway bot burn
931
+ // its opponent's budget and get its own back.
932
+ const swappedBudget = budget
933
+ ? {states: [budget.states[1], budget.states[0]] as [BotBudgetState, BotBudgetState], limits: budget.limits}
934
+ : undefined;
935
+
876
936
  const swapped = simulate(wizard2AI, wizard1AI, {
877
937
  seed: options.seed,
878
938
  maxTicks: options.maxTicks,
879
939
  spawnDistance,
880
940
  skipHistory: true,
941
+ budget: swappedBudget,
881
942
  });
882
943
 
944
+ if (budget && swappedBudget)
945
+ {
946
+ budget.states = [swappedBudget.states[1], swappedBudget.states[0]];
947
+ }
948
+
883
949
  // Don't push swapped match to matches array (it's only for scoring)
884
950
  if (swapped.winner === 'wizard-1')
885
951
  {
@@ -919,6 +985,19 @@ export function simulate(
919
985
  seed?: number;
920
986
  spawnDistance?: number;
921
987
  skipHistory?: boolean;
988
+ /**
989
+ * Per-bot compute budget for this match alone. Omitted means unbudgeted, which is
990
+ * what manual play and most tests want — the clock is then never read at all. See
991
+ * bot-compute-budget.ts for why exceeding it costs a bot its action rather than
992
+ * producing an error.
993
+ */
994
+ budgetLimits?: BudgetLimits;
995
+ /**
996
+ * A budget SHARED across every match of a fight, updated in place as this match
997
+ * runs. This is what `fight()` passes, because a fight is ten matches and a
998
+ * per-match budget would bound nothing. Takes precedence over `budgetLimits`.
999
+ */
1000
+ budget?: FightBudget;
922
1001
  } = {},
923
1002
  ): SimulateResult
924
1003
  {
@@ -992,9 +1071,26 @@ export function simulate(
992
1071
 
993
1072
  let deathTick: number | null = null;
994
1073
 
1074
+ // A shared fight budget wins over per-match limits: a fight is ten matches, and only the
1075
+ // shared one can bound the whole thing.
1076
+ const sharedBudget = options.budget;
1077
+ const budgetLimits = sharedBudget?.limits ?? options.budgetLimits;
1078
+ let budgets: [BotBudgetState, BotBudgetState] | undefined = sharedBudget
1079
+ ? sharedBudget.states
1080
+ : (budgetLimits ? [createBudgetState(), createBudgetState()] : undefined);
1081
+
995
1082
  while (currentTick < maxTicks)
996
1083
  {
997
- const result = tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, seed);
1084
+ const result = tick(
1085
+ currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, seed,
1086
+ budgetLimits && budgets ? {states: budgets, limits: budgetLimits} : undefined,
1087
+ );
1088
+ if (result.budgets)
1089
+ {
1090
+ budgets = result.budgets;
1091
+ // Write the spend back so it survives into the fight's remaining matches.
1092
+ if (sharedBudget) sharedBudget.states = result.budgets;
1093
+ }
998
1094
  currentTick = result.nextTick;
999
1095
  wizards = result.wizards;
1000
1096
  projectiles = result.projectiles;
@@ -9,4 +9,4 @@
9
9
  * Used to gate spectator replays: a recorded match can only be re-simulated when
10
10
  * the runtime engine version matches the version that produced the match.
11
11
  */
12
- export const ENGINE_VERSION = 1203306608058186;
12
+ export const ENGINE_VERSION = 1709865010946010;
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ export * from './types.js';
2
2
  export * from './rules.js';
3
3
  export * from './engine-version.js';
4
4
  export * from './engine/simulation.js';
5
+ export {DEFAULT_BUDGET, DEFAULT_FIGHT_BACKSTOP_MS, createBudgetState, createFightBudget, recordSpend, mayAct} from './engine/bot-compute-budget.js';
6
+ export type {BudgetLimits, BotBudgetState, FightBudget} from './engine/bot-compute-budget.js';
5
7
  export * from './engine/hooks-runtime.js';
6
8
  export * from './engine/physics.js';
7
9
  export * from './engine/spells.js';