@vibemancer/core 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-W7HWJFQ4.js +10599 -0
  3. package/dist/chunk-W7HWJFQ4.js.map +1 -0
  4. package/dist/index-browser.d.ts +1300 -1051
  5. package/dist/index-browser.js +55 -17
  6. package/dist/index.d.ts +53 -3
  7. package/dist/index.js +200 -54
  8. package/dist/index.js.map +1 -1
  9. package/package.json +21 -15
  10. package/src/bots/Hero.ts +867 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +162 -120
  12. package/src/bots/berserker/02_Stormcaller.ts +160 -116
  13. package/src/bots/berserker/03_Stormforger.ts +162 -129
  14. package/src/bots/caster/01_Flamecaller.ts +126 -84
  15. package/src/bots/caster/02_Pyromancer.ts +148 -101
  16. package/src/bots/caster/03_Infernalist.ts +180 -160
  17. package/src/bots/defensive/01_Turtle.ts +48 -44
  18. package/src/bots/defensive/02_Sentinel.ts +57 -53
  19. package/src/bots/defensive/03_Golem.ts +85 -97
  20. package/src/bots/duelist/01_Battlemage.ts +157 -122
  21. package/src/bots/duelist/02_Warmage.ts +166 -121
  22. package/src/bots/duelist/03_Archmage.ts +198 -178
  23. package/src/bots/homing/01_Bonemancer.ts +20 -32
  24. package/src/bots/homing/02_Lich.ts +145 -93
  25. package/src/bots/homing/03_Archlich.ts +129 -60
  26. package/src/bots/kiter/01_Spellspinner.ts +145 -107
  27. package/src/bots/kiter/02_Spellweaver.ts +153 -105
  28. package/src/bots/kiter/03_Spellbinder.ts +168 -123
  29. package/src/bots/melee/01_Shadowblade.ts +131 -75
  30. package/src/bots/melee/02_Nightblade.ts +174 -130
  31. package/src/bots/melee/03_Voidblade.ts +266 -168
  32. package/src/bots/registry.ts +44 -37
  33. package/src/bots/shared.ts +185 -25
  34. package/src/bots/sniper/01_Spellshot.ts +151 -98
  35. package/src/bots/sniper/02_Spelltracer.ts +173 -128
  36. package/src/bots/sniper/03_Spellseeker.ts +177 -152
  37. package/src/bots/standalone/Critter.ts +46 -52
  38. package/src/bots/standalone/Doombringer.ts +36 -40
  39. package/src/bots/standalone/Hogger.ts +120 -89
  40. package/src/bots/standalone/Rookie.ts +21 -23
  41. package/src/bots/standalone/TargetDummy.ts +5 -6
  42. package/src/bots/test/cheater.ts +91 -85
  43. package/src/bots/test/crasher.ts +26 -28
  44. package/src/engine/bundle-fight.ts +242 -0
  45. package/src/engine/hooks-runtime.ts +58 -18
  46. package/src/engine/manual-match.ts +33 -25
  47. package/src/engine/missile-templates.ts +25 -43
  48. package/src/engine/optimizer.ts +7 -7
  49. package/src/engine/params-runtime.ts +3 -3
  50. package/src/engine/physics.ts +33 -23
  51. package/src/engine/sandbox-browser.ts +8 -7
  52. package/src/engine/sandbox-compile.ts +1 -1
  53. package/src/engine/sandbox-harness.ts +299 -367
  54. package/src/engine/sandbox.ts +3 -3
  55. package/src/engine/simulation.ts +291 -76
  56. package/src/engine/spells.ts +9 -12
  57. package/src/engine-version.ts +1 -1
  58. package/src/hooks/action-builders.ts +76 -21
  59. package/src/hooks/index.ts +17 -9
  60. package/src/hooks/state-hooks.ts +61 -25
  61. package/src/hooks/threat-analysis.ts +44 -20
  62. package/src/hooks/types.ts +62 -5
  63. package/src/index.ts +2 -0
  64. package/src/rules.ts +209 -63
  65. package/src/stats.ts +2 -2
  66. package/src/testing.ts +6 -30
  67. package/src/trace.ts +63 -3
  68. package/src/types.ts +127 -14
  69. package/src/utils/angles.ts +1 -0
  70. package/src/utils/combat.ts +98 -6
  71. package/src/utils/spatial.ts +3 -3
  72. package/dist/chunk-B7YYYBEC.js +0 -9140
  73. package/dist/chunk-B7YYYBEC.js.map +0 -1
  74. 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 {BotContext} from '../hooks/types.js';
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 _getCurrentBotContext(): BotContext | null
66
+ function _getCurrentWizardContext(): WizardContext | null
67
67
  {
68
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_currentBotContext
69
- return (_g.__vibemancer_currentBotContext as BotContext | null) ?? null;
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 _setCurrentBotContext(ctx: BotContext | null): void
71
+ function _setCurrentWizardContext(ctx: WizardContext | null): void
72
72
  {
73
- _g.__vibemancer_currentBotContext = ctx;
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 runBotWithContext<T>(entityId: string, context: BotContext, fn: () => T): T
218
+ export function runWizardWithContext<T>(entityId: string, context: WizardContext, fn: () => T): T
210
219
  {
211
- const previousContext = _getCurrentBotContext();
212
- _setCurrentBotContext(context);
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
- _setCurrentBotContext(previousContext);
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
- * Use this inside wrapNewBot where the entity ID is already set by the outer runWithHooks.
235
+ * Used by the simulation to set up WizardContext before calling hooks-style bots.
227
236
  */
228
- export function withBotContext<T>(context: BotContext, fn: () => T): T
237
+ export function withWizardContext<T>(context: WizardContext, fn: () => T): T
229
238
  {
230
- const previousContext = _getCurrentBotContext();
231
- _setCurrentBotContext(context);
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
- _setCurrentBotContext(previousContext);
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 getBotContext(): BotContext
255
+ export function getWizardContext(): WizardContext
247
256
  {
248
- if (!_getCurrentBotContext())
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 _getCurrentBotContext()!;
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-hijack feature in manual play)
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
- ARENA_HEIGHT,
18
- ARENA_WIDTH,
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: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
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: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
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, MissileAIFunction> = new Map();
102
- /** First-replacement originals for hijacked missiles. Used by restoreMissileAI. */
103
- private originalMissileAIs: Map<string, MissileAIFunction> = new Map();
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!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100)
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: ARENA_WIDTH, height: ARENA_HEIGHT}),
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.wizards[0]!.health <= 0
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-hijack feature
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: MissileAIFunction): void
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 hijacked missile's original AI function.
221
- * No-op if the projectile id doesn't exist or was never hijacked.
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 {MissileAIFunction, MissileConfig} from '../types.js';
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: MissileAIFunction;
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: MissileAIFunction = () => ({});
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: MissileAIFunction = ({worldState}) =>
79
+ const ai: MissileFunction = () =>
79
80
  {
80
- const enemy = worldState.enemies[0];
81
- if (!enemy) return {};
82
- return {turnToward: enemy.position};
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: MissileAIFunction = ({missileState}) =>
101
+ const ai: MissileFunction = () =>
122
102
  {
123
- const phase = -missileState.remainingTicks * freq;
103
+ const ctx = getMissileContext();
104
+ const phase = -ctx.remainingTicks * freq;
124
105
  const target = {
125
- x: missileState.position.x + Math.cos(phase) * radius,
126
- y: missileState.position.y + Math.sin(phase) * radius,
106
+ x: ctx.position.x + Math.cos(phase) * radius,
107
+ y: ctx.position.y + Math.sin(phase) * radius,
127
108
  };
128
- return {turnToward: target};
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: MissileAIFunction = ({missileState, worldState}) =>
125
+ const ai: MissileFunction = () =>
145
126
  {
146
- const enemy = worldState.enemies[0];
147
- if (!enemy) return {};
148
- const dx = enemy.position.x - missileState.position.x;
149
- const dy = enemy.position.y - missileState.position.y;
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 {turnToward: enemy.position};
133
+ if (distSq < minDistSq) return flyStraight();
134
+ return turnToward(enemy.position.x, enemy.position.y);
153
135
  };
154
136
  return {config, ai};
155
137
  }