@vibemancer/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/chunk-L7Z7OFXD.js +9140 -0
- package/dist/chunk-L7Z7OFXD.js.map +1 -0
- package/dist/index-browser.d.ts +2602 -0
- package/dist/index-browser.js +407 -0
- package/dist/index-browser.js.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +750 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
- package/src/bots/berserker/01_Stormchaser.ts +457 -0
- package/src/bots/berserker/02_Stormcaller.ts +417 -0
- package/src/bots/berserker/03_Stormforger.ts +481 -0
- package/src/bots/caster/01_Flamecaller.ts +286 -0
- package/src/bots/caster/02_Pyromancer.ts +350 -0
- package/src/bots/caster/03_Infernalist.ts +492 -0
- package/src/bots/defensive/01_Turtle.ts +151 -0
- package/src/bots/defensive/02_Sentinel.ts +134 -0
- package/src/bots/defensive/03_Golem.ts +357 -0
- package/src/bots/duelist/01_Battlemage.ts +433 -0
- package/src/bots/duelist/02_Warmage.ts +438 -0
- package/src/bots/duelist/03_Archmage.ts +588 -0
- package/src/bots/homing/01_Bonemancer.ts +67 -0
- package/src/bots/homing/02_Lich.ts +356 -0
- package/src/bots/homing/03_Archlich.ts +220 -0
- package/src/bots/index.ts +30 -0
- package/src/bots/kiter/01_Spellspinner.ts +398 -0
- package/src/bots/kiter/02_Spellweaver.ts +378 -0
- package/src/bots/kiter/03_Spellbinder.ts +448 -0
- package/src/bots/melee/01_Shadowblade.ts +270 -0
- package/src/bots/melee/02_Nightblade.ts +437 -0
- package/src/bots/melee/03_Voidblade.ts +582 -0
- package/src/bots/registry.ts +207 -0
- package/src/bots/shared.ts +472 -0
- package/src/bots/sniper/01_Spellshot.ts +385 -0
- package/src/bots/sniper/02_Spelltracer.ts +441 -0
- package/src/bots/sniper/03_Spellseeker.ts +546 -0
- package/src/bots/standalone/Critter.ts +89 -0
- package/src/bots/standalone/Doombringer.ts +91 -0
- package/src/bots/standalone/Hogger.ts +228 -0
- package/src/bots/standalone/Rookie.ts +50 -0
- package/src/bots/standalone/TargetDummy.ts +21 -0
- package/src/bots/test/cheater.ts +405 -0
- package/src/bots/test/crasher.ts +81 -0
- package/src/engine/hooks-runtime.ts +394 -0
- package/src/engine/manual-match.ts +289 -0
- package/src/engine/missile-templates.ts +155 -0
- package/src/engine/optimizer.ts +220 -0
- package/src/engine/params-runtime.ts +189 -0
- package/src/engine/physics.ts +143 -0
- package/src/engine/sandbox-browser.ts +671 -0
- package/src/engine/sandbox-compile.ts +197 -0
- package/src/engine/sandbox-harness.ts +367 -0
- package/src/engine/sandbox.ts +332 -0
- package/src/engine/simulation.ts +828 -0
- package/src/engine/spells.ts +128 -0
- package/src/engine-version.ts +11 -0
- package/src/hooks/action-builders.ts +210 -0
- package/src/hooks/bot-wrapper.ts +84 -0
- package/src/hooks/index.ts +75 -0
- package/src/hooks/state-hooks.ts +354 -0
- package/src/hooks/threat-analysis.ts +365 -0
- package/src/hooks/types.ts +142 -0
- package/src/index-browser.ts +30 -0
- package/src/index.ts +24 -0
- package/src/rules.ts +254 -0
- package/src/stats.ts +262 -0
- package/src/testing.ts +207 -0
- package/src/trace.ts +430 -0
- package/src/types.ts +193 -0
- package/src/utils/angles.ts +47 -0
- package/src/utils/combat.ts +279 -0
- package/src/utils/distance.ts +21 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/movement.ts +108 -0
- package/src/utils/random.ts +65 -0
- package/src/utils/spatial.ts +63 -0
- package/src/utils/targeting.ts +45 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER — BROWSER SANDBOX
|
|
3
|
+
*
|
|
4
|
+
* Provides Web Worker-based sandboxing for bot code execution in the browser.
|
|
5
|
+
* Same compiled bundles as the isolated-vm sandbox (MatchSandbox), but runs
|
|
6
|
+
* in a Web Worker instead of a V8 isolate.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - Host: creates Worker from Blob URL, communicates via postMessage
|
|
10
|
+
* - Worker: loads compiled bundle (sets globalThis.__fight/__simulate),
|
|
11
|
+
* dispatches fight/simulate calls, posts results back
|
|
12
|
+
*
|
|
13
|
+
* Safety:
|
|
14
|
+
* - Timeout via setTimeout + worker.terminate() catches infinite loops
|
|
15
|
+
* - No memory limit (browser manages worker memory; worst case = tab crash)
|
|
16
|
+
* - Prototype freeze prevents cross-bot sabotage (same banner as isolated-vm)
|
|
17
|
+
* - No Node.js APIs available in Web Workers
|
|
18
|
+
*
|
|
19
|
+
* NOTE: This file has ZERO Node.js dependencies. It works in any JS environment.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type {GameState, MissileAIFunction, ProjectileState, WizardActions} from '../types.js';
|
|
23
|
+
import type {BotError, FightResult, MatchWinner, SimulateResult} from './simulation.js';
|
|
24
|
+
import type {StepResult} from './manual-match.js';
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// WORKER PROTOCOL
|
|
28
|
+
// ============================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimal Worker interface for dependency injection.
|
|
32
|
+
* Matches the browser Worker API subset we need.
|
|
33
|
+
* For tests, a Node.js worker_threads adapter can implement this.
|
|
34
|
+
*/
|
|
35
|
+
export interface WorkerLike
|
|
36
|
+
{
|
|
37
|
+
postMessage(data: unknown): void;
|
|
38
|
+
terminate(): void;
|
|
39
|
+
addEventListener(type: string, listener: (ev: unknown) => void): void;
|
|
40
|
+
removeEventListener(type: string, listener: (ev: unknown) => void): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Factory function that creates a WorkerLike from a JavaScript code string.
|
|
45
|
+
* Default: creates a browser Web Worker via Blob URL.
|
|
46
|
+
* Override in options.createWorker for testing with Node.js worker_threads.
|
|
47
|
+
*/
|
|
48
|
+
export type WorkerFactory = (code: string) => {worker: WorkerLike; cleanup?: () => void};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Options for browser sandbox creation.
|
|
52
|
+
*/
|
|
53
|
+
export interface BrowserSandboxOptions
|
|
54
|
+
{
|
|
55
|
+
/** Timeout in ms for fight/simulate calls (default: 30000). */
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
/** Custom worker factory for dependency injection (testing). */
|
|
58
|
+
createWorker?: WorkerFactory;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================
|
|
62
|
+
// WORKER BOOTSTRAP
|
|
63
|
+
// ============================================================
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* JavaScript bootstrap code appended to the compiled bundle.
|
|
67
|
+
* Runs inside the worker after the IIFE sets up the relevant globals
|
|
68
|
+
* (e.g., __fight/__simulate for fight bundles, __manualMatchInit/Step/etc
|
|
69
|
+
* for manual-play bundles).
|
|
70
|
+
*
|
|
71
|
+
* The dispatcher is generic: any method name `foo` resolves to
|
|
72
|
+
* `globalThis.__foo`. This allows new bundle types to expose new method
|
|
73
|
+
* names without changing the bootstrap.
|
|
74
|
+
*
|
|
75
|
+
* Environment-adaptive: works in both browser Web Workers and
|
|
76
|
+
* Node.js worker_threads (for testing).
|
|
77
|
+
*/
|
|
78
|
+
const WORKER_BOOTSTRAP = `
|
|
79
|
+
;(function() {
|
|
80
|
+
var _port = null;
|
|
81
|
+
try { _port = require('worker_threads').parentPort; } catch(e) {}
|
|
82
|
+
|
|
83
|
+
function send(data) {
|
|
84
|
+
if (_port) _port.postMessage(data);
|
|
85
|
+
else self.postMessage(data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function listen(fn) {
|
|
89
|
+
if (_port) _port.on('message', fn);
|
|
90
|
+
else self.addEventListener('message', function(e) { fn(e.data); });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
listen(function(data) {
|
|
94
|
+
if (data && data.method) {
|
|
95
|
+
try {
|
|
96
|
+
var fn = globalThis['__' + data.method];
|
|
97
|
+
if (typeof fn !== 'function') {
|
|
98
|
+
throw new Error('Unknown sandbox method: ' + data.method);
|
|
99
|
+
}
|
|
100
|
+
var result = fn(data.options);
|
|
101
|
+
send({id: data.id, result: result});
|
|
102
|
+
} catch(err) {
|
|
103
|
+
send({id: data.id, error: (err && err.message) || String(err)});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
send({type: 'ready'});
|
|
109
|
+
})();
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create the full worker script from a compiled match bundle.
|
|
114
|
+
* Appends the message-handling bootstrap to the bundle IIFE.
|
|
115
|
+
*/
|
|
116
|
+
export function createWorkerScript(bundle: string): string
|
|
117
|
+
{
|
|
118
|
+
return bundle + '\n' + WORKER_BOOTSTRAP;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================
|
|
122
|
+
// BROWSER MATCH SANDBOX
|
|
123
|
+
// ============================================================
|
|
124
|
+
|
|
125
|
+
interface PendingCall
|
|
126
|
+
{
|
|
127
|
+
resolve: (value: unknown) => void;
|
|
128
|
+
reject: (error: Error) => void;
|
|
129
|
+
timer: ReturnType<typeof setTimeout>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Browser-compatible sandboxed match runner using Web Workers.
|
|
134
|
+
*
|
|
135
|
+
* Same compiled bundles as MatchSandbox (isolated-vm), but runs in a
|
|
136
|
+
* Web Worker instead. All fight/simulate calls are async (postMessage-based).
|
|
137
|
+
*
|
|
138
|
+
* Usage:
|
|
139
|
+
* ```ts
|
|
140
|
+
* // Bundle is compiled server-side or at build time (Node.js only)
|
|
141
|
+
* const bundle = await MatchSandbox.compile(bot1, bot2);
|
|
142
|
+
*
|
|
143
|
+
* // Run in browser via Web Worker
|
|
144
|
+
* const sandbox = await BrowserMatchSandbox.fromBundle(bundle);
|
|
145
|
+
* const result = await sandbox.fight({ seed: 42 });
|
|
146
|
+
* sandbox.dispose();
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
export class BrowserMatchSandbox
|
|
150
|
+
{
|
|
151
|
+
private worker: WorkerLike;
|
|
152
|
+
private workerCleanup: (() => void) | undefined;
|
|
153
|
+
private timeout: number;
|
|
154
|
+
private disposed = false;
|
|
155
|
+
private nextId = 0;
|
|
156
|
+
private pending = new Map<number, PendingCall>();
|
|
157
|
+
private messageHandler: ((ev: unknown) => void) | null = null;
|
|
158
|
+
private errorHandler: ((ev: unknown) => void) | null = null;
|
|
159
|
+
|
|
160
|
+
private constructor(
|
|
161
|
+
worker: WorkerLike,
|
|
162
|
+
workerCleanup: (() => void) | undefined,
|
|
163
|
+
timeout: number,
|
|
164
|
+
)
|
|
165
|
+
{
|
|
166
|
+
this.worker = worker;
|
|
167
|
+
this.workerCleanup = workerCleanup;
|
|
168
|
+
this.timeout = timeout;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Create a browser sandbox from a pre-compiled bundle string.
|
|
173
|
+
* The bundle should be the output of MatchSandbox.compile() (or equivalent IIFE
|
|
174
|
+
* that sets globalThis.__fight and globalThis.__simulate).
|
|
175
|
+
*/
|
|
176
|
+
static async fromBundle(
|
|
177
|
+
bundle: string,
|
|
178
|
+
options?: BrowserSandboxOptions,
|
|
179
|
+
): Promise<BrowserMatchSandbox>
|
|
180
|
+
{
|
|
181
|
+
const timeout = options?.timeoutMs ?? 30000;
|
|
182
|
+
const code = createWorkerScript(bundle);
|
|
183
|
+
|
|
184
|
+
let worker: WorkerLike;
|
|
185
|
+
let cleanup: (() => void) | undefined;
|
|
186
|
+
|
|
187
|
+
if (options?.createWorker)
|
|
188
|
+
{
|
|
189
|
+
const created = options.createWorker(code);
|
|
190
|
+
worker = created.worker;
|
|
191
|
+
cleanup = created.cleanup;
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
{
|
|
195
|
+
// Browser default: Blob URL Worker
|
|
196
|
+
// Uses globalThis to avoid TypeScript error in Node.js builds (no DOM lib)
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- runtime browser check
|
|
198
|
+
const BrowserWorker = (globalThis as Record<string, unknown>).Worker as
|
|
199
|
+
{new (url: string | URL): WorkerLike} | undefined;
|
|
200
|
+
if (!BrowserWorker)
|
|
201
|
+
{
|
|
202
|
+
throw new Error(
|
|
203
|
+
'Web Worker API not available. Pass a createWorker factory for Node.js usage.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const blob = new Blob([code], {type: 'application/javascript'});
|
|
207
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
208
|
+
cleanup = (): void => URL.revokeObjectURL(blobUrl);
|
|
209
|
+
worker = new BrowserWorker(blobUrl);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const sandbox = new BrowserMatchSandbox(worker, cleanup, timeout);
|
|
213
|
+
|
|
214
|
+
try
|
|
215
|
+
{
|
|
216
|
+
await sandbox.waitForReady(timeout);
|
|
217
|
+
}
|
|
218
|
+
catch(error)
|
|
219
|
+
{
|
|
220
|
+
sandbox.dispose();
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return sandbox;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Wait for the worker to post {type: 'ready'}, then attach permanent handlers.
|
|
229
|
+
*/
|
|
230
|
+
private waitForReady(timeout: number): Promise<void>
|
|
231
|
+
{
|
|
232
|
+
return new Promise<void>((resolve, reject) =>
|
|
233
|
+
{
|
|
234
|
+
const timer = setTimeout(() =>
|
|
235
|
+
{
|
|
236
|
+
reject(new Error('Worker initialization timed out'));
|
|
237
|
+
}, timeout);
|
|
238
|
+
|
|
239
|
+
const readyListener = (ev: unknown): void =>
|
|
240
|
+
{
|
|
241
|
+
// Unwrap MessageEvent (browser) or raw data (Node.js adapter)
|
|
242
|
+
const data = this.unwrapEvent(ev);
|
|
243
|
+
if (data && typeof data === 'object' && 'type' in data && data.type === 'ready')
|
|
244
|
+
{
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
this.worker.removeEventListener('message', readyListener);
|
|
247
|
+
this.attachHandlers();
|
|
248
|
+
resolve();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
this.worker.addEventListener('message', readyListener);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Extract message data from a browser MessageEvent or raw Node.js data.
|
|
258
|
+
*/
|
|
259
|
+
private unwrapEvent(ev: unknown): Record<string, unknown> | null
|
|
260
|
+
{
|
|
261
|
+
if (ev == null) return null;
|
|
262
|
+
if (typeof ev !== 'object') return null;
|
|
263
|
+
|
|
264
|
+
// Browser MessageEvent has .data
|
|
265
|
+
const maybeEvent = ev as {data?: unknown};
|
|
266
|
+
const raw = maybeEvent.data !== undefined ? maybeEvent.data : ev;
|
|
267
|
+
|
|
268
|
+
if (raw != null && typeof raw === 'object')
|
|
269
|
+
{
|
|
270
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- runtime type-checked above
|
|
271
|
+
return raw as Record<string, unknown>;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Attach permanent message and error handlers for fight/simulate responses.
|
|
278
|
+
*/
|
|
279
|
+
private attachHandlers(): void
|
|
280
|
+
{
|
|
281
|
+
this.messageHandler = (ev: unknown): void =>
|
|
282
|
+
{
|
|
283
|
+
const data = this.unwrapEvent(ev);
|
|
284
|
+
if (!data || data.id == null) return;
|
|
285
|
+
|
|
286
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- id is always a number from our protocol
|
|
287
|
+
const id = data.id as number;
|
|
288
|
+
const handler = this.pending.get(id);
|
|
289
|
+
if (!handler) return;
|
|
290
|
+
|
|
291
|
+
clearTimeout(handler.timer);
|
|
292
|
+
this.pending.delete(id);
|
|
293
|
+
|
|
294
|
+
if (data.error)
|
|
295
|
+
{
|
|
296
|
+
handler.reject(new Error(String(data.error)));
|
|
297
|
+
}
|
|
298
|
+
else
|
|
299
|
+
{
|
|
300
|
+
handler.resolve(data.result);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
this.errorHandler = (ev: unknown): void =>
|
|
305
|
+
{
|
|
306
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ErrorEvent shape from Worker
|
|
307
|
+
const message = (ev as {message?: string}).message ?? 'Worker error';
|
|
308
|
+
// Reject all pending calls
|
|
309
|
+
for (const [, handler] of this.pending)
|
|
310
|
+
{
|
|
311
|
+
clearTimeout(handler.timer);
|
|
312
|
+
handler.reject(new Error(message));
|
|
313
|
+
}
|
|
314
|
+
this.pending.clear();
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
this.worker.addEventListener('message', this.messageHandler);
|
|
318
|
+
this.worker.addEventListener('error', this.errorHandler);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ============================================================
|
|
322
|
+
// PUBLIC API
|
|
323
|
+
// ============================================================
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Run a full fight (10 matches: 5 spawn distances x 2 sides).
|
|
327
|
+
* Returns a Promise because Worker communication is async.
|
|
328
|
+
*/
|
|
329
|
+
async fight(options?: {seed?: number; maxTicks?: number}): Promise<FightResult>
|
|
330
|
+
{
|
|
331
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- worker returns FightResult shape
|
|
332
|
+
return this.call('fight', options ?? {}) as Promise<FightResult>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Run a single simulation. Returns a Promise.
|
|
337
|
+
*
|
|
338
|
+
* @param options.params1 - useParam overrides for bot 1 (wizard-1)
|
|
339
|
+
* @param options.params2 - useParam overrides for bot 2 (wizard-2)
|
|
340
|
+
*/
|
|
341
|
+
async simulate(options?: {
|
|
342
|
+
seed?: number;
|
|
343
|
+
maxTicks?: number;
|
|
344
|
+
spawnDistance?: number;
|
|
345
|
+
skipHistory?: boolean;
|
|
346
|
+
params1?: Record<string, number>;
|
|
347
|
+
params2?: Record<string, number>;
|
|
348
|
+
}): Promise<SimulateResult>
|
|
349
|
+
{
|
|
350
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- worker returns SimulateResult shape
|
|
351
|
+
return this.call('simulate', options ?? {}) as Promise<SimulateResult>;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Dispose the worker and free all resources.
|
|
356
|
+
* The sandbox cannot be used after disposal.
|
|
357
|
+
*/
|
|
358
|
+
dispose(): void
|
|
359
|
+
{
|
|
360
|
+
if (!this.disposed)
|
|
361
|
+
{
|
|
362
|
+
this.disposed = true;
|
|
363
|
+
|
|
364
|
+
// Reject all pending calls
|
|
365
|
+
for (const [, handler] of this.pending)
|
|
366
|
+
{
|
|
367
|
+
clearTimeout(handler.timer);
|
|
368
|
+
handler.reject(new Error('BrowserMatchSandbox has been disposed'));
|
|
369
|
+
}
|
|
370
|
+
this.pending.clear();
|
|
371
|
+
|
|
372
|
+
// Remove event listeners
|
|
373
|
+
if (this.messageHandler)
|
|
374
|
+
{
|
|
375
|
+
this.worker.removeEventListener('message', this.messageHandler);
|
|
376
|
+
}
|
|
377
|
+
if (this.errorHandler)
|
|
378
|
+
{
|
|
379
|
+
this.worker.removeEventListener('error', this.errorHandler);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Terminate worker
|
|
383
|
+
this.worker.terminate();
|
|
384
|
+
|
|
385
|
+
// Clean up Blob URL (or other resources)
|
|
386
|
+
this.workerCleanup?.();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Whether this sandbox has been disposed.
|
|
392
|
+
*/
|
|
393
|
+
get isDisposed(): boolean
|
|
394
|
+
{
|
|
395
|
+
return this.disposed;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ============================================================
|
|
399
|
+
// INTERNAL
|
|
400
|
+
// ============================================================
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Send a generic method call to the worker. Used by sibling sandboxes
|
|
404
|
+
* (e.g. BrowserManualMatchSandbox) that need to dispatch to method
|
|
405
|
+
* names other than fight/simulate. The worker bootstrap looks up
|
|
406
|
+
* `globalThis['__' + method]` and calls it with `options`.
|
|
407
|
+
*/
|
|
408
|
+
callRaw(method: string, options: unknown): Promise<unknown>
|
|
409
|
+
{
|
|
410
|
+
return this.call(method, options);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Send a method call to the worker and wait for the response.
|
|
415
|
+
* Times out and terminates the worker if no response within timeout.
|
|
416
|
+
*/
|
|
417
|
+
private call(method: string, options: unknown): Promise<unknown>
|
|
418
|
+
{
|
|
419
|
+
this.ensureNotDisposed();
|
|
420
|
+
const id = this.nextId++;
|
|
421
|
+
|
|
422
|
+
return new Promise((resolve, reject) =>
|
|
423
|
+
{
|
|
424
|
+
const timer = setTimeout(() =>
|
|
425
|
+
{
|
|
426
|
+
this.pending.delete(id);
|
|
427
|
+
// Worker is stuck — terminate and clean up all resources
|
|
428
|
+
this.worker.terminate();
|
|
429
|
+
this.workerCleanup?.();
|
|
430
|
+
this.disposed = true;
|
|
431
|
+
reject(new Error(`BrowserMatchSandbox timed out after ${this.timeout}ms`));
|
|
432
|
+
}, this.timeout);
|
|
433
|
+
|
|
434
|
+
this.pending.set(id, {resolve, reject, timer});
|
|
435
|
+
this.worker.postMessage({id, method, options});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private ensureNotDisposed(): void
|
|
440
|
+
{
|
|
441
|
+
if (this.disposed)
|
|
442
|
+
{
|
|
443
|
+
throw new Error('BrowserMatchSandbox has been disposed');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ============================================================
|
|
449
|
+
// CONVENIENCE FUNCTIONS
|
|
450
|
+
// ============================================================
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* One-shot browser-sandboxed fight. Creates worker, runs fight, disposes.
|
|
454
|
+
*/
|
|
455
|
+
export async function browserSandboxFight(
|
|
456
|
+
bundle: string,
|
|
457
|
+
options?: {seed?: number; maxTicks?: number} & BrowserSandboxOptions,
|
|
458
|
+
): Promise<FightResult>
|
|
459
|
+
{
|
|
460
|
+
const sandbox = await BrowserMatchSandbox.fromBundle(bundle, options);
|
|
461
|
+
try
|
|
462
|
+
{
|
|
463
|
+
return await sandbox.fight({seed: options?.seed, maxTicks: options?.maxTicks});
|
|
464
|
+
}
|
|
465
|
+
finally
|
|
466
|
+
{
|
|
467
|
+
sandbox.dispose();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* One-shot browser-sandboxed simulate. Creates worker, runs simulate, disposes.
|
|
473
|
+
*/
|
|
474
|
+
export async function browserSandboxSimulate(
|
|
475
|
+
bundle: string,
|
|
476
|
+
options?: {
|
|
477
|
+
seed?: number;
|
|
478
|
+
maxTicks?: number;
|
|
479
|
+
spawnDistance?: number;
|
|
480
|
+
skipHistory?: boolean;
|
|
481
|
+
params1?: Record<string, number>;
|
|
482
|
+
params2?: Record<string, number>;
|
|
483
|
+
} & BrowserSandboxOptions,
|
|
484
|
+
): Promise<SimulateResult>
|
|
485
|
+
{
|
|
486
|
+
const sandbox = await BrowserMatchSandbox.fromBundle(bundle, options);
|
|
487
|
+
try
|
|
488
|
+
{
|
|
489
|
+
return await sandbox.simulate({
|
|
490
|
+
seed: options?.seed,
|
|
491
|
+
maxTicks: options?.maxTicks,
|
|
492
|
+
spawnDistance: options?.spawnDistance,
|
|
493
|
+
skipHistory: options?.skipHistory,
|
|
494
|
+
params1: options?.params1,
|
|
495
|
+
params2: options?.params2,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
finally
|
|
499
|
+
{
|
|
500
|
+
sandbox.dispose();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ============================================================
|
|
505
|
+
// BROWSER MANUAL-MATCH SANDBOX
|
|
506
|
+
// ============================================================
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Options accepted by `__manualMatchInit` (worker-side). Mirrors
|
|
510
|
+
* `ManualMatchOptions` from manual-match.ts, but without the constructor's
|
|
511
|
+
* AI parameters since the player AI is a worker-local stub.
|
|
512
|
+
*/
|
|
513
|
+
export interface ManualMatchInitOptions
|
|
514
|
+
{
|
|
515
|
+
seed?: number;
|
|
516
|
+
spawnDistance?: number;
|
|
517
|
+
maxTicks?: number;
|
|
518
|
+
initialHumanActions?: WizardActions;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export interface ManualMatchStepRequest
|
|
522
|
+
{
|
|
523
|
+
humanActions?: WizardActions;
|
|
524
|
+
humanMissileTargets?: Record<string, {x: number; y: number}>;
|
|
525
|
+
count?: number;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Long-lived Web Worker sandbox holding a single ManualMatch instance.
|
|
530
|
+
*
|
|
531
|
+
* Unlike BrowserMatchSandbox (which runs one batched fight/simulate per
|
|
532
|
+
* worker), BrowserManualMatchSandbox keeps the worker alive across many
|
|
533
|
+
* step calls so the engine state and hook state persist between ticks.
|
|
534
|
+
* This is what manual play mode uses: one worker per session, disposed
|
|
535
|
+
* when the user leaves the page or starts a new match.
|
|
536
|
+
*
|
|
537
|
+
* Usage:
|
|
538
|
+
* ```ts
|
|
539
|
+
* const sandbox = await BrowserManualMatchSandbox.fromBundle(opponentBundle);
|
|
540
|
+
* await sandbox.init({seed: 42});
|
|
541
|
+
* for (let i = 0; i < 100; i++) {
|
|
542
|
+
* await sandbox.step({humanActions: {move: {x: 100, y: 0}}, count: 1});
|
|
543
|
+
* }
|
|
544
|
+
* await sandbox.dispose();
|
|
545
|
+
* ```
|
|
546
|
+
*/
|
|
547
|
+
export class BrowserManualMatchSandbox
|
|
548
|
+
{
|
|
549
|
+
private readonly inner: BrowserMatchSandbox;
|
|
550
|
+
|
|
551
|
+
private constructor(inner: BrowserMatchSandbox)
|
|
552
|
+
{
|
|
553
|
+
this.inner = inner;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Create a manual-match sandbox from a pre-compiled bundle. The bundle
|
|
558
|
+
* must be the output of `compileManualMatchBundle()` — the regular
|
|
559
|
+
* `compileMatchBundle()` output won't work since it doesn't expose the
|
|
560
|
+
* `__manualMatch*` globals.
|
|
561
|
+
*/
|
|
562
|
+
static async fromBundle(
|
|
563
|
+
bundle: string,
|
|
564
|
+
options?: BrowserSandboxOptions,
|
|
565
|
+
): Promise<BrowserManualMatchSandbox>
|
|
566
|
+
{
|
|
567
|
+
const inner = await BrowserMatchSandbox.fromBundle(bundle, options);
|
|
568
|
+
return new BrowserManualMatchSandbox(inner);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Initialize the worker-side ManualMatch instance.
|
|
573
|
+
* Returns the initial GameState (tick 0).
|
|
574
|
+
*/
|
|
575
|
+
async init(options?: ManualMatchInitOptions): Promise<GameState>
|
|
576
|
+
{
|
|
577
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- worker returns GameState shape
|
|
578
|
+
return this.inner.callRaw('manualMatchInit', options ?? {}) as Promise<GameState>;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Advance the match by `request.count` ticks (default 1), updating
|
|
583
|
+
* the player's human actions and any hijacked missile targets first.
|
|
584
|
+
*/
|
|
585
|
+
async step(request: ManualMatchStepRequest = {}): Promise<StepResult>
|
|
586
|
+
{
|
|
587
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- worker returns StepResult shape
|
|
588
|
+
return this.inner.callRaw('manualMatchStep', request) as Promise<StepResult>;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Replace a missile's AI with a worker-local hijack stub that reads
|
|
593
|
+
* from `latestHumanMissileTargets[projectileId]`. Subsequent step()
|
|
594
|
+
* calls with `humanMissileTargets` populated for this id steer the
|
|
595
|
+
* missile.
|
|
596
|
+
*/
|
|
597
|
+
async hijackMissile(projectileId: string): Promise<void>
|
|
598
|
+
{
|
|
599
|
+
await this.inner.callRaw('manualMatchHijack', {projectileId});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Restore a hijacked missile's original AI.
|
|
604
|
+
*/
|
|
605
|
+
async releaseMissile(projectileId: string): Promise<void>
|
|
606
|
+
{
|
|
607
|
+
await this.inner.callRaw('manualMatchRelease', {projectileId});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Toggle invincibility for a wizard.
|
|
612
|
+
*/
|
|
613
|
+
async setInvincible(wizardIndex: 0 | 1, on: boolean): Promise<void>
|
|
614
|
+
{
|
|
615
|
+
await this.inner.callRaw('manualMatchSetInvincible', {wizardIndex, on});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Get the current game state without advancing.
|
|
620
|
+
*/
|
|
621
|
+
async getState(): Promise<GameState>
|
|
622
|
+
{
|
|
623
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- worker returns GameState shape
|
|
624
|
+
return this.inner.callRaw('manualMatchGetState', {}) as Promise<GameState>;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Get the full SimulateResult-compatible result object (history + winner).
|
|
629
|
+
*/
|
|
630
|
+
async getResult(): Promise<SimulateResult>
|
|
631
|
+
{
|
|
632
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- worker returns SimulateResult shape
|
|
633
|
+
return this.inner.callRaw('manualMatchGetResult', {}) as Promise<SimulateResult>;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Dispose the worker-side ManualMatch instance. Does NOT terminate the
|
|
638
|
+
* worker — call dispose() for that.
|
|
639
|
+
*/
|
|
640
|
+
async resetMatch(): Promise<void>
|
|
641
|
+
{
|
|
642
|
+
await this.inner.callRaw('manualMatchDispose', {});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Terminate the worker and free all resources.
|
|
647
|
+
*/
|
|
648
|
+
dispose(): void
|
|
649
|
+
{
|
|
650
|
+
this.inner.dispose();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
get isDisposed(): boolean
|
|
654
|
+
{
|
|
655
|
+
return this.inner.isDisposed;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Re-import-friendly types (re-exported here so consumers don't need to
|
|
661
|
+
* cherry-pick from internal modules).
|
|
662
|
+
*/
|
|
663
|
+
export type {
|
|
664
|
+
BotError,
|
|
665
|
+
GameState,
|
|
666
|
+
MatchWinner,
|
|
667
|
+
MissileAIFunction,
|
|
668
|
+
ProjectileState,
|
|
669
|
+
StepResult,
|
|
670
|
+
WizardActions,
|
|
671
|
+
};
|