@vibemancer/core 1.0.3 → 1.0.5

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,6 +19,7 @@ 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;
@@ -32,7 +33,7 @@ const MEMORY_LIMIT_MB = 256;
32
33
  * finished in 1.3s on the same machine at the same moment. The limit was not catching a
33
34
  * runaway bot; it was catching load.
34
35
  */
35
- export const DEFAULT_FIGHT_TIMEOUT_MS = 60_000;
36
+ export const DEFAULT_FIGHT_TIMEOUT_MS = DEFAULT_FIGHT_BACKSTOP_MS;
36
37
 
37
38
  /**
38
39
  * Resolve the effective fight timeout.
@@ -96,6 +97,7 @@ export function buildMatchTemplate(): Promise<string>
96
97
  const entryPoint = `
97
98
  import {fight, simulate} from '${coreSrc}/engine/simulation.ts';
98
99
  import {wrapWithParams} from '${coreSrc}/engine/params-runtime.ts';
100
+ import {DEFAULT_BUDGET} from '${coreSrc}/engine/bot-compute-budget.ts';
99
101
 
100
102
  const __Bot1 = (globalThis as any).__injectedBot1;
101
103
  const __Bot2 = (globalThis as any).__injectedBot2;
@@ -104,14 +106,18 @@ if (!__Bot1) throw new Error('Bot 1 not injected (set globalThis.__injectedBot1)
104
106
  if (!__Bot2) throw new Error('Bot 2 not injected (set globalThis.__injectedBot2)');
105
107
 
106
108
  (globalThis as any).__fight = function __fight(options: any) {
107
- 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});
108
112
  };
109
113
 
110
114
  (globalThis as any).__simulate = function __simulate(options: any) {
111
115
  const {params1, params2, ...simOptions} = options;
112
116
  const bot1 = params1 ? wrapWithParams(__Bot1, params1) : __Bot1;
113
117
  const bot2 = params2 ? wrapWithParams(__Bot2, params2) : __Bot2;
114
- 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});
115
121
  };
116
122
  `;
117
123
 
@@ -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 = 4221126750888319;
12
+ export const ENGINE_VERSION = 4396905295552957;
@@ -4,9 +4,10 @@
4
4
  * Fluent API for constructing bot actions with type-safe chaining.
5
5
  *
6
6
  * UNITS REFERENCE (100 ticks = 1 second):
7
- * Position: absolute world coordinates, 0-800 on each axis (800×800 arena)
7
+ * Position: absolute world coordinates, 0-860 on each axis (860×860 arena, of which
8
+ * the outer 30 units on every side are lethal lava — playfield is [30, 830])
8
9
  * Movement: direction vector, magnitude clamped to max 1 (speed in [0, 1])
9
- * Speed: units per tick (player moves at 1 unit/tick = 100 units/sec)
10
+ * Speed: units per tick (player moves at 1.5 units/tick = 150 units/sec)
10
11
  * Duration: ticks (divide by 100 for seconds)
11
12
  * Angles: degrees (0°=right, 90°=down, 180°=left, 270°=up)
12
13
  * Damage: raw HP removed on hit (wizard has 60 HP)
@@ -5,7 +5,8 @@
5
5
  * These handle the "subconscious" perception that humans do instinctively.
6
6
  *
7
7
  * UNITS REFERENCE (100 ticks = 1 second):
8
- * Position: absolute world coordinates, 0-800 on each axis (800×800 arena)
8
+ * Position: absolute world coordinates, 0-860 on each axis. The arena is 860x860,
9
+ * but only [30, 830] is safe — the outer 30 units are lethal lava.
9
10
  * Velocity: units per tick on each axis (player max speed = 1 u/t)
10
11
  * Health: hit points (max 60)
11
12
  * Ticks: game ticks (10ms each, 100/sec). Divide by 100 for seconds.
@@ -41,8 +42,15 @@ export function useHealth(): number
41
42
  }
42
43
 
43
44
  /**
44
- * Get your current position as {x, y} in world coordinates (0-800).
45
- * Position is clamped to [5, 795] (arena bounds minus wizard radius).
45
+ * Get your current position as {x, y} in world coordinates (0-860).
46
+ *
47
+ * Clamped to [5, 855] — the ARENA bounds minus the wizard radius, NOT the playfield. That
48
+ * distinction is the difference between living and dying: the playfield is [30, 830] and
49
+ * everything outside it is lava, so you can walk straight out of the safe area and be
50
+ * killed. Nothing stops you. Check against ARENA_MIN/ARENA_MAX, not against these numbers.
51
+ *
52
+ * (This previously quoted the playfield-sized bounds as the clamp, which told the reader
53
+ * they were safely fenced in. They are not — see docs-match-engine.test.ts.)
46
54
  */
47
55
  export function usePosition(): Position
48
56
  {
@@ -298,8 +306,11 @@ export function useLastHitTick(): number
298
306
  // ============================================================
299
307
 
300
308
  /**
301
- * Get arena dimensions. Default: {width: 800, height: 800}.
302
- * Wizards are clamped to [5, 795] on each axis (radius = 5).
309
+ * Get arena dimensions. Default: {width: 860, height: 860} — the FULL arena, lava included.
310
+ * The safe playfield is [30, 830]; the outer 30 units on every side are lethal.
311
+ *
312
+ * Wizards are clamped to [5, 855] (arena bounds minus the radius of 5), which does not keep
313
+ * them out of the lava.
303
314
  */
304
315
  export function useArenaSize(): {width: number; height: number}
305
316
  {
@@ -4,7 +4,8 @@
4
4
  * Types for the hooks-based bot API.
5
5
  *
6
6
  * UNITS REFERENCE (100 ticks = 1 second):
7
- * Position: absolute world coordinates, 0-800 on each axis (800×800 arena)
7
+ * Position: absolute world coordinates, 0-860 on each axis. The arena is 860x860,
8
+ * but only [30, 830] is safe — the outer 30 units are lethal lava.
8
9
  * Velocity: units per tick on each axis (player max speed = 1 u/t)
9
10
  * Health: hit points (max 60)
10
11
  * Ticks: game ticks (10ms each, 100/sec). Divide by 100 for seconds.
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';