@vibemancer/core 1.0.10 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,7 @@ import {runWithHooks, resetAllHooks, clearHooks} from './hooks-runtime.js';
32
32
  import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
33
33
  import {createBudgetState, createFightBudget, recordSpend, mayAct, type BotBudgetState, type BudgetLimits, type FightBudget} from './bot-compute-budget.js';
34
34
  import {createRandom, createEntitySeed} from '../utils/random.js';
35
+ import {describeThrown, cappedNotice, MAX_ERRORS_PER_MATCH} from './bot-error-capture.js';
35
36
 
36
37
  export interface InternalWizardState extends WizardState
37
38
  {
@@ -175,11 +176,14 @@ export function tick(
175
176
  projectiles: ProjectileState[];
176
177
  events: SimEvent[];
177
178
  errors: BotError[];
179
+ budgetExhausted: BudgetExhaustion[];
178
180
  budgets?: [BotBudgetState, BotBudgetState];
179
181
  }
180
182
  {
181
183
  const nextTick = currentTick + 1;
182
184
  const errors: BotError[] = [];
185
+ const budgetExhausted: BudgetExhaustion[] = [];
186
+
183
187
  const events: SimEvent[] = [];
184
188
 
185
189
  // 1. Run wizard AIs with isolated random generators
@@ -215,7 +219,17 @@ export function tick(
215
219
  const state = nextBudgets?.[index];
216
220
 
217
221
  // An exhausted bot is not called at all, so its remaining ticks are free.
218
- if (state && !mayAct(state)) return IDLE_ACTION();
222
+ if (state && !mayAct(state))
223
+ {
224
+ // Reported once, on the tick it is first refused. The bot is skipped for thousands
225
+ // of ticks afterwards, and thousands of identical records is not a report.
226
+ if (!state.reported)
227
+ {
228
+ state.reported = true;
229
+ budgetExhausted.push({tick: nextTick, entityId, spentMs: state.spentMs});
230
+ }
231
+ return IDLE_ACTION();
232
+ }
219
233
 
220
234
  const startedAt = state ? Date.now() : 0;
221
235
  let action: WizardActions;
@@ -225,7 +239,13 @@ export function tick(
225
239
  }
226
240
  catch(e)
227
241
  {
228
- errors.push({tick: nextTick, entityId, message: e instanceof Error ? e.message : String(e)});
242
+ const alreadyDead = (wizards[index]?.health ?? 1) <= 0;
243
+ errors.push({
244
+ tick: nextTick,
245
+ entityId,
246
+ message: describeThrown(e),
247
+ ...(alreadyDead ? {afterDeath: true} : {}),
248
+ });
229
249
  action = IDLE_ACTION();
230
250
  }
231
251
 
@@ -356,12 +376,24 @@ export function tick(
356
376
  }
357
377
  else if (spell === 'blink' && wizard.blinkTarget)
358
378
  {
359
- // Clamp blink target to RULES.BLINK_RANGE from current position
360
- const dx = wizard.blinkTarget.x - wizard.position.x;
361
- const dy = wizard.blinkTarget.y - wizard.position.y;
379
+ // A bot's coordinates are untrusted input, exactly like its move vector.
380
+ //
381
+ // blink(NaN, NaN) used to make the wizard's position NaN — isInLava reads
382
+ // that as false so nobody dies, and resolveWizardCollision then divided by
383
+ // a NaN distance and wrote NaN into the OPPONENT's position too. Both
384
+ // wizards froze and every match was a silent draw. calculateBlinkCooldown
385
+ // went NaN with it, and `NaN > 0` is false, so the cooldown gate stopped
386
+ // working as well.
387
+ //
388
+ // moveWizard was hardened for this and blink was not, so the same one-line
389
+ // exploit just moved to the other door. A non-finite target blinks NOWHERE.
390
+ const targetX = Number.isFinite(wizard.blinkTarget.x) ? wizard.blinkTarget.x : wizard.position.x;
391
+ const targetY = Number.isFinite(wizard.blinkTarget.y) ? wizard.blinkTarget.y : wizard.position.y;
392
+ const dx = targetX - wizard.position.x;
393
+ const dy = targetY - wizard.position.y;
362
394
  const distance = Math.sqrt(dx * dx + dy * dy);
363
395
 
364
- let targetPos = wizard.blinkTarget;
396
+ let targetPos = {x: targetX, y: targetY};
365
397
  if (distance > RULES.BLINK_RANGE)
366
398
  {
367
399
  // Clamp to max range in the same direction
@@ -558,7 +590,7 @@ export function tick(
558
590
  }
559
591
  catch(e)
560
592
  {
561
- errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
593
+ errors.push({tick: nextTick, entityId: projectile.id, message: describeThrown(e)});
562
594
  }
563
595
 
564
596
  targetAngle = missileActions.turnToward
@@ -569,7 +601,13 @@ export function tick(
569
601
  }
570
602
  } // end else (not guided)
571
603
 
572
- if (targetAngle !== null)
604
+ // A missile AI's steering is untrusted input too. `turnToward(NaN, NaN)` sent a NaN
605
+ // angle into angleDiff, so the projectile's rotation and position went NaN and it
606
+ // could never hit anything — and nothing was reported, because returning nonsense is
607
+ // not throwing. A bot doing this LOST silently; the same bot throwing got a full
608
+ // report. Ignoring the instruction means the missile flies straight, which is what a
609
+ // missile with no steering does.
610
+ if (targetAngle !== null && Number.isFinite(targetAngle))
573
611
  {
574
612
  const diff = angleDiff(projectile.rotation, targetAngle);
575
613
  const turn = Math.max(-projectile.turnRate, Math.min(projectile.turnRate, diff));
@@ -668,6 +706,7 @@ export function tick(
668
706
  projectiles: remainingProjectiles,
669
707
  events,
670
708
  errors,
709
+ budgetExhausted,
671
710
  budgets: nextBudgets,
672
711
  };
673
712
  }
@@ -851,11 +890,44 @@ export type FightWinner = 'wizard-1' | 'wizard-2' | 'draw';
851
890
  * Result of a simulation.
852
891
  */
853
892
  /** A runtime error captured from a bot or missile AI function. */
893
+ /**
894
+ * A bot that ran out of its compute budget and stopped being called.
895
+ *
896
+ * Deliberately NOT a BotError. Errors feed `consecutiveCrashes` on the ladder and
897
+ * auto-deactivate a wizard at three; being slow on a loaded server must never cost someone
898
+ * their bot. But it was recorded NOWHERE, so a bot that burned its allowance stood still for
899
+ * the rest of the fight and reported a clean scoreline — measured, a certain 10-0 became 1
900
+ * win and 9 draws with nothing saying why.
901
+ */
902
+ export interface BudgetExhaustion
903
+ {
904
+ entityId: string;
905
+ /** The tick the bot was first refused. */
906
+ tick: number;
907
+ /** How much it had spent when it was cut off, in milliseconds. */
908
+ spentMs: number;
909
+ /** 1-based match index within a fight; set by `fight()`, absent from a lone simulate(). */
910
+ match?: number;
911
+ }
912
+
854
913
  export interface BotError
855
914
  {
856
915
  tick: number;
857
916
  entityId: string;
858
917
  message: string;
918
+ /**
919
+ * True when the wizard was ALREADY DEAD on the tick this was thrown.
920
+ *
921
+ * The engine keeps calling a bot after its health reaches zero and discards the action, so
922
+ * these errors change nothing. Unmarked they are actively misleading in two ways: a player
923
+ * sees a fault spanning hundreds of ticks with no hint the wizard was dead for all of
924
+ * them, and the ladder counts them toward consecutiveCrashes — so a bot can be
925
+ * auto-deactivated for errors the guide itself calls harmless.
926
+ *
927
+ * The engine is the only place that knows, so it is recorded here rather than guessed
928
+ * downstream from a reconstructed death tick.
929
+ */
930
+ afterDeath?: boolean;
859
931
  /**
860
932
  * 1-based index within the fight's TEN matches, present only on `FightResult.allErrors`.
861
933
  *
@@ -876,6 +948,15 @@ export interface SimulateResult
876
948
  history: GameState[];
877
949
  /** Runtime errors thrown by bot or missile AI functions (empty if no errors). */
878
950
  errors: BotError[];
951
+ /**
952
+ * Bots that ran out of compute and stopped being called (empty if nobody did).
953
+ *
954
+ * Separate from `errors` on purpose: errors feed consecutiveCrashes and deactivate a
955
+ * wizard at three, and being slow must never do that. But it has to be reported SOMEWHERE
956
+ * — a bot cut off in match 1 stands still for the rest of the fight, and without this the
957
+ * player sees a clean scoreline and rewrites a strategy that was never the problem.
958
+ */
959
+ budgetExhausted: BudgetExhaustion[];
879
960
  }
880
961
 
881
962
  /**
@@ -906,6 +987,14 @@ export interface FightResult
906
987
  * raw would blame each side's faults on the other.
907
988
  */
908
989
  allErrors: BotError[];
990
+ /**
991
+ * Bots cut off by the compute budget, across all ten matches.
992
+ *
993
+ * The budget is FIGHT-scoped, so this is where it belongs: exhausting it in match 1
994
+ * freezes the bot for the other nine. Ids are in the caller's frame, mirrored out of the
995
+ * swapped matches like allErrors.
996
+ */
997
+ budgetExhausted: BudgetExhaustion[];
909
998
  }
910
999
 
911
1000
  /**
@@ -956,6 +1045,7 @@ export function fight(
956
1045
  {
957
1046
  const matches: SimulateResult[] = [];
958
1047
  const allErrors: BotError[] = [];
1048
+ const allBudgetExhausted: BudgetExhaustion[] = [];
959
1049
  // 1..10 in the order matches are played, so the number a player is shown is the number
960
1050
  // every tool description promises.
961
1051
  let matchNumber = 1;
@@ -978,6 +1068,7 @@ export function fight(
978
1068
 
979
1069
  matches.push(result);
980
1070
  allErrors.push(...result.errors.map((e) => ({...e, match: matchNumber})));
1071
+ allBudgetExhausted.push(...result.budgetExhausted.map((b) => ({...b, match: matchNumber})));
981
1072
  matchNumber++;
982
1073
 
983
1074
  if (result.winner === 'wizard-1')
@@ -1021,6 +1112,7 @@ export function fight(
1021
1112
  // into the caller's frame first: this match ran bot 2 as "wizard-1", so passing the
1022
1113
  // records through raw would report each bot's faults against the other.
1023
1114
  allErrors.push(...swapped.errors.map((e) => ({...e, entityId: swapSide(e.entityId), match: matchNumber})));
1115
+ allBudgetExhausted.push(...swapped.budgetExhausted.map((b) => ({...b, entityId: swapSide(b.entityId), match: matchNumber})));
1024
1116
  matchNumber++;
1025
1117
 
1026
1118
  // Don't push swapped match to matches array (it's only for scoring)
@@ -1043,7 +1135,7 @@ export function fight(
1043
1135
  : wizard2Wins > wizard1Wins ? 'wizard-2'
1044
1136
  : 'draw';
1045
1137
 
1046
- return {wizard1Wins, wizard2Wins, draws, winner, matches, allErrors};
1138
+ return {wizard1Wins, wizard2Wins, draws, winner, matches, allErrors, budgetExhausted: allBudgetExhausted};
1047
1139
  }
1048
1140
 
1049
1141
  /**
@@ -1145,6 +1237,7 @@ export function simulate(
1145
1237
  let projectiles: ProjectileState[] = [];
1146
1238
  const missileAIs = new Map<string, MissileFunction>();
1147
1239
  const allErrors: BotError[] = [];
1240
+ const allBudgetExhausted: BudgetExhaustion[] = [];
1148
1241
 
1149
1242
  let deathTick: number | null = null;
1150
1243
 
@@ -1171,7 +1264,23 @@ export function simulate(
1171
1264
  currentTick = result.nextTick;
1172
1265
  wizards = result.wizards;
1173
1266
  projectiles = result.projectiles;
1174
- if (result.errors.length > 0) allErrors.push(...result.errors);
1267
+ // Bounded on purpose. This array lives for the whole match, so leaving it unbounded
1268
+ // lets a bot choose how much memory the engine allocates — and a ~2KB message thrown
1269
+ // every tick genuinely exhausts the isolate, after which the failure surfaces as an
1270
+ // infrastructure fault and, on the ladder, is blamed on nobody or on the opponent.
1271
+ for (const error of result.errors)
1272
+ {
1273
+ if (allErrors.length > MAX_ERRORS_PER_MATCH) break;
1274
+ if (allErrors.length === MAX_ERRORS_PER_MATCH)
1275
+ {
1276
+ // Announce the cap; going quiet here would recreate the silent failure that
1277
+ // surfacing bot errors existed to fix.
1278
+ allErrors.push(cappedNotice(error.entityId, error.tick));
1279
+ break;
1280
+ }
1281
+ allErrors.push(error);
1282
+ }
1283
+ if (result.budgetExhausted.length > 0) allBudgetExhausted.push(...result.budgetExhausted);
1175
1284
 
1176
1285
  if (!skipHistory)
1177
1286
  {
@@ -1216,5 +1325,6 @@ export function simulate(
1216
1325
  finalState,
1217
1326
  history: skipHistory ? [] : history,
1218
1327
  errors: allErrors,
1328
+ budgetExhausted: allBudgetExhausted,
1219
1329
  };
1220
1330
  }
@@ -9,7 +9,7 @@
9
9
  * Used to gate spectator replays: a recorded match can only be re-simulated when
10
10
  * the runtime engine version matches the version that produced the match.
11
11
  */
12
- export const ENGINE_VERSION = 293192982913104;
12
+ export const ENGINE_VERSION = 2983169471988242;
13
13
 
14
14
  /**
15
15
  * Historical versions that denote the SAME engine as `ENGINE_VERSION`.
@@ -90,7 +90,7 @@ export function shield(): ActionBuilder
90
90
  * Cast a missile spell.
91
91
  *
92
92
  * Cast time scales with damage, speed, duration, and turn rate — bigger missiles
93
- * take longer to cast. While casting you move at 50% speed. After firing, 100-tick
93
+ * take longer to cast. While casting you move at 33% speed. After firing, 100-tick
94
94
  * (1s) GCD before next spell.
95
95
  *
96
96
  * Repeated similar missiles cast 20% faster (warmup bonus). Switching styles
@@ -158,13 +158,16 @@ export function aim(degrees: number): ActionBuilder
158
158
  *
159
159
  * Max range: 300 units from current position (clamped by engine if further).
160
160
  * Cast time: 10 ticks (0.1s). Cooldown scales with distance:
161
- * - 100 units → 100 ticks (1s)
161
+ * - 10 units → 100 ticks (1s); 100 units → 667 ticks; 150 → 1000 (10s); 300 → 2000 (20s).
162
+ * The cooldown scales with DISTANCE — a micro-blink costs a second, a full-range one
163
+ * costs twenty. This line used to claim 100 units → 100 ticks, understating it 6.7x.
162
164
  * - 300 units → 2000 ticks (20s)
163
165
  *
164
166
  * Cannot chain .move() — blink IS the movement.
165
167
  *
166
- * @param x - Target X position (0-800, absolute world coordinate)
167
- * @param y - Target Y position (0-800, absolute world coordinate)
168
+ * @param x - Target X position (absolute world coordinate; arena is 0-860, and the
169
+ * survivable playfield is [30, 830] outside that band is lava)
170
+ * @param y - Target Y position (absolute world coordinate; see x)
168
171
  *
169
172
  * @example
170
173
  * return blink(400, 400); // blink to center
@@ -295,5 +298,109 @@ export function extractMissileAction(action: MissileAction): MissileActions
295
298
  */
296
299
  export function extractAction(finalAction: FinalAction): WizardActions
297
300
  {
298
- return finalAction._toAction();
301
+ // Checked rather than assumed, because the failure message is what a player debugs from.
302
+ //
303
+ // This used to be a bare `finalAction._toAction()`, so returning undefined gave "Cannot
304
+ // read properties of undefined (reading '_toAction')" and returning a plain object gave
305
+ // "finalAction._toAction is not a function". `_toAction` is internal and appears nowhere
306
+ // in the player-facing API, so the message named the one thing they cannot look up.
307
+ //
308
+ // Returning `{move: {x: 1, y: 0}}` is the most common version of this mistake — it is
309
+ // exactly the shape of WizardActions — which made the likeliest error the least useful.
310
+ if (finalAction === null || finalAction === undefined || typeof finalAction._toAction !== 'function')
311
+ {
312
+ // Named specifically rather than by bare typeof: "a object" is both ungrammatical and
313
+ // unhelpful, and the realistic version of this mistake is `async function MyBot()`,
314
+ // where the value is a Promise and the fix is to remove the `async`.
315
+ const describeReturned = (value: unknown): string =>
316
+ {
317
+ if (value === null) return 'null';
318
+ if (value === undefined) return 'undefined';
319
+ if (Array.isArray(value)) return 'an array';
320
+ if (typeof value === 'object')
321
+ {
322
+ if ('then' in value && typeof value.then === 'function')
323
+ {
324
+ return 'a Promise — your bot function is `async`, and it must not be: remove the async keyword';
325
+ }
326
+ return 'a plain object';
327
+ }
328
+ return `a ${typeof value}`;
329
+ };
330
+ const got = describeReturned(finalAction);
331
+ throw new Error(
332
+ `Your bot returned ${got}. Return one of: idle(), move(x, y), missile(config, ai, angle), `
333
+ + 'shield(), blink(x, y), cancel() or aim(degrees). '
334
+ + 'A bot must return an action every tick — returning nothing throws, it does not skip the tick.',
335
+ );
336
+ }
337
+ // Read every number the engine will later use, HERE — inside the bot's own try/catch.
338
+ //
339
+ // A bot can hand back an object with a throwing `valueOf`, and the engine reads those
340
+ // coordinates later, outside runBot's try/catch: `wizard.blinkTarget.x - position.x` at
341
+ // blink completion. The throw then escaped tick -> simulate -> fight and surfaced as a
342
+ // bare fight failure with NO bot named. Two things followed, both serious:
343
+ //
344
+ // - the ladder blamed whichever wizard happened to be PICKED, giving an innocent bot a
345
+ // loss, a lastError and a crash strike toward deactivation;
346
+ // - the message was forgeable. `throw new Error('Script execution timed out')` from a
347
+ // valueOf never passes through the harness, so it matched the INFRASTRUCTURE markers
348
+ // and the ladder SKIPPED the match — an unlosable bot — while the fight tool printed a
349
+ // confident explanation about infinite loops for a bot containing no loop.
350
+ //
351
+ // Forcing the conversion now means a hostile valueOf throws where every other bot fault
352
+ // throws: inside the try, attributed to the bot that wrote it.
353
+ const action = finalAction._toAction();
354
+
355
+ // The shape was checked and the RESULT was trusted. `{_toAction: () => 'junk'}` therefore
356
+ // passed straight through: ten draws, zero errors, nothing reported — the same silent
357
+ // failure this area exists to remove, one level further in. An action without a `move` is
358
+ // not an action, whatever produced it.
359
+ if (action === null || typeof action !== 'object' || typeof (action as WizardActions).move !== 'object'
360
+ || (action as WizardActions).move === null)
361
+ {
362
+ throw new Error(
363
+ 'Your bot returned something that is not a valid action. Build actions with the provided '
364
+ + 'functions — idle(), move(x, y), missile(config, ai, angle), shield(), blink(x, y), '
365
+ + 'cancel() or aim(degrees) — rather than constructing the object yourself.',
366
+ );
367
+ }
368
+
369
+ return normaliseActionNumbers(action);
370
+ }
371
+
372
+ /**
373
+ * Coerce an action's numeric fields eagerly, so lazy getters cannot fire inside the engine.
374
+ *
375
+ * Deliberately NOT a validation pass — invalid values are still handled where they always
376
+ * were (moveWizard, the blink guard, validateMissileConfig). This only decides WHEN the
377
+ * numbers are read, and therefore who gets blamed when reading one throws.
378
+ */
379
+ function normaliseActionNumbers(action: WizardActions): WizardActions
380
+ {
381
+ const num = (value: unknown): number => Number(value);
382
+
383
+ const normalised: WizardActions = {
384
+ ...action,
385
+ move: {x: num(action.move?.x), y: num(action.move?.y)},
386
+ };
387
+
388
+ if (action.aimDirection !== undefined) normalised.aimDirection = num(action.aimDirection);
389
+
390
+ if (action.startCast?.spell === 'missile')
391
+ {
392
+ normalised.startCast = {
393
+ ...action.startCast,
394
+ ...(action.startCast.direction !== undefined ? {direction: num(action.startCast.direction)} : {}),
395
+ };
396
+ }
397
+ else if (action.startCast?.spell === 'blink')
398
+ {
399
+ normalised.startCast = {
400
+ ...action.startCast,
401
+ target: {x: num(action.startCast.target?.x), y: num(action.startCast.target?.y)},
402
+ };
403
+ }
404
+
405
+ return normalised;
299
406
  }