@vibemancer/core 1.0.10 → 1.0.11

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.
@@ -1,341 +1,347 @@
1
- /**
2
- * VIBEMANCER — SANDBOX
3
- *
4
- * Provides isolated-vm sandboxing for bot code execution. Both bots + the
5
- * entire simulation engine run inside a single V8 isolate, so there is ZERO
6
- * per-tick boundary crossing overhead. The only data crossing the boundary
7
- * is fight/simulate options going in and results coming out.
8
- *
9
- * Architecture:
10
- * - Host: creates isolate, loads compiled bundle, calls __fight/__simulate
11
- * - Isolate: contains both bots + full simulation engine, runs fight/simulate
12
- *
13
- * Safety:
14
- * - Memory limit (default 512 MB) catches memory bombs
15
- * - Timeout (default 30s) catches infinite loops
16
- * - Prototype freeze prevents cross-bot sabotage
17
- * - platform: 'neutral' strips Node.js APIs (no fs/net/process)
18
- */
19
-
20
- import ivm from 'isolated-vm';
21
- import type {FightResult, SimulateResult} from './simulation.js';
22
- import {BotBundle, compileMatchBundle} from './sandbox-compile.js';
23
- import {DEFAULT_FIGHT_BACKSTOP_MS} from './bot-compute-budget.js';
24
- import type {CompileOptions} from './sandbox-compile.js';
25
-
26
- // Re-export BotBundle so existing imports from sandbox.ts keep working
27
- export {BotBundle} from './sandbox-compile.js';
28
- export {compileMatchBundle, compileManualMatchBundle} from './sandbox-compile.js';
29
- export type {CompileOptions} from './sandbox-compile.js';
30
-
31
- /**
32
- * Options for sandbox creation.
33
- */
34
- export interface SandboxOptions
35
- {
36
- /** Memory limit in MB for the isolate (default: 512). */
37
- memoryLimitMB?: number;
38
- /** Timeout in ms for fight/simulate calls (default: 60000). */
39
- timeoutMs?: number;
40
- /** Options passed to esbuild compilation (aliases, externals). */
41
- compileOptions?: CompileOptions;
42
- }
43
-
44
- /**
45
- * A sandboxed match runner. Both bots + the entire simulation engine run
46
- * inside a single isolated-vm isolate.
47
- *
48
- * Usage:
49
- * ```ts
50
- * const sandbox = await MatchSandbox.create(botA, botB);
51
- * const result = sandbox.fight({ seed: 42 });
52
- * sandbox.dispose();
53
- * ```
54
- */
55
- export class MatchSandbox
56
- {
57
- private isolate: ivm.Isolate;
58
- private context: ivm.Context;
59
- private fightFn: ivm.Reference;
60
- private simulateFn: ivm.Reference;
61
- private timeout: number;
62
- private disposed = false;
63
-
64
- private constructor(
65
- isolate: ivm.Isolate,
66
- context: ivm.Context,
67
- fightFn: ivm.Reference,
68
- simulateFn: ivm.Reference,
69
- timeout: number,
70
- )
71
- {
72
- this.isolate = isolate;
73
- this.context = context;
74
- this.fightFn = fightFn;
75
- this.simulateFn = simulateFn;
76
- this.timeout = timeout;
77
- }
78
-
79
- /**
80
- * Create a sandbox with both bots loaded. Compiles the match bundle
81
- * automatically using esbuild.
82
- */
83
- static async create(
84
- bot1: BotBundle,
85
- bot2: BotBundle,
86
- options?: SandboxOptions,
87
- ): Promise<MatchSandbox>
88
- {
89
- const memoryLimitMB = options?.memoryLimitMB ?? 512;
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;
95
-
96
- // 1. Compile the match bundle
97
- const bundle = await compileMatchBundle(bot1, bot2, options?.compileOptions);
98
-
99
- // 2. Create isolate with memory limit
100
- const isolate = new ivm.Isolate({memoryLimit: memoryLimitMB});
101
-
102
- try
103
- {
104
- // 3. Create context and load the bundle
105
- const context = await isolate.createContext();
106
- const script = await isolate.compileScript(bundle, {filename: 'match-bundle.js'});
107
- await script.run(context, {timeout: timeoutMs});
108
-
109
- // 4. Get references to the exposed functions
110
- const global = context.global;
111
- const fightFn = await global.get('__fight', {reference: true});
112
- const simulateFn = await global.get('__simulate', {reference: true});
113
-
114
- return new MatchSandbox(isolate, context, fightFn, simulateFn, timeoutMs);
115
- }
116
- catch(error)
117
- {
118
- // OOM or timeout can auto-dispose the isolate, so guard the cleanup dispose.
119
- try
120
- {
121
- isolate.dispose();
122
- }
123
- catch
124
- {
125
- // already disposed
126
- }
127
- throw error;
128
- }
129
- }
130
-
131
- /**
132
- * Create a sandbox from a pre-compiled bundle string.
133
- * Useful for caching compiled bundles across multiple MatchSandbox instances.
134
- */
135
- static async fromBundle(
136
- bundle: string,
137
- options?: SandboxOptions,
138
- ): Promise<MatchSandbox>
139
- {
140
- const memoryLimitMB = options?.memoryLimitMB ?? 512;
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;
146
-
147
- const isolate = new ivm.Isolate({memoryLimit: memoryLimitMB});
148
-
149
- try
150
- {
151
- const context = await isolate.createContext();
152
- const script = await isolate.compileScript(bundle, {filename: 'match-bundle.js'});
153
- await script.run(context, {timeout: timeoutMs});
154
-
155
- const global = context.global;
156
- const fightFn = await global.get('__fight', {reference: true});
157
- const simulateFn = await global.get('__simulate', {reference: true});
158
-
159
- return new MatchSandbox(isolate, context, fightFn, simulateFn, timeoutMs);
160
- }
161
- catch(error)
162
- {
163
- try
164
- {
165
- isolate.dispose();
166
- }
167
- catch
168
- {
169
- // already disposed
170
- }
171
- throw error;
172
- }
173
- }
174
-
175
- /**
176
- * Compile a match bundle without creating an isolate.
177
- * Returns the compiled JS string for caching/reuse.
178
- */
179
- static async compile(bot1: BotBundle, bot2: BotBundle, compileOptions?: CompileOptions): Promise<string>
180
- {
181
- return compileMatchBundle(bot1, bot2, compileOptions);
182
- }
183
-
184
- /**
185
- * Run a full fight (10 matches: 5 spawn distances x 2 sides).
186
- * Synchronous after isolate creation — runs entirely inside the isolate.
187
- */
188
- fight(options?: {seed?: number; maxTicks?: number}): FightResult
189
- {
190
- this.ensureNotDisposed();
191
-
192
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- isolated-vm returns unknown
193
- return this.fightFn.applySync(undefined, [options ?? {}], {
194
- arguments: {copy: true},
195
- result: {copy: true},
196
- timeout: this.timeout,
197
- }) as FightResult;
198
- }
199
-
200
- /**
201
- * Run a single simulation.
202
- * Synchronous after isolate creation.
203
- *
204
- * @param options.params1 - useParam overrides for bot 1 (wizard-1)
205
- * @param options.params2 - useParam overrides for bot 2 (wizard-2)
206
- */
207
- simulate(options?: {
208
- seed?: number;
209
- maxTicks?: number;
210
- spawnDistance?: number;
211
- skipHistory?: boolean;
212
- params1?: Record<string, number>;
213
- params2?: Record<string, number>;
214
- }): SimulateResult
215
- {
216
- this.ensureNotDisposed();
217
-
218
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- isolated-vm returns unknown
219
- return this.simulateFn.applySync(undefined, [options ?? {}], {
220
- arguments: {copy: true},
221
- result: {copy: true},
222
- timeout: this.timeout,
223
- }) as SimulateResult;
224
- }
225
-
226
- /**
227
- * Dispose the isolate and free all memory.
228
- * The sandbox cannot be used after disposal.
229
- */
230
- dispose(): void
231
- {
232
- if (!this.disposed)
233
- {
234
- this.disposed = true;
235
- // OOM can auto-dispose the isolate, so guard all cleanup
236
- try
237
- {
238
- this.fightFn.release();
239
- }
240
- catch
241
- {
242
- // isolate already disposed
243
- }
244
- try
245
- {
246
- this.simulateFn.release();
247
- }
248
- catch
249
- {
250
- // isolate already disposed
251
- }
252
- try
253
- {
254
- this.context.release();
255
- }
256
- catch
257
- {
258
- // isolate already disposed
259
- }
260
- try
261
- {
262
- this.isolate.dispose();
263
- }
264
- catch
265
- {
266
- // isolate already disposed
267
- }
268
- }
269
- }
270
-
271
- /**
272
- * Whether this sandbox has been disposed.
273
- */
274
- get isDisposed(): boolean
275
- {
276
- return this.disposed;
277
- }
278
-
279
- private ensureNotDisposed(): void
280
- {
281
- if (this.disposed)
282
- {
283
- throw new Error('MatchSandbox has been disposed');
284
- }
285
- }
286
- }
287
-
288
- /**
289
- * One-shot sandboxed fight. Creates isolate, runs fight, disposes.
290
- * Convenience wrapper for single-use scenarios.
291
- */
292
- export async function sandboxFight(
293
- bot1: BotBundle,
294
- bot2: BotBundle,
295
- options?: {seed?: number; maxTicks?: number} & SandboxOptions,
296
- ): Promise<FightResult>
297
- {
298
- const sandbox = await MatchSandbox.create(bot1, bot2, options);
299
- try
300
- {
301
- return sandbox.fight({seed: options?.seed, maxTicks: options?.maxTicks});
302
- }
303
- finally
304
- {
305
- sandbox.dispose();
306
- }
307
- }
308
-
309
- /**
310
- * One-shot sandboxed simulate. Creates isolate, runs simulate, disposes.
311
- */
312
- export async function sandboxSimulate(
313
- bot1: BotBundle,
314
- bot2: BotBundle,
315
- options?: {
316
- seed?: number;
317
- maxTicks?: number;
318
- spawnDistance?: number;
319
- skipHistory?: boolean;
320
- params1?: Record<string, number>;
321
- params2?: Record<string, number>;
322
- } & SandboxOptions,
323
- ): Promise<SimulateResult>
324
- {
325
- const sandbox = await MatchSandbox.create(bot1, bot2, options);
326
- try
327
- {
328
- return sandbox.simulate({
329
- seed: options?.seed,
330
- maxTicks: options?.maxTicks,
331
- spawnDistance: options?.spawnDistance,
332
- skipHistory: options?.skipHistory,
333
- params1: options?.params1,
334
- params2: options?.params2,
335
- });
336
- }
337
- finally
338
- {
339
- sandbox.dispose();
340
- }
341
- }
1
+ /**
2
+ * VIBEMANCER — SANDBOX
3
+ *
4
+ * Provides isolated-vm sandboxing for bot code execution. Both bots + the
5
+ * entire simulation engine run inside a single V8 isolate, so there is ZERO
6
+ * per-tick boundary crossing overhead. The only data crossing the boundary
7
+ * is fight/simulate options going in and results coming out.
8
+ *
9
+ * Architecture:
10
+ * - Host: creates isolate, loads compiled bundle, calls __fight/__simulate
11
+ * - Isolate: contains both bots + full simulation engine, runs fight/simulate
12
+ *
13
+ * Safety:
14
+ * - Memory limit (default 512 MB) catches memory bombs
15
+ * - Timeout (default DEFAULT_FIGHT_BACKSTOP_MS) catches infinite loops
16
+ * - Prototype freeze prevents cross-bot sabotage
17
+ * - platform: 'neutral' strips Node.js APIs (no fs/net/process)
18
+ */
19
+
20
+ import ivm from 'isolated-vm';
21
+ import type {FightResult, SimulateResult} from './simulation.js';
22
+ import {BotBundle, compileMatchBundle} from './sandbox-compile.js';
23
+ import {DEFAULT_FIGHT_BACKSTOP_MS} from './bot-compute-budget.js';
24
+ import type {CompileOptions} from './sandbox-compile.js';
25
+
26
+ // Re-export BotBundle so existing imports from sandbox.ts keep working
27
+ export {BotBundle} from './sandbox-compile.js';
28
+ export {compileMatchBundle, compileManualMatchBundle} from './sandbox-compile.js';
29
+ export type {CompileOptions} from './sandbox-compile.js';
30
+
31
+ /**
32
+ * Options for sandbox creation.
33
+ */
34
+ export interface SandboxOptions
35
+ {
36
+ /** Memory limit in MB for the isolate (default: 512). */
37
+ memoryLimitMB?: number;
38
+ /**
39
+ * Timeout in ms for fight/simulate calls. Defaults to DEFAULT_FIGHT_BACKSTOP_MS.
40
+ *
41
+ * Named rather than written out, because this file previously gave THREE different
42
+ * numbers for one default — "30s" in the header, "60000" here, and the actual value used
43
+ * below — and a reader had no way to tell which was true.
44
+ */
45
+ timeoutMs?: number;
46
+ /** Options passed to esbuild compilation (aliases, externals). */
47
+ compileOptions?: CompileOptions;
48
+ }
49
+
50
+ /**
51
+ * A sandboxed match runner. Both bots + the entire simulation engine run
52
+ * inside a single isolated-vm isolate.
53
+ *
54
+ * Usage:
55
+ * ```ts
56
+ * const sandbox = await MatchSandbox.create(botA, botB);
57
+ * const result = sandbox.fight({ seed: 42 });
58
+ * sandbox.dispose();
59
+ * ```
60
+ */
61
+ export class MatchSandbox
62
+ {
63
+ private isolate: ivm.Isolate;
64
+ private context: ivm.Context;
65
+ private fightFn: ivm.Reference;
66
+ private simulateFn: ivm.Reference;
67
+ private timeout: number;
68
+ private disposed = false;
69
+
70
+ private constructor(
71
+ isolate: ivm.Isolate,
72
+ context: ivm.Context,
73
+ fightFn: ivm.Reference,
74
+ simulateFn: ivm.Reference,
75
+ timeout: number,
76
+ )
77
+ {
78
+ this.isolate = isolate;
79
+ this.context = context;
80
+ this.fightFn = fightFn;
81
+ this.simulateFn = simulateFn;
82
+ this.timeout = timeout;
83
+ }
84
+
85
+ /**
86
+ * Create a sandbox with both bots loaded. Compiles the match bundle
87
+ * automatically using esbuild.
88
+ */
89
+ static async create(
90
+ bot1: BotBundle,
91
+ bot2: BotBundle,
92
+ options?: SandboxOptions,
93
+ ): Promise<MatchSandbox>
94
+ {
95
+ const memoryLimitMB = options?.memoryLimitMB ?? 512;
96
+ // Was a bare 60000, which the per-bot compute budget made wrong: a budgeted fight can
97
+ // legitimately let one runaway bot spend 45s before it stops being called, and 60s
98
+ // here would kill the whole fight first — putting the wall clock back in charge of
99
+ // outcomes. Shared constant so this cannot drift from the other copies again.
100
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_FIGHT_BACKSTOP_MS;
101
+
102
+ // 1. Compile the match bundle
103
+ const bundle = await compileMatchBundle(bot1, bot2, options?.compileOptions);
104
+
105
+ // 2. Create isolate with memory limit
106
+ const isolate = new ivm.Isolate({memoryLimit: memoryLimitMB});
107
+
108
+ try
109
+ {
110
+ // 3. Create context and load the bundle
111
+ const context = await isolate.createContext();
112
+ const script = await isolate.compileScript(bundle, {filename: 'match-bundle.js'});
113
+ await script.run(context, {timeout: timeoutMs});
114
+
115
+ // 4. Get references to the exposed functions
116
+ const global = context.global;
117
+ const fightFn = await global.get('__fight', {reference: true});
118
+ const simulateFn = await global.get('__simulate', {reference: true});
119
+
120
+ return new MatchSandbox(isolate, context, fightFn, simulateFn, timeoutMs);
121
+ }
122
+ catch(error)
123
+ {
124
+ // OOM or timeout can auto-dispose the isolate, so guard the cleanup dispose.
125
+ try
126
+ {
127
+ isolate.dispose();
128
+ }
129
+ catch
130
+ {
131
+ // already disposed
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Create a sandbox from a pre-compiled bundle string.
139
+ * Useful for caching compiled bundles across multiple MatchSandbox instances.
140
+ */
141
+ static async fromBundle(
142
+ bundle: string,
143
+ options?: SandboxOptions,
144
+ ): Promise<MatchSandbox>
145
+ {
146
+ const memoryLimitMB = options?.memoryLimitMB ?? 512;
147
+ // Was a bare 60000, which the per-bot compute budget made wrong: a budgeted fight can
148
+ // legitimately let one runaway bot spend 45s before it stops being called, and 60s
149
+ // here would kill the whole fight first — putting the wall clock back in charge of
150
+ // outcomes. Shared constant so this cannot drift from the other copies again.
151
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_FIGHT_BACKSTOP_MS;
152
+
153
+ const isolate = new ivm.Isolate({memoryLimit: memoryLimitMB});
154
+
155
+ try
156
+ {
157
+ const context = await isolate.createContext();
158
+ const script = await isolate.compileScript(bundle, {filename: 'match-bundle.js'});
159
+ await script.run(context, {timeout: timeoutMs});
160
+
161
+ const global = context.global;
162
+ const fightFn = await global.get('__fight', {reference: true});
163
+ const simulateFn = await global.get('__simulate', {reference: true});
164
+
165
+ return new MatchSandbox(isolate, context, fightFn, simulateFn, timeoutMs);
166
+ }
167
+ catch(error)
168
+ {
169
+ try
170
+ {
171
+ isolate.dispose();
172
+ }
173
+ catch
174
+ {
175
+ // already disposed
176
+ }
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Compile a match bundle without creating an isolate.
183
+ * Returns the compiled JS string for caching/reuse.
184
+ */
185
+ static async compile(bot1: BotBundle, bot2: BotBundle, compileOptions?: CompileOptions): Promise<string>
186
+ {
187
+ return compileMatchBundle(bot1, bot2, compileOptions);
188
+ }
189
+
190
+ /**
191
+ * Run a full fight (10 matches: 5 spawn distances x 2 sides).
192
+ * Synchronous after isolate creation runs entirely inside the isolate.
193
+ */
194
+ fight(options?: {seed?: number; maxTicks?: number}): FightResult
195
+ {
196
+ this.ensureNotDisposed();
197
+
198
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- isolated-vm returns unknown
199
+ return this.fightFn.applySync(undefined, [options ?? {}], {
200
+ arguments: {copy: true},
201
+ result: {copy: true},
202
+ timeout: this.timeout,
203
+ }) as FightResult;
204
+ }
205
+
206
+ /**
207
+ * Run a single simulation.
208
+ * Synchronous after isolate creation.
209
+ *
210
+ * @param options.params1 - useParam overrides for bot 1 (wizard-1)
211
+ * @param options.params2 - useParam overrides for bot 2 (wizard-2)
212
+ */
213
+ simulate(options?: {
214
+ seed?: number;
215
+ maxTicks?: number;
216
+ spawnDistance?: number;
217
+ skipHistory?: boolean;
218
+ params1?: Record<string, number>;
219
+ params2?: Record<string, number>;
220
+ }): SimulateResult
221
+ {
222
+ this.ensureNotDisposed();
223
+
224
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- isolated-vm returns unknown
225
+ return this.simulateFn.applySync(undefined, [options ?? {}], {
226
+ arguments: {copy: true},
227
+ result: {copy: true},
228
+ timeout: this.timeout,
229
+ }) as SimulateResult;
230
+ }
231
+
232
+ /**
233
+ * Dispose the isolate and free all memory.
234
+ * The sandbox cannot be used after disposal.
235
+ */
236
+ dispose(): void
237
+ {
238
+ if (!this.disposed)
239
+ {
240
+ this.disposed = true;
241
+ // OOM can auto-dispose the isolate, so guard all cleanup
242
+ try
243
+ {
244
+ this.fightFn.release();
245
+ }
246
+ catch
247
+ {
248
+ // isolate already disposed
249
+ }
250
+ try
251
+ {
252
+ this.simulateFn.release();
253
+ }
254
+ catch
255
+ {
256
+ // isolate already disposed
257
+ }
258
+ try
259
+ {
260
+ this.context.release();
261
+ }
262
+ catch
263
+ {
264
+ // isolate already disposed
265
+ }
266
+ try
267
+ {
268
+ this.isolate.dispose();
269
+ }
270
+ catch
271
+ {
272
+ // isolate already disposed
273
+ }
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Whether this sandbox has been disposed.
279
+ */
280
+ get isDisposed(): boolean
281
+ {
282
+ return this.disposed;
283
+ }
284
+
285
+ private ensureNotDisposed(): void
286
+ {
287
+ if (this.disposed)
288
+ {
289
+ throw new Error('MatchSandbox has been disposed');
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * One-shot sandboxed fight. Creates isolate, runs fight, disposes.
296
+ * Convenience wrapper for single-use scenarios.
297
+ */
298
+ export async function sandboxFight(
299
+ bot1: BotBundle,
300
+ bot2: BotBundle,
301
+ options?: {seed?: number; maxTicks?: number} & SandboxOptions,
302
+ ): Promise<FightResult>
303
+ {
304
+ const sandbox = await MatchSandbox.create(bot1, bot2, options);
305
+ try
306
+ {
307
+ return sandbox.fight({seed: options?.seed, maxTicks: options?.maxTicks});
308
+ }
309
+ finally
310
+ {
311
+ sandbox.dispose();
312
+ }
313
+ }
314
+
315
+ /**
316
+ * One-shot sandboxed simulate. Creates isolate, runs simulate, disposes.
317
+ */
318
+ export async function sandboxSimulate(
319
+ bot1: BotBundle,
320
+ bot2: BotBundle,
321
+ options?: {
322
+ seed?: number;
323
+ maxTicks?: number;
324
+ spawnDistance?: number;
325
+ skipHistory?: boolean;
326
+ params1?: Record<string, number>;
327
+ params2?: Record<string, number>;
328
+ } & SandboxOptions,
329
+ ): Promise<SimulateResult>
330
+ {
331
+ const sandbox = await MatchSandbox.create(bot1, bot2, options);
332
+ try
333
+ {
334
+ return sandbox.simulate({
335
+ seed: options?.seed,
336
+ maxTicks: options?.maxTicks,
337
+ spawnDistance: options?.spawnDistance,
338
+ skipHistory: options?.skipHistory,
339
+ params1: options?.params1,
340
+ params2: options?.params2,
341
+ });
342
+ }
343
+ finally
344
+ {
345
+ sandbox.dispose();
346
+ }
347
+ }