@vibemancer/core 0.1.4 → 0.1.6
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 +2 -2
- package/dist/chunk-G5T65F3W.js +10617 -0
- package/dist/chunk-G5T65F3W.js.map +1 -0
- package/dist/index-browser.d.ts +1328 -1051
- package/dist/index-browser.js +59 -17
- package/dist/index.d.ts +53 -3
- package/dist/index.js +204 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -15
- package/src/banned-bot-names.ts +53 -0
- package/src/bots/Hero.ts +867 -0
- package/src/bots/berserker/01_Stormchaser.ts +162 -120
- package/src/bots/berserker/02_Stormcaller.ts +160 -116
- package/src/bots/berserker/03_Stormforger.ts +162 -129
- package/src/bots/caster/01_Flamecaller.ts +126 -84
- package/src/bots/caster/02_Pyromancer.ts +148 -101
- package/src/bots/caster/03_Infernalist.ts +180 -160
- package/src/bots/defensive/01_Turtle.ts +48 -44
- package/src/bots/defensive/02_Sentinel.ts +57 -53
- package/src/bots/defensive/03_Golem.ts +85 -97
- package/src/bots/duelist/01_Battlemage.ts +157 -122
- package/src/bots/duelist/02_Warmage.ts +166 -121
- package/src/bots/duelist/03_Archmage.ts +198 -178
- package/src/bots/homing/01_Bonemancer.ts +20 -32
- package/src/bots/homing/02_Lich.ts +145 -93
- package/src/bots/homing/03_Archlich.ts +129 -60
- package/src/bots/kiter/01_Spellspinner.ts +145 -107
- package/src/bots/kiter/02_Spellweaver.ts +153 -105
- package/src/bots/kiter/03_Spellbinder.ts +168 -123
- package/src/bots/melee/01_Shadowblade.ts +131 -75
- package/src/bots/melee/02_Nightblade.ts +174 -130
- package/src/bots/melee/03_Voidblade.ts +266 -168
- package/src/bots/registry.ts +44 -37
- package/src/bots/shared.ts +185 -25
- package/src/bots/sniper/01_Spellshot.ts +151 -98
- package/src/bots/sniper/02_Spelltracer.ts +173 -128
- package/src/bots/sniper/03_Spellseeker.ts +177 -152
- package/src/bots/standalone/Critter.ts +46 -52
- package/src/bots/standalone/Doombringer.ts +36 -40
- package/src/bots/standalone/Hogger.ts +120 -89
- package/src/bots/standalone/Rookie.ts +21 -23
- package/src/bots/standalone/TargetDummy.ts +5 -6
- package/src/bots/test/cheater.ts +91 -85
- package/src/bots/test/crasher.ts +26 -28
- package/src/engine/bundle-fight.ts +242 -0
- package/src/engine/hooks-runtime.ts +58 -18
- package/src/engine/manual-match.ts +33 -25
- package/src/engine/missile-templates.ts +25 -43
- package/src/engine/optimizer.ts +7 -7
- package/src/engine/params-runtime.ts +3 -3
- package/src/engine/physics.ts +33 -23
- package/src/engine/sandbox-browser.ts +8 -7
- package/src/engine/sandbox-compile.ts +1 -1
- package/src/engine/sandbox-harness.ts +299 -367
- package/src/engine/sandbox.ts +3 -3
- package/src/engine/simulation.ts +291 -76
- package/src/engine/spells.ts +9 -12
- package/src/engine-version.ts +1 -1
- package/src/hooks/action-builders.ts +76 -21
- package/src/hooks/index.ts +17 -9
- package/src/hooks/state-hooks.ts +61 -25
- package/src/hooks/threat-analysis.ts +44 -20
- package/src/hooks/types.ts +62 -5
- package/src/index-browser.ts +1 -0
- package/src/index.ts +3 -0
- package/src/rules.ts +209 -63
- package/src/stats.ts +2 -2
- package/src/testing.ts +6 -30
- package/src/trace.ts +63 -3
- package/src/types.ts +127 -14
- package/src/utils/angles.ts +1 -0
- package/src/utils/combat.ts +98 -6
- package/src/utils/spatial.ts +3 -3
- package/dist/chunk-74FFJCFF.js +0 -9140
- package/dist/chunk-74FFJCFF.js.map +0 -1
- package/src/hooks/bot-wrapper.ts +0 -84
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a fight between two PRECOMPILED bot bundles — the canonical uploaded-wizard
|
|
3
|
+
* format where each IIFE sets `globalThis.__injectedBot1` (see compileSingleBotBundle).
|
|
4
|
+
*
|
|
5
|
+
* Shared by the Cloud Functions matchmaker and the devkit CLI so that a
|
|
6
|
+
* `handle/botname` fight runs through the exact same engine as the live ladder.
|
|
7
|
+
*
|
|
8
|
+
* Both bundles run inside an isolated-vm isolate alongside a "match template" —
|
|
9
|
+
* the engine + a `__fight` harness that reads the injected bots. The template is
|
|
10
|
+
* built lazily from the engine source on first use and cached; callers that
|
|
11
|
+
* already have one (the Cloud Functions committed MATCH_TEMPLATE) pass it in to
|
|
12
|
+
* skip the esbuild step.
|
|
13
|
+
*
|
|
14
|
+
* Execution order inside the isolate: bot1 bundle → bot2 bundle → match template.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import ivm from 'isolated-vm';
|
|
19
|
+
import {build} from 'esbuild';
|
|
20
|
+
import {getEngineDir} from './sandbox-compile.js';
|
|
21
|
+
import type {FightResult, SimulateResult} from './simulation.js';
|
|
22
|
+
|
|
23
|
+
/** Memory limit per sandbox isolate (MB). */
|
|
24
|
+
const MEMORY_LIMIT_MB = 256;
|
|
25
|
+
/** Timeout per fight (ms). */
|
|
26
|
+
const FIGHT_TIMEOUT_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
const FREEZE_BANNER = `
|
|
29
|
+
Object.freeze(Object.prototype);
|
|
30
|
+
Object.freeze(Array.prototype);
|
|
31
|
+
Object.freeze(Function.prototype);
|
|
32
|
+
Object.freeze(String.prototype);
|
|
33
|
+
Object.freeze(Number.prototype);
|
|
34
|
+
Object.freeze(Boolean.prototype);
|
|
35
|
+
Object.freeze(RegExp.prototype);
|
|
36
|
+
Object.freeze(Date.prototype);
|
|
37
|
+
Object.freeze(Error.prototype);
|
|
38
|
+
Object.freeze(Map.prototype);
|
|
39
|
+
Object.freeze(Set.prototype);
|
|
40
|
+
Object.freeze(Math);
|
|
41
|
+
Object.freeze(JSON);
|
|
42
|
+
(function() {
|
|
43
|
+
var g = typeof globalThis !== 'undefined' ? globalThis : typeof self !== 'undefined' ? self : {};
|
|
44
|
+
var blocked = [
|
|
45
|
+
'fetch', 'XMLHttpRequest', 'WebSocket', 'EventSource',
|
|
46
|
+
'importScripts', 'Worker', 'SharedWorker',
|
|
47
|
+
'Request', 'Response', 'Headers',
|
|
48
|
+
'navigator', 'BroadcastChannel',
|
|
49
|
+
'indexedDB', 'caches'
|
|
50
|
+
];
|
|
51
|
+
for (var i = 0; i < blocked.length; i++) {
|
|
52
|
+
try { Object.defineProperty(g, blocked[i], {value: undefined, writable: false, configurable: false}); }
|
|
53
|
+
catch(e) {}
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
let cachedTemplate: Promise<string> | null = null;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build (and cache) the match template: the engine + `__fight`/`__simulate`
|
|
62
|
+
* harness bundled into a single IIFE string, ready to run after two bot bundles
|
|
63
|
+
* have set `globalThis.__injectedBot1`/`__injectedBot2`.
|
|
64
|
+
*/
|
|
65
|
+
export function buildMatchTemplate(): Promise<string>
|
|
66
|
+
{
|
|
67
|
+
if (!cachedTemplate)
|
|
68
|
+
{
|
|
69
|
+
cachedTemplate = (async(): Promise<string> =>
|
|
70
|
+
{
|
|
71
|
+
const engineDir = getEngineDir();
|
|
72
|
+
const coreSrc = path.dirname(engineDir).replace(/\\/g, '/');
|
|
73
|
+
|
|
74
|
+
const entryPoint = `
|
|
75
|
+
import {fight, simulate} from '${coreSrc}/engine/simulation.ts';
|
|
76
|
+
import {wrapWithParams} from '${coreSrc}/engine/params-runtime.ts';
|
|
77
|
+
|
|
78
|
+
const __Bot1 = (globalThis as any).__injectedBot1;
|
|
79
|
+
const __Bot2 = (globalThis as any).__injectedBot2;
|
|
80
|
+
|
|
81
|
+
if (!__Bot1) throw new Error('Bot 1 not injected (set globalThis.__injectedBot1)');
|
|
82
|
+
if (!__Bot2) throw new Error('Bot 2 not injected (set globalThis.__injectedBot2)');
|
|
83
|
+
|
|
84
|
+
(globalThis as any).__fight = function __fight(options: any) {
|
|
85
|
+
return fight(__Bot1, __Bot2, options);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
(globalThis as any).__simulate = function __simulate(options: any) {
|
|
89
|
+
const {params1, params2, ...simOptions} = options;
|
|
90
|
+
const bot1 = params1 ? wrapWithParams(__Bot1, params1) : __Bot1;
|
|
91
|
+
const bot2 = params2 ? wrapWithParams(__Bot2, params2) : __Bot2;
|
|
92
|
+
return simulate(bot1, bot2, simOptions);
|
|
93
|
+
};
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const result = await build({
|
|
97
|
+
stdin: {contents: entryPoint, resolveDir: engineDir, loader: 'ts'},
|
|
98
|
+
bundle: true,
|
|
99
|
+
write: false,
|
|
100
|
+
format: 'iife',
|
|
101
|
+
platform: 'neutral',
|
|
102
|
+
target: 'es2022',
|
|
103
|
+
banner: {js: FREEZE_BANNER},
|
|
104
|
+
logLevel: 'error',
|
|
105
|
+
external: [
|
|
106
|
+
'isolated-vm', 'esbuild',
|
|
107
|
+
'node:path', 'node:fs', 'node:url',
|
|
108
|
+
'node:worker_threads', 'node:crypto', 'node:os', 'node:child_process',
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!result.outputFiles?.[0]) throw new Error('esbuild produced no match-template output');
|
|
113
|
+
return result.outputFiles[0].text;
|
|
114
|
+
})();
|
|
115
|
+
}
|
|
116
|
+
return cachedTemplate;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface RunBundleFightOptions
|
|
120
|
+
{
|
|
121
|
+
seed: number;
|
|
122
|
+
/** Prebuilt match template (e.g. the committed Cloud Functions one). Built lazily if omitted. */
|
|
123
|
+
matchTemplate?: string;
|
|
124
|
+
/** Skip per-tick history in the result (smaller copy out of the isolate). Default true. */
|
|
125
|
+
skipHistory?: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Run a fight between two compiled `__injectedBot1`-format bundles.
|
|
130
|
+
*
|
|
131
|
+
* bundle1 is saved and its global cleared before bundle2 runs, so bot code can
|
|
132
|
+
* never read the opponent's function off globalThis. Both globals are deleted
|
|
133
|
+
* after wiring so runtime bot code can't reach them either.
|
|
134
|
+
*/
|
|
135
|
+
export async function runBundleFight(
|
|
136
|
+
bundle1: string,
|
|
137
|
+
bundle2: string,
|
|
138
|
+
options: RunBundleFightOptions,
|
|
139
|
+
): Promise<FightResult>
|
|
140
|
+
{
|
|
141
|
+
const template = options.matchTemplate ?? await buildMatchTemplate();
|
|
142
|
+
const skipHistory = options.skipHistory ?? true;
|
|
143
|
+
|
|
144
|
+
const code = bundle1
|
|
145
|
+
+ '\nvar __savedBot1 = globalThis.__injectedBot1;\n'
|
|
146
|
+
+ 'globalThis.__injectedBot1 = undefined;\n'
|
|
147
|
+
+ bundle2
|
|
148
|
+
+ '\nglobalThis.__injectedBot2 = globalThis.__injectedBot1;\n'
|
|
149
|
+
+ '\nglobalThis.__injectedBot1 = __savedBot1;\n'
|
|
150
|
+
+ '__savedBot1 = undefined;\n'
|
|
151
|
+
+ template
|
|
152
|
+
+ '\ndelete globalThis.__injectedBot1;\ndelete globalThis.__injectedBot2;\n';
|
|
153
|
+
|
|
154
|
+
const isolate = new ivm.Isolate({memoryLimit: MEMORY_LIMIT_MB});
|
|
155
|
+
|
|
156
|
+
try
|
|
157
|
+
{
|
|
158
|
+
const context = await isolate.createContext();
|
|
159
|
+
const jail = context.global;
|
|
160
|
+
await jail.set('global', jail.derefInto());
|
|
161
|
+
|
|
162
|
+
const script = await isolate.compileScript(code);
|
|
163
|
+
await script.run(context, {timeout: FIGHT_TIMEOUT_MS});
|
|
164
|
+
|
|
165
|
+
const fightFn = await jail.get('__fight');
|
|
166
|
+
const result = await fightFn.apply(
|
|
167
|
+
undefined,
|
|
168
|
+
[new ivm.ExternalCopy({seed: options.seed, skipHistory}).copyInto()],
|
|
169
|
+
{timeout: FIGHT_TIMEOUT_MS, result: {copy: true}},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- isolated-vm returns unknown
|
|
173
|
+
return result as FightResult;
|
|
174
|
+
}
|
|
175
|
+
finally
|
|
176
|
+
{
|
|
177
|
+
if (!isolate.isDisposed) isolate.dispose();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface RunBundleSimulateOptions
|
|
182
|
+
{
|
|
183
|
+
seed?: number;
|
|
184
|
+
spawnDistance?: number;
|
|
185
|
+
maxTicks?: number;
|
|
186
|
+
matchTemplate?: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Run a SINGLE match between two compiled bundles, returning the full per-tick
|
|
191
|
+
* history (for tracing/debugging). Same isolate wiring as runBundleFight, but
|
|
192
|
+
* calls the template's `__simulate` so the caller gets a SimulateResult.
|
|
193
|
+
*/
|
|
194
|
+
export async function runBundleSimulate(
|
|
195
|
+
bundle1: string,
|
|
196
|
+
bundle2: string,
|
|
197
|
+
options: RunBundleSimulateOptions = {},
|
|
198
|
+
): Promise<SimulateResult>
|
|
199
|
+
{
|
|
200
|
+
const template = options.matchTemplate ?? await buildMatchTemplate();
|
|
201
|
+
|
|
202
|
+
const code = bundle1
|
|
203
|
+
+ '\nvar __savedBot1 = globalThis.__injectedBot1;\n'
|
|
204
|
+
+ 'globalThis.__injectedBot1 = undefined;\n'
|
|
205
|
+
+ bundle2
|
|
206
|
+
+ '\nglobalThis.__injectedBot2 = globalThis.__injectedBot1;\n'
|
|
207
|
+
+ '\nglobalThis.__injectedBot1 = __savedBot1;\n'
|
|
208
|
+
+ '__savedBot1 = undefined;\n'
|
|
209
|
+
+ template
|
|
210
|
+
+ '\ndelete globalThis.__injectedBot1;\ndelete globalThis.__injectedBot2;\n';
|
|
211
|
+
|
|
212
|
+
const isolate = new ivm.Isolate({memoryLimit: MEMORY_LIMIT_MB});
|
|
213
|
+
|
|
214
|
+
try
|
|
215
|
+
{
|
|
216
|
+
const context = await isolate.createContext();
|
|
217
|
+
const jail = context.global;
|
|
218
|
+
await jail.set('global', jail.derefInto());
|
|
219
|
+
|
|
220
|
+
const script = await isolate.compileScript(code);
|
|
221
|
+
await script.run(context, {timeout: FIGHT_TIMEOUT_MS});
|
|
222
|
+
|
|
223
|
+
const simulateFn = await jail.get('__simulate');
|
|
224
|
+
const simOptions = {
|
|
225
|
+
seed: options.seed ?? 1,
|
|
226
|
+
spawnDistance: options.spawnDistance,
|
|
227
|
+
maxTicks: options.maxTicks,
|
|
228
|
+
};
|
|
229
|
+
const result = await simulateFn.apply(
|
|
230
|
+
undefined,
|
|
231
|
+
[new ivm.ExternalCopy(simOptions).copyInto()],
|
|
232
|
+
{timeout: FIGHT_TIMEOUT_MS, result: {copy: true}},
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- isolated-vm returns unknown
|
|
236
|
+
return result as SimulateResult;
|
|
237
|
+
}
|
|
238
|
+
finally
|
|
239
|
+
{
|
|
240
|
+
if (!isolate.isDisposed) isolate.dispose();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Violating these rules throws an error (detected via hook index validation).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type {
|
|
15
|
+
import type {WizardContext, MissileContext} from '../hooks/types.js';
|
|
16
16
|
|
|
17
17
|
export interface HookState
|
|
18
18
|
{
|
|
@@ -63,14 +63,23 @@ function _setCurrentHookIndex(idx: number): void
|
|
|
63
63
|
{
|
|
64
64
|
_g.__vibemancer_currentHookIndex = idx;
|
|
65
65
|
}
|
|
66
|
-
function
|
|
66
|
+
function _getCurrentWizardContext(): WizardContext | null
|
|
67
67
|
{
|
|
68
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own
|
|
69
|
-
return (_g.
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_currentWizardContext
|
|
69
|
+
return (_g.__vibemancer_currentWizardContext as WizardContext | null) ?? null;
|
|
70
70
|
}
|
|
71
|
-
function
|
|
71
|
+
function _setCurrentWizardContext(ctx: WizardContext | null): void
|
|
72
72
|
{
|
|
73
|
-
_g.
|
|
73
|
+
_g.__vibemancer_currentWizardContext = ctx;
|
|
74
|
+
}
|
|
75
|
+
function _getCurrentMissileContext(): MissileContext | null
|
|
76
|
+
{
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_currentMissileContext
|
|
78
|
+
return (_g.__vibemancer_currentMissileContext as MissileContext | null) ?? null;
|
|
79
|
+
}
|
|
80
|
+
function _setCurrentMissileContext(ctx: MissileContext | null): void
|
|
81
|
+
{
|
|
82
|
+
_g.__vibemancer_currentMissileContext = ctx;
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
/**
|
|
@@ -206,10 +215,10 @@ export function runWithHooks<T>(entityId: string, fn: () => T): T
|
|
|
206
215
|
* Run a bot function with full context (game state + persistence hooks).
|
|
207
216
|
* Use this when you need to set BOTH the entity ID and the game context.
|
|
208
217
|
*/
|
|
209
|
-
export function
|
|
218
|
+
export function runWizardWithContext<T>(entityId: string, context: WizardContext, fn: () => T): T
|
|
210
219
|
{
|
|
211
|
-
const previousContext =
|
|
212
|
-
|
|
220
|
+
const previousContext = _getCurrentWizardContext();
|
|
221
|
+
_setCurrentWizardContext(context);
|
|
213
222
|
|
|
214
223
|
try
|
|
215
224
|
{
|
|
@@ -217,18 +226,18 @@ export function runBotWithContext<T>(entityId: string, context: BotContext, fn:
|
|
|
217
226
|
}
|
|
218
227
|
finally
|
|
219
228
|
{
|
|
220
|
-
|
|
229
|
+
_setCurrentWizardContext(previousContext);
|
|
221
230
|
}
|
|
222
231
|
}
|
|
223
232
|
|
|
224
233
|
/**
|
|
225
234
|
* Run a function with game state context only, preserving the current entity ID.
|
|
226
|
-
*
|
|
235
|
+
* Used by the simulation to set up WizardContext before calling hooks-style bots.
|
|
227
236
|
*/
|
|
228
|
-
export function
|
|
237
|
+
export function withWizardContext<T>(context: WizardContext, fn: () => T): T
|
|
229
238
|
{
|
|
230
|
-
const previousContext =
|
|
231
|
-
|
|
239
|
+
const previousContext = _getCurrentWizardContext();
|
|
240
|
+
_setCurrentWizardContext(context);
|
|
232
241
|
|
|
233
242
|
try
|
|
234
243
|
{
|
|
@@ -236,20 +245,51 @@ export function withBotContext<T>(context: BotContext, fn: () => T): T
|
|
|
236
245
|
}
|
|
237
246
|
finally
|
|
238
247
|
{
|
|
239
|
-
|
|
248
|
+
_setCurrentWizardContext(previousContext);
|
|
240
249
|
}
|
|
241
250
|
}
|
|
242
251
|
|
|
243
252
|
/**
|
|
244
253
|
* Get the current bot context. Throws if called outside bot execution.
|
|
245
254
|
*/
|
|
246
|
-
export function
|
|
255
|
+
export function getWizardContext(): WizardContext
|
|
247
256
|
{
|
|
248
|
-
if (!
|
|
257
|
+
if (!_getCurrentWizardContext())
|
|
249
258
|
{
|
|
250
259
|
throw new Error('Game state hooks can only be used inside a bot function');
|
|
251
260
|
}
|
|
252
|
-
return
|
|
261
|
+
return _getCurrentWizardContext()!;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run a function with missile context set, preserving the current entity ID.
|
|
266
|
+
* Used by the simulation to set up MissileContext before calling hooks-style missile AIs.
|
|
267
|
+
*/
|
|
268
|
+
export function withMissileContext<T>(context: MissileContext, fn: () => T): T
|
|
269
|
+
{
|
|
270
|
+
const previousContext = _getCurrentMissileContext();
|
|
271
|
+
_setCurrentMissileContext(context);
|
|
272
|
+
|
|
273
|
+
try
|
|
274
|
+
{
|
|
275
|
+
return fn();
|
|
276
|
+
}
|
|
277
|
+
finally
|
|
278
|
+
{
|
|
279
|
+
_setCurrentMissileContext(previousContext);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get the current missile context. Throws if called outside missile AI execution.
|
|
285
|
+
*/
|
|
286
|
+
export function getMissileContext(): MissileContext
|
|
287
|
+
{
|
|
288
|
+
if (!_getCurrentMissileContext())
|
|
289
|
+
{
|
|
290
|
+
throw new Error('getMissileContext() can only be used inside a missile AI function');
|
|
291
|
+
}
|
|
292
|
+
return _getCurrentMissileContext()!;
|
|
253
293
|
}
|
|
254
294
|
|
|
255
295
|
/**
|
|
@@ -9,25 +9,26 @@
|
|
|
9
9
|
* Extra capabilities over `simulate()`:
|
|
10
10
|
* - step(N) advances N ticks at a time (default 1) — caller controls pacing
|
|
11
11
|
* - replaceMissileAI(id, ai) hot-swaps a missile's AI mid-flight (used by
|
|
12
|
-
* the missile-
|
|
12
|
+
* the missile-guide feature in manual play)
|
|
13
13
|
* - setInvincible(wizardIndex, on) toggles damage immunity per wizard
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
RULES,
|
|
18
|
+
ARENA_SIZE,
|
|
19
|
+
ARENA_MIN,
|
|
20
|
+
ARENA_MAX,
|
|
19
21
|
MATCH_DURATION,
|
|
20
22
|
SPAWN_DISTANCE,
|
|
21
23
|
TICKS_PER_SECOND,
|
|
22
|
-
WIZARD_HEALTH,
|
|
23
24
|
} from '../rules.js';
|
|
24
25
|
import type {
|
|
25
26
|
GameConfig,
|
|
26
27
|
GameState,
|
|
27
|
-
MissileAIFunction,
|
|
28
28
|
ProjectileState,
|
|
29
|
-
WizardFunction,
|
|
30
29
|
} from '../types.js';
|
|
30
|
+
import type {MissileFunction} from '../hooks/types.js';
|
|
31
|
+
import type {WizardFunction} from '../hooks/types.js';
|
|
31
32
|
import {clearHooks} from './hooks-runtime.js';
|
|
32
33
|
import {
|
|
33
34
|
type BotError,
|
|
@@ -61,10 +62,10 @@ function createInitialWizards(spawnDistance: number): InternalWizardState[]
|
|
|
61
62
|
return [
|
|
62
63
|
{
|
|
63
64
|
id: 'wizard-1',
|
|
64
|
-
position: {x:
|
|
65
|
+
position: {x: ARENA_SIZE / 2 - spawnDistance / 2, y: ARENA_SIZE / 2},
|
|
65
66
|
rotation: 0,
|
|
66
|
-
health: WIZARD_HEALTH,
|
|
67
|
-
maxHealth: WIZARD_HEALTH,
|
|
67
|
+
health: RULES.WIZARD_HEALTH,
|
|
68
|
+
maxHealth: RULES.WIZARD_HEALTH,
|
|
68
69
|
state: 'idle',
|
|
69
70
|
blinkCooldown: 0,
|
|
70
71
|
velocity: {x: 0, y: 0},
|
|
@@ -74,10 +75,10 @@ function createInitialWizards(spawnDistance: number): InternalWizardState[]
|
|
|
74
75
|
},
|
|
75
76
|
{
|
|
76
77
|
id: 'wizard-2',
|
|
77
|
-
position: {x:
|
|
78
|
+
position: {x: ARENA_SIZE / 2 + spawnDistance / 2, y: ARENA_SIZE / 2},
|
|
78
79
|
rotation: 180,
|
|
79
|
-
health: WIZARD_HEALTH,
|
|
80
|
-
maxHealth: WIZARD_HEALTH,
|
|
80
|
+
health: RULES.WIZARD_HEALTH,
|
|
81
|
+
maxHealth: RULES.WIZARD_HEALTH,
|
|
81
82
|
state: 'idle',
|
|
82
83
|
blinkCooldown: 0,
|
|
83
84
|
velocity: {x: 0, y: 0},
|
|
@@ -98,13 +99,15 @@ export class ManualMatch
|
|
|
98
99
|
|
|
99
100
|
private wizards: InternalWizardState[];
|
|
100
101
|
private projectiles: ProjectileState[] = [];
|
|
101
|
-
private missileAIs: Map<string,
|
|
102
|
-
/** First-replacement originals for
|
|
103
|
-
private originalMissileAIs: Map<string,
|
|
102
|
+
private missileAIs: Map<string, MissileFunction> = new Map();
|
|
103
|
+
/** First-replacement originals for guided missiles. Used by restoreMissileAI. */
|
|
104
|
+
private originalMissileAIs: Map<string, MissileFunction> = new Map();
|
|
104
105
|
private currentTick = 0;
|
|
105
106
|
private done = false;
|
|
107
|
+
private deathTick: number | null = null;
|
|
106
108
|
private allErrors: BotError[] = [];
|
|
107
109
|
private history: GameState[];
|
|
110
|
+
private lastTickEvents: import('../types.js').SimEvent[] = [];
|
|
108
111
|
|
|
109
112
|
constructor(wizard1AI: WizardFunction, wizard2AI: WizardFunction, options: ManualMatchOptions = {})
|
|
110
113
|
{
|
|
@@ -131,11 +134,11 @@ export class ManualMatch
|
|
|
131
134
|
? Math.min(Math.floor(rawMaxTicks), 100000)
|
|
132
135
|
: MATCH_DURATION;
|
|
133
136
|
const spawnDistance = Number.isFinite(rawSpawn) && rawSpawn! > 0
|
|
134
|
-
? Math.min(rawSpawn!,
|
|
137
|
+
? Math.min(rawSpawn!, (ARENA_MAX - ARENA_MIN) - 100)
|
|
135
138
|
: SPAWN_DISTANCE;
|
|
136
139
|
|
|
137
140
|
this.config = Object.freeze({
|
|
138
|
-
arenaSize: Object.freeze({width:
|
|
141
|
+
arenaSize: Object.freeze({width: ARENA_SIZE, height: ARENA_SIZE}),
|
|
139
142
|
tickRate: TICKS_PER_SECOND,
|
|
140
143
|
maxTicks: this.maxTicks,
|
|
141
144
|
});
|
|
@@ -172,17 +175,22 @@ export class ManualMatch
|
|
|
172
175
|
this.currentTick = result.nextTick;
|
|
173
176
|
this.wizards = result.wizards;
|
|
174
177
|
this.projectiles = result.projectiles;
|
|
178
|
+
this.lastTickEvents = result.events;
|
|
175
179
|
if (result.errors.length > 0)
|
|
176
180
|
{
|
|
177
181
|
this.allErrors.push(...result.errors);
|
|
178
182
|
errorsThisCall.push(...result.errors);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
this.history.push(getPlayerState(0, this.wizards, this.projectiles, this.currentTick));
|
|
185
|
+
this.history.push(getPlayerState(0, this.wizards, this.projectiles, this.currentTick, result.events));
|
|
182
186
|
|
|
187
|
+
// Track death and add grace period (0.5s = 50 ticks at 100 tps)
|
|
188
|
+
if (!this.deathTick && (this.wizards[0]!.health <= 0 || this.wizards[1]!.health <= 0))
|
|
189
|
+
{
|
|
190
|
+
this.deathTick = this.currentTick;
|
|
191
|
+
}
|
|
183
192
|
if (
|
|
184
|
-
this.
|
|
185
|
-
|| this.wizards[1]!.health <= 0
|
|
193
|
+
(this.deathTick && this.currentTick - this.deathTick >= 200)
|
|
186
194
|
|| this.currentTick >= this.maxTicks
|
|
187
195
|
)
|
|
188
196
|
{
|
|
@@ -198,14 +206,14 @@ export class ManualMatch
|
|
|
198
206
|
}
|
|
199
207
|
|
|
200
208
|
/**
|
|
201
|
-
* Hot-swap a missile's AI function. Used by the missile-
|
|
209
|
+
* Hot-swap a missile's AI function. Used by the missile-guide feature
|
|
202
210
|
* in manual play. The first replacement remembers the original AI so
|
|
203
211
|
* `restoreMissileAI` can put it back. Subsequent replacements update
|
|
204
212
|
* the active AI but leave the remembered original alone.
|
|
205
213
|
*
|
|
206
214
|
* No-op if the projectile id doesn't exist.
|
|
207
215
|
*/
|
|
208
|
-
replaceMissileAI(projectileId: string, ai:
|
|
216
|
+
replaceMissileAI(projectileId: string, ai: MissileFunction): void
|
|
209
217
|
{
|
|
210
218
|
const existing = this.missileAIs.get(projectileId);
|
|
211
219
|
if (!existing) return;
|
|
@@ -217,8 +225,8 @@ export class ManualMatch
|
|
|
217
225
|
}
|
|
218
226
|
|
|
219
227
|
/**
|
|
220
|
-
* Restore a previously
|
|
221
|
-
* No-op if the projectile id doesn't exist or was never
|
|
228
|
+
* Restore a previously guided missile's original AI function.
|
|
229
|
+
* No-op if the projectile id doesn't exist or was never guided.
|
|
222
230
|
*/
|
|
223
231
|
restoreMissileAI(projectileId: string): void
|
|
224
232
|
{
|
|
@@ -246,7 +254,7 @@ export class ManualMatch
|
|
|
246
254
|
*/
|
|
247
255
|
getGameState(): GameState
|
|
248
256
|
{
|
|
249
|
-
return getPlayerState(0, this.wizards, this.projectiles, this.currentTick);
|
|
257
|
+
return getPlayerState(0, this.wizards, this.projectiles, this.currentTick, this.lastTickEvents);
|
|
250
258
|
}
|
|
251
259
|
|
|
252
260
|
getCurrentTick(): number
|
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
MISSILE_MIN_SPEED,
|
|
12
12
|
MISSILE_MIN_DURATION,
|
|
13
13
|
} from '../rules.js';
|
|
14
|
-
import type {
|
|
14
|
+
import type {MissileConfig} from '../types.js';
|
|
15
|
+
import type {MissileFunction} from '../hooks/types.js';
|
|
16
|
+
import {getMissileContext} from './hooks-runtime.js';
|
|
17
|
+
import {turnToward, flyStraight} from '../hooks/action-builders.js';
|
|
15
18
|
|
|
16
19
|
interface BaseParams
|
|
17
20
|
{
|
|
@@ -29,8 +32,6 @@ export type StraightParams = BaseParams;
|
|
|
29
32
|
|
|
30
33
|
export type HomingParams = TurningParams;
|
|
31
34
|
|
|
32
|
-
export type AntiHomingParams = TurningParams;
|
|
33
|
-
|
|
34
35
|
export interface SpiralParams extends BaseParams
|
|
35
36
|
{
|
|
36
37
|
spiralRadius: number;
|
|
@@ -45,7 +46,7 @@ export interface SeekerParams extends TurningParams
|
|
|
45
46
|
export interface MissileTemplate
|
|
46
47
|
{
|
|
47
48
|
config: MissileConfig;
|
|
48
|
-
ai:
|
|
49
|
+
ai: MissileFunction;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function clampBase(p: BaseParams): {damage: number; speed: number; duration: number}
|
|
@@ -64,7 +65,7 @@ export function straightMissile(p: StraightParams): MissileTemplate
|
|
|
64
65
|
{
|
|
65
66
|
const base = clampBase(p);
|
|
66
67
|
const config: MissileConfig = {...base, turnRate: 0};
|
|
67
|
-
const ai:
|
|
68
|
+
const ai: MissileFunction = () => flyStraight();
|
|
68
69
|
return {config, ai};
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -75,33 +76,12 @@ export function homingMissile(p: HomingParams): MissileTemplate
|
|
|
75
76
|
{
|
|
76
77
|
const base = clampBase(p);
|
|
77
78
|
const config: MissileConfig = {...base, turnRate: p.turnRate};
|
|
78
|
-
const ai:
|
|
79
|
+
const ai: MissileFunction = () =>
|
|
79
80
|
{
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
return {config, ai};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Anti-homing missile. Steers AWAY from the enemy — useful as a feint or
|
|
89
|
-
* area-denial pattern.
|
|
90
|
-
*/
|
|
91
|
-
export function antiHomingMissile(p: AntiHomingParams): MissileTemplate
|
|
92
|
-
{
|
|
93
|
-
const base = clampBase(p);
|
|
94
|
-
const config: MissileConfig = {...base, turnRate: p.turnRate};
|
|
95
|
-
const ai: MissileAIFunction = ({missileState, worldState}) =>
|
|
96
|
-
{
|
|
97
|
-
const enemy = worldState.enemies[0];
|
|
98
|
-
if (!enemy) return {};
|
|
99
|
-
// Reflect the enemy position through the missile to get an "away" target.
|
|
100
|
-
const target = {
|
|
101
|
-
x: missileState.position.x * 2 - enemy.position.x,
|
|
102
|
-
y: missileState.position.y * 2 - enemy.position.y,
|
|
103
|
-
};
|
|
104
|
-
return {turnToward: target};
|
|
81
|
+
const ctx = getMissileContext();
|
|
82
|
+
const enemy = ctx.worldState.enemies[0];
|
|
83
|
+
if (!enemy) return flyStraight();
|
|
84
|
+
return turnToward(enemy.position.x, enemy.position.y);
|
|
105
85
|
};
|
|
106
86
|
return {config, ai};
|
|
107
87
|
}
|
|
@@ -118,14 +98,15 @@ export function spiralMissile(p: SpiralParams): MissileTemplate
|
|
|
118
98
|
const config: MissileConfig = {...base, turnRate: 12};
|
|
119
99
|
const radius = Math.max(1, p.spiralRadius);
|
|
120
100
|
const freq = p.spiralFreq;
|
|
121
|
-
const ai:
|
|
101
|
+
const ai: MissileFunction = () =>
|
|
122
102
|
{
|
|
123
|
-
const
|
|
103
|
+
const ctx = getMissileContext();
|
|
104
|
+
const phase = -ctx.remainingTicks * freq;
|
|
124
105
|
const target = {
|
|
125
|
-
x:
|
|
126
|
-
y:
|
|
106
|
+
x: ctx.position.x + Math.cos(phase) * radius,
|
|
107
|
+
y: ctx.position.y + Math.sin(phase) * radius,
|
|
127
108
|
};
|
|
128
|
-
return
|
|
109
|
+
return turnToward(target.x, target.y);
|
|
129
110
|
};
|
|
130
111
|
return {config, ai};
|
|
131
112
|
}
|
|
@@ -141,15 +122,16 @@ export function seekerMissile(p: SeekerParams): MissileTemplate
|
|
|
141
122
|
const config: MissileConfig = {...base, turnRate: p.turnRate};
|
|
142
123
|
const minDist = Math.max(0, p.minLockDistance);
|
|
143
124
|
const minDistSq = minDist * minDist;
|
|
144
|
-
const ai:
|
|
125
|
+
const ai: MissileFunction = () =>
|
|
145
126
|
{
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
127
|
+
const ctx = getMissileContext();
|
|
128
|
+
const enemy = ctx.worldState.enemies[0];
|
|
129
|
+
if (!enemy) return flyStraight();
|
|
130
|
+
const dx = enemy.position.x - ctx.position.x;
|
|
131
|
+
const dy = enemy.position.y - ctx.position.y;
|
|
150
132
|
const distSq = dx * dx + dy * dy;
|
|
151
|
-
if (distSq < minDistSq) return
|
|
152
|
-
return
|
|
133
|
+
if (distSq < minDistSq) return flyStraight();
|
|
134
|
+
return turnToward(enemy.position.x, enemy.position.y);
|
|
153
135
|
};
|
|
154
136
|
return {config, ai};
|
|
155
137
|
}
|