@vibemancer/core 0.1.0 → 0.1.1
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 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-7VDJHO22.js} +3 -3
- package/dist/chunk-7VDJHO22.js.map +1 -0
- package/dist/index-browser.d.ts +1 -1
- package/dist/index-browser.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +79 -78
- package/src/bots/berserker/01_Stormchaser.ts +457 -457
- package/src/bots/berserker/02_Stormcaller.ts +417 -417
- package/src/bots/berserker/03_Stormforger.ts +481 -481
- package/src/bots/caster/01_Flamecaller.ts +286 -286
- package/src/bots/caster/02_Pyromancer.ts +350 -350
- package/src/bots/caster/03_Infernalist.ts +492 -492
- package/src/bots/defensive/01_Turtle.ts +151 -151
- package/src/bots/defensive/02_Sentinel.ts +134 -134
- package/src/bots/defensive/03_Golem.ts +357 -357
- package/src/bots/duelist/01_Battlemage.ts +433 -433
- package/src/bots/duelist/02_Warmage.ts +438 -438
- package/src/bots/duelist/03_Archmage.ts +588 -588
- package/src/bots/homing/01_Bonemancer.ts +67 -67
- package/src/bots/homing/02_Lich.ts +356 -356
- package/src/bots/homing/03_Archlich.ts +220 -220
- package/src/bots/kiter/01_Spellspinner.ts +398 -398
- package/src/bots/kiter/02_Spellweaver.ts +378 -378
- package/src/bots/kiter/03_Spellbinder.ts +448 -448
- package/src/bots/melee/01_Shadowblade.ts +270 -270
- package/src/bots/melee/02_Nightblade.ts +437 -437
- package/src/bots/melee/03_Voidblade.ts +582 -582
- package/src/bots/sniper/01_Spellshot.ts +385 -385
- package/src/bots/sniper/02_Spelltracer.ts +441 -441
- package/src/bots/sniper/03_Spellseeker.ts +546 -546
- package/src/bots/standalone/Critter.ts +89 -89
- package/src/bots/standalone/Doombringer.ts +91 -91
- package/src/bots/standalone/Hogger.ts +228 -228
- package/src/bots/standalone/Rookie.ts +50 -50
- package/src/bots/standalone/TargetDummy.ts +21 -21
- package/src/bots/test/cheater.ts +405 -405
- package/src/bots/test/crasher.ts +81 -81
- package/src/engine/hooks-runtime.ts +394 -394
- package/src/engine/manual-match.ts +289 -289
- package/src/engine/missile-templates.ts +155 -155
- package/src/engine/physics.ts +143 -143
- package/src/engine/simulation.ts +828 -828
- package/src/engine/spells.ts +128 -128
- package/src/engine-version.ts +1 -1
- package/src/index.ts +23 -23
- package/src/rules.ts +254 -254
- package/src/types.ts +193 -193
- package/src/utils/index.ts +6 -6
- package/dist/chunk-L7Z7OFXD.js.map +0 -1
|
@@ -1,394 +1,394 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VIBEMANCER - HOOKS RUNTIME
|
|
3
|
-
*
|
|
4
|
-
* This file implements a minimal React-like hooks runtime for AI programming.
|
|
5
|
-
* It supports useState, useEffect, useMemo, useRef, and useParam with entity isolation.
|
|
6
|
-
*
|
|
7
|
-
* RULES OF HOOKS (same as React):
|
|
8
|
-
* - Hooks must be called at the top level of the bot function
|
|
9
|
-
* - Hooks must be called in the same order every tick
|
|
10
|
-
* - Hooks must NOT be called conditionally
|
|
11
|
-
*
|
|
12
|
-
* Violating these rules throws an error (detected via hook index validation).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type {BotContext} from '../hooks/types.js';
|
|
16
|
-
|
|
17
|
-
export interface HookState
|
|
18
|
-
{
|
|
19
|
-
values: unknown[];
|
|
20
|
-
effects: {
|
|
21
|
-
callback: () => void | (() => void);
|
|
22
|
-
deps?: unknown[];
|
|
23
|
-
cleanup?: () => void;
|
|
24
|
-
}[];
|
|
25
|
-
memos: {
|
|
26
|
-
value: unknown;
|
|
27
|
-
deps?: unknown[];
|
|
28
|
-
}[];
|
|
29
|
-
/** Type of each hook call in order (for validation). */
|
|
30
|
-
hookTypes: string[];
|
|
31
|
-
/** Total hooks called on first successful tick. */
|
|
32
|
-
hookCount: number;
|
|
33
|
-
/** Whether the first tick has completed successfully (hook pattern established). */
|
|
34
|
-
initialized: boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Use globalThis for hooks state so that multiple copies of the hooks runtime
|
|
38
|
-
// (e.g., engine template + bot bundle in sandboxed execution) share the same state.
|
|
39
|
-
// Without this, bot bundles that inline their own copy of the hooks runtime would
|
|
40
|
-
// have separate currentEntityId etc., causing "useParam can only be used inside
|
|
41
|
-
// a bot function" errors.
|
|
42
|
-
const _g = globalThis as Record<string, unknown>;
|
|
43
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_entityHooks
|
|
44
|
-
const entityHooks: Map<string, HookState> = (_g.__vibemancer_entityHooks as Map<string, HookState>)
|
|
45
|
-
?? (_g.__vibemancer_entityHooks = new Map<string, HookState>());
|
|
46
|
-
|
|
47
|
-
// Accessor functions for shared mutable state on globalThis
|
|
48
|
-
function _getCurrentEntityId(): string | null
|
|
49
|
-
{
|
|
50
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_currentEntityId
|
|
51
|
-
return (_g.__vibemancer_currentEntityId as string | null) ?? null;
|
|
52
|
-
}
|
|
53
|
-
function _setCurrentEntityId(id: string | null): void
|
|
54
|
-
{
|
|
55
|
-
_g.__vibemancer_currentEntityId = id;
|
|
56
|
-
}
|
|
57
|
-
function _getCurrentHookIndex(): number
|
|
58
|
-
{
|
|
59
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_currentHookIndex
|
|
60
|
-
return (_g.__vibemancer_currentHookIndex as number) ?? 0;
|
|
61
|
-
}
|
|
62
|
-
function _setCurrentHookIndex(idx: number): void
|
|
63
|
-
{
|
|
64
|
-
_g.__vibemancer_currentHookIndex = idx;
|
|
65
|
-
}
|
|
66
|
-
function _getCurrentBotContext(): BotContext | null
|
|
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;
|
|
70
|
-
}
|
|
71
|
-
function _setCurrentBotContext(ctx: BotContext | null): void
|
|
72
|
-
{
|
|
73
|
-
_g.__vibemancer_currentBotContext = ctx;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Get or create the hook state for an entity.
|
|
78
|
-
*/
|
|
79
|
-
function getHookState(entityId: string): HookState
|
|
80
|
-
{
|
|
81
|
-
let state = entityHooks.get(entityId);
|
|
82
|
-
if (!state)
|
|
83
|
-
{
|
|
84
|
-
state = {
|
|
85
|
-
values: [],
|
|
86
|
-
effects: [],
|
|
87
|
-
memos: [],
|
|
88
|
-
hookTypes: [],
|
|
89
|
-
hookCount: 0,
|
|
90
|
-
initialized: false,
|
|
91
|
-
};
|
|
92
|
-
entityHooks.set(entityId, state);
|
|
93
|
-
}
|
|
94
|
-
return state;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Validate a hook call and return its sequential index.
|
|
99
|
-
* Ensures hooks are called in the same order every tick.
|
|
100
|
-
*
|
|
101
|
-
* On the first tick: records the hook type at this index.
|
|
102
|
-
* On subsequent ticks: validates the hook type matches.
|
|
103
|
-
*
|
|
104
|
-
* @param type - The hook type name (e.g., 'useState', 'useEffect', 'useParam')
|
|
105
|
-
* @returns The sequential hook index
|
|
106
|
-
* @throws If called outside runWithHooks or if hook order changed
|
|
107
|
-
*/
|
|
108
|
-
export function validateHookCall(type: string): number
|
|
109
|
-
{
|
|
110
|
-
if (!_getCurrentEntityId())
|
|
111
|
-
{
|
|
112
|
-
throw new Error(`${type} can only be used inside a bot function (runWithHooks)`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const state = getHookState(_getCurrentEntityId()!);
|
|
116
|
-
const index = _getCurrentHookIndex();
|
|
117
|
-
_setCurrentHookIndex(index + 1);
|
|
118
|
-
|
|
119
|
-
if (state.initialized)
|
|
120
|
-
{
|
|
121
|
-
// Validate: not more hooks than first tick
|
|
122
|
-
if (index >= state.hookCount)
|
|
123
|
-
{
|
|
124
|
-
throw new Error(
|
|
125
|
-
`Hook "${type}" called at index ${index}, but only ${state.hookCount} hooks were registered on the first tick. ` +
|
|
126
|
-
'This likely means a hook was called conditionally. Hooks must be called in the same order every tick.',
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
// Validate: same hook type at this index
|
|
130
|
-
if (state.hookTypes[index] !== type)
|
|
131
|
-
{
|
|
132
|
-
throw new Error(
|
|
133
|
-
`Hook order changed at index ${index}: expected "${state.hookTypes[index]}", got "${type}". ` +
|
|
134
|
-
'Hooks must be called in the same order every tick.',
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
else
|
|
139
|
-
{
|
|
140
|
-
// First tick: record hook type
|
|
141
|
-
state.hookTypes[index] = type;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return index;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Finalize hook count after a successful tick.
|
|
149
|
-
* On first tick: establishes the hook pattern.
|
|
150
|
-
* On subsequent ticks: validates the count matches.
|
|
151
|
-
*/
|
|
152
|
-
function finalizeHookCount(entityId: string): void
|
|
153
|
-
{
|
|
154
|
-
const state = entityHooks.get(entityId);
|
|
155
|
-
if (!state) return;
|
|
156
|
-
|
|
157
|
-
if (state.initialized)
|
|
158
|
-
{
|
|
159
|
-
if (_getCurrentHookIndex() < state.hookCount)
|
|
160
|
-
{
|
|
161
|
-
throw new Error(
|
|
162
|
-
`Fewer hooks were called (${_getCurrentHookIndex()}) than on the first tick (${state.hookCount}). ` +
|
|
163
|
-
'This likely means a hook was called conditionally. Hooks must be called in the same order every tick.',
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
else
|
|
168
|
-
{
|
|
169
|
-
state.hookCount = _getCurrentHookIndex();
|
|
170
|
-
state.initialized = true;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Run a function with a specific entity's hook context.
|
|
176
|
-
*/
|
|
177
|
-
export function runWithHooks<T>(entityId: string, fn: () => T): T
|
|
178
|
-
{
|
|
179
|
-
const previousEntityId = _getCurrentEntityId();
|
|
180
|
-
const previousHookIndex = _getCurrentHookIndex();
|
|
181
|
-
|
|
182
|
-
_setCurrentEntityId(entityId);
|
|
183
|
-
_setCurrentHookIndex(0);
|
|
184
|
-
|
|
185
|
-
// If the first tick failed, reset hook type tracking so next attempt starts clean
|
|
186
|
-
const state = entityHooks.get(entityId);
|
|
187
|
-
if (state && !state.initialized)
|
|
188
|
-
{
|
|
189
|
-
state.hookTypes.length = 0;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
try
|
|
193
|
-
{
|
|
194
|
-
const result = fn();
|
|
195
|
-
finalizeHookCount(entityId);
|
|
196
|
-
return result;
|
|
197
|
-
}
|
|
198
|
-
finally
|
|
199
|
-
{
|
|
200
|
-
_setCurrentEntityId(previousEntityId);
|
|
201
|
-
_setCurrentHookIndex(previousHookIndex);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Run a bot function with full context (game state + persistence hooks).
|
|
207
|
-
* Use this when you need to set BOTH the entity ID and the game context.
|
|
208
|
-
*/
|
|
209
|
-
export function runBotWithContext<T>(entityId: string, context: BotContext, fn: () => T): T
|
|
210
|
-
{
|
|
211
|
-
const previousContext = _getCurrentBotContext();
|
|
212
|
-
_setCurrentBotContext(context);
|
|
213
|
-
|
|
214
|
-
try
|
|
215
|
-
{
|
|
216
|
-
return runWithHooks(entityId, fn);
|
|
217
|
-
}
|
|
218
|
-
finally
|
|
219
|
-
{
|
|
220
|
-
_setCurrentBotContext(previousContext);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* 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.
|
|
227
|
-
*/
|
|
228
|
-
export function withBotContext<T>(context: BotContext, fn: () => T): T
|
|
229
|
-
{
|
|
230
|
-
const previousContext = _getCurrentBotContext();
|
|
231
|
-
_setCurrentBotContext(context);
|
|
232
|
-
|
|
233
|
-
try
|
|
234
|
-
{
|
|
235
|
-
return fn();
|
|
236
|
-
}
|
|
237
|
-
finally
|
|
238
|
-
{
|
|
239
|
-
_setCurrentBotContext(previousContext);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Get the current bot context. Throws if called outside bot execution.
|
|
245
|
-
*/
|
|
246
|
-
export function getBotContext(): BotContext
|
|
247
|
-
{
|
|
248
|
-
if (!_getCurrentBotContext())
|
|
249
|
-
{
|
|
250
|
-
throw new Error('Game state hooks can only be used inside a bot function');
|
|
251
|
-
}
|
|
252
|
-
return _getCurrentBotContext()!;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Persist state between ticks.
|
|
257
|
-
*/
|
|
258
|
-
export function useState<T>(initialValue: T | (() => T)): [T, (newValue: T | ((prev: T) => T)) => void]
|
|
259
|
-
{
|
|
260
|
-
const index = validateHookCall('useState');
|
|
261
|
-
const state = getHookState(_getCurrentEntityId()!);
|
|
262
|
-
|
|
263
|
-
if (state.values.length <= index)
|
|
264
|
-
{
|
|
265
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
266
|
-
state.values[index] = (typeof initialValue === 'function') ? (initialValue as () => T)() : initialValue;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const setter = (newValue: T | ((prev: T) => T)): void =>
|
|
270
|
-
{
|
|
271
|
-
if (typeof newValue === 'function')
|
|
272
|
-
{
|
|
273
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
274
|
-
state.values[index] = (newValue as (prev: T) => T)(state.values[index] as T);
|
|
275
|
-
}
|
|
276
|
-
else
|
|
277
|
-
{
|
|
278
|
-
state.values[index] = newValue;
|
|
279
|
-
}
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
283
|
-
return [state.values[index] as T, setter];
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* React to state changes.
|
|
288
|
-
*/
|
|
289
|
-
export function useEffect(callback: () => void | (() => void), deps?: unknown[]): void
|
|
290
|
-
{
|
|
291
|
-
const index = validateHookCall('useEffect');
|
|
292
|
-
const state = getHookState(_getCurrentEntityId()!);
|
|
293
|
-
|
|
294
|
-
const oldEffect = state.effects[index];
|
|
295
|
-
const hasChanged = !oldEffect || !deps ||
|
|
296
|
-
deps.length !== oldEffect.deps?.length ||
|
|
297
|
-
!deps.every((dep, i) => dep === oldEffect.deps?.[i]);
|
|
298
|
-
|
|
299
|
-
if (hasChanged)
|
|
300
|
-
{
|
|
301
|
-
// Cleanup old effect if it exists
|
|
302
|
-
if (oldEffect?.cleanup)
|
|
303
|
-
{
|
|
304
|
-
oldEffect.cleanup();
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Run new effect
|
|
308
|
-
const cleanup = callback();
|
|
309
|
-
state.effects[index] = {
|
|
310
|
-
callback,
|
|
311
|
-
deps,
|
|
312
|
-
cleanup: typeof cleanup === 'function' ? cleanup : undefined,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Memoize expensive calculations.
|
|
319
|
-
*/
|
|
320
|
-
export function useMemo<T>(factory: () => T, deps?: unknown[]): T
|
|
321
|
-
{
|
|
322
|
-
const index = validateHookCall('useMemo');
|
|
323
|
-
const state = getHookState(_getCurrentEntityId()!);
|
|
324
|
-
|
|
325
|
-
const oldMemo = state.memos[index];
|
|
326
|
-
const hasChanged = !oldMemo || !deps ||
|
|
327
|
-
deps.length !== oldMemo.deps?.length ||
|
|
328
|
-
!deps.every((dep, i) => dep === oldMemo.deps?.[i]);
|
|
329
|
-
|
|
330
|
-
if (hasChanged)
|
|
331
|
-
{
|
|
332
|
-
const value = factory();
|
|
333
|
-
state.memos[index] = {
|
|
334
|
-
value,
|
|
335
|
-
deps,
|
|
336
|
-
};
|
|
337
|
-
return value;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
341
|
-
return oldMemo.value as T;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Mutable reference that persists across ticks.
|
|
346
|
-
* Unlike useState, mutations don't need a setter - just modify .current directly.
|
|
347
|
-
*/
|
|
348
|
-
export interface RefObject<T>
|
|
349
|
-
{
|
|
350
|
-
current: T;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export function useRef<T>(initialValue: T): RefObject<T>
|
|
354
|
-
{
|
|
355
|
-
const index = validateHookCall('useRef');
|
|
356
|
-
const state = getHookState(_getCurrentEntityId()!);
|
|
357
|
-
|
|
358
|
-
if (state.values.length <= index)
|
|
359
|
-
{
|
|
360
|
-
state.values[index] = {current: initialValue};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
364
|
-
return state.values[index] as RefObject<T>;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Clear hook state for an entity (e.g., when it dies).
|
|
369
|
-
*/
|
|
370
|
-
export function clearHooks(entityId: string): void
|
|
371
|
-
{
|
|
372
|
-
const state = entityHooks.get(entityId);
|
|
373
|
-
if (state)
|
|
374
|
-
{
|
|
375
|
-
state.effects.forEach((effect) =>
|
|
376
|
-
{
|
|
377
|
-
if (effect.cleanup)
|
|
378
|
-
{
|
|
379
|
-
effect.cleanup();
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
entityHooks.delete(entityId);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Reset all hook states (e.g., when a match restarts).
|
|
388
|
-
*/
|
|
389
|
-
export function resetAllHooks(): void
|
|
390
|
-
{
|
|
391
|
-
entityHooks.forEach((_, entityId) => clearHooks(entityId));
|
|
392
|
-
entityHooks.clear();
|
|
393
|
-
}
|
|
394
|
-
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER - HOOKS RUNTIME
|
|
3
|
+
*
|
|
4
|
+
* This file implements a minimal React-like hooks runtime for AI programming.
|
|
5
|
+
* It supports useState, useEffect, useMemo, useRef, and useParam with entity isolation.
|
|
6
|
+
*
|
|
7
|
+
* RULES OF HOOKS (same as React):
|
|
8
|
+
* - Hooks must be called at the top level of the bot function
|
|
9
|
+
* - Hooks must be called in the same order every tick
|
|
10
|
+
* - Hooks must NOT be called conditionally
|
|
11
|
+
*
|
|
12
|
+
* Violating these rules throws an error (detected via hook index validation).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {BotContext} from '../hooks/types.js';
|
|
16
|
+
|
|
17
|
+
export interface HookState
|
|
18
|
+
{
|
|
19
|
+
values: unknown[];
|
|
20
|
+
effects: {
|
|
21
|
+
callback: () => void | (() => void);
|
|
22
|
+
deps?: unknown[];
|
|
23
|
+
cleanup?: () => void;
|
|
24
|
+
}[];
|
|
25
|
+
memos: {
|
|
26
|
+
value: unknown;
|
|
27
|
+
deps?: unknown[];
|
|
28
|
+
}[];
|
|
29
|
+
/** Type of each hook call in order (for validation). */
|
|
30
|
+
hookTypes: string[];
|
|
31
|
+
/** Total hooks called on first successful tick. */
|
|
32
|
+
hookCount: number;
|
|
33
|
+
/** Whether the first tick has completed successfully (hook pattern established). */
|
|
34
|
+
initialized: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Use globalThis for hooks state so that multiple copies of the hooks runtime
|
|
38
|
+
// (e.g., engine template + bot bundle in sandboxed execution) share the same state.
|
|
39
|
+
// Without this, bot bundles that inline their own copy of the hooks runtime would
|
|
40
|
+
// have separate currentEntityId etc., causing "useParam can only be used inside
|
|
41
|
+
// a bot function" errors.
|
|
42
|
+
const _g = globalThis as Record<string, unknown>;
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_entityHooks
|
|
44
|
+
const entityHooks: Map<string, HookState> = (_g.__vibemancer_entityHooks as Map<string, HookState>)
|
|
45
|
+
?? (_g.__vibemancer_entityHooks = new Map<string, HookState>());
|
|
46
|
+
|
|
47
|
+
// Accessor functions for shared mutable state on globalThis
|
|
48
|
+
function _getCurrentEntityId(): string | null
|
|
49
|
+
{
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_currentEntityId
|
|
51
|
+
return (_g.__vibemancer_currentEntityId as string | null) ?? null;
|
|
52
|
+
}
|
|
53
|
+
function _setCurrentEntityId(id: string | null): void
|
|
54
|
+
{
|
|
55
|
+
_g.__vibemancer_currentEntityId = id;
|
|
56
|
+
}
|
|
57
|
+
function _getCurrentHookIndex(): number
|
|
58
|
+
{
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- we own __vibemancer_currentHookIndex
|
|
60
|
+
return (_g.__vibemancer_currentHookIndex as number) ?? 0;
|
|
61
|
+
}
|
|
62
|
+
function _setCurrentHookIndex(idx: number): void
|
|
63
|
+
{
|
|
64
|
+
_g.__vibemancer_currentHookIndex = idx;
|
|
65
|
+
}
|
|
66
|
+
function _getCurrentBotContext(): BotContext | null
|
|
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;
|
|
70
|
+
}
|
|
71
|
+
function _setCurrentBotContext(ctx: BotContext | null): void
|
|
72
|
+
{
|
|
73
|
+
_g.__vibemancer_currentBotContext = ctx;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get or create the hook state for an entity.
|
|
78
|
+
*/
|
|
79
|
+
function getHookState(entityId: string): HookState
|
|
80
|
+
{
|
|
81
|
+
let state = entityHooks.get(entityId);
|
|
82
|
+
if (!state)
|
|
83
|
+
{
|
|
84
|
+
state = {
|
|
85
|
+
values: [],
|
|
86
|
+
effects: [],
|
|
87
|
+
memos: [],
|
|
88
|
+
hookTypes: [],
|
|
89
|
+
hookCount: 0,
|
|
90
|
+
initialized: false,
|
|
91
|
+
};
|
|
92
|
+
entityHooks.set(entityId, state);
|
|
93
|
+
}
|
|
94
|
+
return state;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate a hook call and return its sequential index.
|
|
99
|
+
* Ensures hooks are called in the same order every tick.
|
|
100
|
+
*
|
|
101
|
+
* On the first tick: records the hook type at this index.
|
|
102
|
+
* On subsequent ticks: validates the hook type matches.
|
|
103
|
+
*
|
|
104
|
+
* @param type - The hook type name (e.g., 'useState', 'useEffect', 'useParam')
|
|
105
|
+
* @returns The sequential hook index
|
|
106
|
+
* @throws If called outside runWithHooks or if hook order changed
|
|
107
|
+
*/
|
|
108
|
+
export function validateHookCall(type: string): number
|
|
109
|
+
{
|
|
110
|
+
if (!_getCurrentEntityId())
|
|
111
|
+
{
|
|
112
|
+
throw new Error(`${type} can only be used inside a bot function (runWithHooks)`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const state = getHookState(_getCurrentEntityId()!);
|
|
116
|
+
const index = _getCurrentHookIndex();
|
|
117
|
+
_setCurrentHookIndex(index + 1);
|
|
118
|
+
|
|
119
|
+
if (state.initialized)
|
|
120
|
+
{
|
|
121
|
+
// Validate: not more hooks than first tick
|
|
122
|
+
if (index >= state.hookCount)
|
|
123
|
+
{
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Hook "${type}" called at index ${index}, but only ${state.hookCount} hooks were registered on the first tick. ` +
|
|
126
|
+
'This likely means a hook was called conditionally. Hooks must be called in the same order every tick.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
// Validate: same hook type at this index
|
|
130
|
+
if (state.hookTypes[index] !== type)
|
|
131
|
+
{
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Hook order changed at index ${index}: expected "${state.hookTypes[index]}", got "${type}". ` +
|
|
134
|
+
'Hooks must be called in the same order every tick.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
{
|
|
140
|
+
// First tick: record hook type
|
|
141
|
+
state.hookTypes[index] = type;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return index;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Finalize hook count after a successful tick.
|
|
149
|
+
* On first tick: establishes the hook pattern.
|
|
150
|
+
* On subsequent ticks: validates the count matches.
|
|
151
|
+
*/
|
|
152
|
+
function finalizeHookCount(entityId: string): void
|
|
153
|
+
{
|
|
154
|
+
const state = entityHooks.get(entityId);
|
|
155
|
+
if (!state) return;
|
|
156
|
+
|
|
157
|
+
if (state.initialized)
|
|
158
|
+
{
|
|
159
|
+
if (_getCurrentHookIndex() < state.hookCount)
|
|
160
|
+
{
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Fewer hooks were called (${_getCurrentHookIndex()}) than on the first tick (${state.hookCount}). ` +
|
|
163
|
+
'This likely means a hook was called conditionally. Hooks must be called in the same order every tick.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else
|
|
168
|
+
{
|
|
169
|
+
state.hookCount = _getCurrentHookIndex();
|
|
170
|
+
state.initialized = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Run a function with a specific entity's hook context.
|
|
176
|
+
*/
|
|
177
|
+
export function runWithHooks<T>(entityId: string, fn: () => T): T
|
|
178
|
+
{
|
|
179
|
+
const previousEntityId = _getCurrentEntityId();
|
|
180
|
+
const previousHookIndex = _getCurrentHookIndex();
|
|
181
|
+
|
|
182
|
+
_setCurrentEntityId(entityId);
|
|
183
|
+
_setCurrentHookIndex(0);
|
|
184
|
+
|
|
185
|
+
// If the first tick failed, reset hook type tracking so next attempt starts clean
|
|
186
|
+
const state = entityHooks.get(entityId);
|
|
187
|
+
if (state && !state.initialized)
|
|
188
|
+
{
|
|
189
|
+
state.hookTypes.length = 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try
|
|
193
|
+
{
|
|
194
|
+
const result = fn();
|
|
195
|
+
finalizeHookCount(entityId);
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
finally
|
|
199
|
+
{
|
|
200
|
+
_setCurrentEntityId(previousEntityId);
|
|
201
|
+
_setCurrentHookIndex(previousHookIndex);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Run a bot function with full context (game state + persistence hooks).
|
|
207
|
+
* Use this when you need to set BOTH the entity ID and the game context.
|
|
208
|
+
*/
|
|
209
|
+
export function runBotWithContext<T>(entityId: string, context: BotContext, fn: () => T): T
|
|
210
|
+
{
|
|
211
|
+
const previousContext = _getCurrentBotContext();
|
|
212
|
+
_setCurrentBotContext(context);
|
|
213
|
+
|
|
214
|
+
try
|
|
215
|
+
{
|
|
216
|
+
return runWithHooks(entityId, fn);
|
|
217
|
+
}
|
|
218
|
+
finally
|
|
219
|
+
{
|
|
220
|
+
_setCurrentBotContext(previousContext);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 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.
|
|
227
|
+
*/
|
|
228
|
+
export function withBotContext<T>(context: BotContext, fn: () => T): T
|
|
229
|
+
{
|
|
230
|
+
const previousContext = _getCurrentBotContext();
|
|
231
|
+
_setCurrentBotContext(context);
|
|
232
|
+
|
|
233
|
+
try
|
|
234
|
+
{
|
|
235
|
+
return fn();
|
|
236
|
+
}
|
|
237
|
+
finally
|
|
238
|
+
{
|
|
239
|
+
_setCurrentBotContext(previousContext);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get the current bot context. Throws if called outside bot execution.
|
|
245
|
+
*/
|
|
246
|
+
export function getBotContext(): BotContext
|
|
247
|
+
{
|
|
248
|
+
if (!_getCurrentBotContext())
|
|
249
|
+
{
|
|
250
|
+
throw new Error('Game state hooks can only be used inside a bot function');
|
|
251
|
+
}
|
|
252
|
+
return _getCurrentBotContext()!;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Persist state between ticks.
|
|
257
|
+
*/
|
|
258
|
+
export function useState<T>(initialValue: T | (() => T)): [T, (newValue: T | ((prev: T) => T)) => void]
|
|
259
|
+
{
|
|
260
|
+
const index = validateHookCall('useState');
|
|
261
|
+
const state = getHookState(_getCurrentEntityId()!);
|
|
262
|
+
|
|
263
|
+
if (state.values.length <= index)
|
|
264
|
+
{
|
|
265
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
266
|
+
state.values[index] = (typeof initialValue === 'function') ? (initialValue as () => T)() : initialValue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const setter = (newValue: T | ((prev: T) => T)): void =>
|
|
270
|
+
{
|
|
271
|
+
if (typeof newValue === 'function')
|
|
272
|
+
{
|
|
273
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
274
|
+
state.values[index] = (newValue as (prev: T) => T)(state.values[index] as T);
|
|
275
|
+
}
|
|
276
|
+
else
|
|
277
|
+
{
|
|
278
|
+
state.values[index] = newValue;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
283
|
+
return [state.values[index] as T, setter];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* React to state changes.
|
|
288
|
+
*/
|
|
289
|
+
export function useEffect(callback: () => void | (() => void), deps?: unknown[]): void
|
|
290
|
+
{
|
|
291
|
+
const index = validateHookCall('useEffect');
|
|
292
|
+
const state = getHookState(_getCurrentEntityId()!);
|
|
293
|
+
|
|
294
|
+
const oldEffect = state.effects[index];
|
|
295
|
+
const hasChanged = !oldEffect || !deps ||
|
|
296
|
+
deps.length !== oldEffect.deps?.length ||
|
|
297
|
+
!deps.every((dep, i) => dep === oldEffect.deps?.[i]);
|
|
298
|
+
|
|
299
|
+
if (hasChanged)
|
|
300
|
+
{
|
|
301
|
+
// Cleanup old effect if it exists
|
|
302
|
+
if (oldEffect?.cleanup)
|
|
303
|
+
{
|
|
304
|
+
oldEffect.cleanup();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Run new effect
|
|
308
|
+
const cleanup = callback();
|
|
309
|
+
state.effects[index] = {
|
|
310
|
+
callback,
|
|
311
|
+
deps,
|
|
312
|
+
cleanup: typeof cleanup === 'function' ? cleanup : undefined,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Memoize expensive calculations.
|
|
319
|
+
*/
|
|
320
|
+
export function useMemo<T>(factory: () => T, deps?: unknown[]): T
|
|
321
|
+
{
|
|
322
|
+
const index = validateHookCall('useMemo');
|
|
323
|
+
const state = getHookState(_getCurrentEntityId()!);
|
|
324
|
+
|
|
325
|
+
const oldMemo = state.memos[index];
|
|
326
|
+
const hasChanged = !oldMemo || !deps ||
|
|
327
|
+
deps.length !== oldMemo.deps?.length ||
|
|
328
|
+
!deps.every((dep, i) => dep === oldMemo.deps?.[i]);
|
|
329
|
+
|
|
330
|
+
if (hasChanged)
|
|
331
|
+
{
|
|
332
|
+
const value = factory();
|
|
333
|
+
state.memos[index] = {
|
|
334
|
+
value,
|
|
335
|
+
deps,
|
|
336
|
+
};
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
341
|
+
return oldMemo.value as T;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Mutable reference that persists across ticks.
|
|
346
|
+
* Unlike useState, mutations don't need a setter - just modify .current directly.
|
|
347
|
+
*/
|
|
348
|
+
export interface RefObject<T>
|
|
349
|
+
{
|
|
350
|
+
current: T;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function useRef<T>(initialValue: T): RefObject<T>
|
|
354
|
+
{
|
|
355
|
+
const index = validateHookCall('useRef');
|
|
356
|
+
const state = getHookState(_getCurrentEntityId()!);
|
|
357
|
+
|
|
358
|
+
if (state.values.length <= index)
|
|
359
|
+
{
|
|
360
|
+
state.values[index] = {current: initialValue};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
364
|
+
return state.values[index] as RefObject<T>;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Clear hook state for an entity (e.g., when it dies).
|
|
369
|
+
*/
|
|
370
|
+
export function clearHooks(entityId: string): void
|
|
371
|
+
{
|
|
372
|
+
const state = entityHooks.get(entityId);
|
|
373
|
+
if (state)
|
|
374
|
+
{
|
|
375
|
+
state.effects.forEach((effect) =>
|
|
376
|
+
{
|
|
377
|
+
if (effect.cleanup)
|
|
378
|
+
{
|
|
379
|
+
effect.cleanup();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
entityHooks.delete(entityId);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Reset all hook states (e.g., when a match restarts).
|
|
388
|
+
*/
|
|
389
|
+
export function resetAllHooks(): void
|
|
390
|
+
{
|
|
391
|
+
entityHooks.forEach((_, entityId) => clearHooks(entityId));
|
|
392
|
+
entityHooks.clear();
|
|
393
|
+
}
|
|
394
|
+
|