@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.
- package/dist/{chunk-XYEK7THS.js → chunk-US4MEDKT.js} +85 -20
- package/dist/chunk-US4MEDKT.js.map +1 -0
- package/dist/index-browser-CYoJrb2d.d.ts +3065 -0
- package/dist/index-browser.d.ts +1 -2878
- package/dist/index-browser.js +3 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +45 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/bots/index.ts +5 -0
- package/src/engine/bot-compute-budget.ts +173 -0
- package/src/engine/bundle-fight.ts +42 -8
- package/src/engine/sandbox-browser.ts +7 -1
- package/src/engine/sandbox-harness.ts +15 -4
- package/src/engine/sandbox.ts +11 -2
- package/src/engine/simulation.ts +113 -17
- package/src/engine-version.ts +1 -1
- package/src/index.ts +2 -0
- package/dist/chunk-XYEK7THS.js.map +0 -1
|
@@ -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
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
`;
|
package/src/engine/sandbox.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/engine/simulation.ts
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|
package/src/engine-version.ts
CHANGED
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';
|