@vibemancer/core 1.0.10 → 1.0.12

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
  {
@@ -159,6 +160,33 @@ function buildWizardContext(state: GameState, config: GameConfig, random: () =>
159
160
  /**
160
161
  * Process one game tick.
161
162
  */
163
+
164
+ /**
165
+ * Name the first non-finite number a bot handed the engine this tick, or null.
166
+ *
167
+ * Checks the three doors that were each found separately: the move vector, the blink target,
168
+ * and the aim angle. Returns WHICH field so the message can point at the player's own call
169
+ * rather than saying "something went wrong".
170
+ */
171
+ function nonFiniteField(
172
+ move: {x?: number; y?: number},
173
+ action: {startCast?: {spell?: string; target?: {x?: number; y?: number}}; aimDirection?: number},
174
+ wizard: {blinkTarget?: {x?: number; y?: number}},
175
+ ): {what: string; value: string} | null
176
+ {
177
+ const bad = (v: unknown): boolean => typeof v === 'number' && !Number.isFinite(v);
178
+ if (bad(move?.x)) return {what: 'move(x, y): x', value: String(move.x)};
179
+ if (bad(move?.y)) return {what: 'move(x, y): y', value: String(move.y)};
180
+
181
+ const target = action.startCast?.spell === 'blink' ? action.startCast.target : undefined;
182
+ if (bad(target?.x)) return {what: 'blink(x, y): x', value: String(target!.x)};
183
+ if (bad(target?.y)) return {what: 'blink(x, y): y', value: String(target!.y)};
184
+ if (bad(wizard.blinkTarget?.x)) return {what: 'blink(x, y): x', value: String(wizard.blinkTarget!.x)};
185
+ if (bad(wizard.blinkTarget?.y)) return {what: 'blink(x, y): y', value: String(wizard.blinkTarget!.y)};
186
+
187
+ if (bad(action.aimDirection)) return {what: 'aim(degrees)', value: String(action.aimDirection)};
188
+ return null;
189
+ }
162
190
  export function tick(
163
191
  currentTick: number,
164
192
  wizard1AI: WizardFunction,
@@ -175,11 +203,14 @@ export function tick(
175
203
  projectiles: ProjectileState[];
176
204
  events: SimEvent[];
177
205
  errors: BotError[];
206
+ budgetExhausted: BudgetExhaustion[];
178
207
  budgets?: [BotBudgetState, BotBudgetState];
179
208
  }
180
209
  {
181
210
  const nextTick = currentTick + 1;
182
211
  const errors: BotError[] = [];
212
+ const budgetExhausted: BudgetExhaustion[] = [];
213
+
183
214
  const events: SimEvent[] = [];
184
215
 
185
216
  // 1. Run wizard AIs with isolated random generators
@@ -215,7 +246,17 @@ export function tick(
215
246
  const state = nextBudgets?.[index];
216
247
 
217
248
  // An exhausted bot is not called at all, so its remaining ticks are free.
218
- if (state && !mayAct(state)) return IDLE_ACTION();
249
+ if (state && !mayAct(state))
250
+ {
251
+ // Reported once, on the tick it is first refused. The bot is skipped for thousands
252
+ // of ticks afterwards, and thousands of identical records is not a report.
253
+ if (!state.reported)
254
+ {
255
+ state.reported = true;
256
+ budgetExhausted.push({tick: nextTick, entityId, spentMs: state.spentMs});
257
+ }
258
+ return IDLE_ACTION();
259
+ }
219
260
 
220
261
  const startedAt = state ? Date.now() : 0;
221
262
  let action: WizardActions;
@@ -225,7 +266,13 @@ export function tick(
225
266
  }
226
267
  catch(e)
227
268
  {
228
- errors.push({tick: nextTick, entityId, message: e instanceof Error ? e.message : String(e)});
269
+ const alreadyDead = (wizards[index]?.health ?? 1) <= 0;
270
+ errors.push({
271
+ tick: nextTick,
272
+ entityId,
273
+ message: describeThrown(e),
274
+ ...(alreadyDead ? {afterDeath: true} : {}),
275
+ });
229
276
  action = IDLE_ACTION();
230
277
  }
231
278
 
@@ -356,12 +403,24 @@ export function tick(
356
403
  }
357
404
  else if (spell === 'blink' && wizard.blinkTarget)
358
405
  {
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;
406
+ // A bot's coordinates are untrusted input, exactly like its move vector.
407
+ //
408
+ // blink(NaN, NaN) used to make the wizard's position NaN — isInLava reads
409
+ // that as false so nobody dies, and resolveWizardCollision then divided by
410
+ // a NaN distance and wrote NaN into the OPPONENT's position too. Both
411
+ // wizards froze and every match was a silent draw. calculateBlinkCooldown
412
+ // went NaN with it, and `NaN > 0` is false, so the cooldown gate stopped
413
+ // working as well.
414
+ //
415
+ // moveWizard was hardened for this and blink was not, so the same one-line
416
+ // exploit just moved to the other door. A non-finite target blinks NOWHERE.
417
+ const targetX = Number.isFinite(wizard.blinkTarget.x) ? wizard.blinkTarget.x : wizard.position.x;
418
+ const targetY = Number.isFinite(wizard.blinkTarget.y) ? wizard.blinkTarget.y : wizard.position.y;
419
+ const dx = targetX - wizard.position.x;
420
+ const dy = targetY - wizard.position.y;
362
421
  const distance = Math.sqrt(dx * dx + dy * dy);
363
422
 
364
- let targetPos = wizard.blinkTarget;
423
+ let targetPos = {x: targetX, y: targetY};
365
424
  if (distance > RULES.BLINK_RANGE)
366
425
  {
367
426
  // Clamp to max range in the same direction
@@ -398,6 +457,35 @@ export function tick(
398
457
  // Handle movement (default to no movement if bot didn't provide move)
399
458
  const oldPos = wizard.position;
400
459
  const move = action.move ?? {x: 0, y: 0};
460
+
461
+ // The non-finite guards work, and were completely silent. A bot that divides by zero
462
+ // somewhere just stops moving; the guard saves the match but tells the author nothing,
463
+ // which is the same "looks passive, is broken" failure as an unreported throw — and
464
+ // reached by accident rather than by anything hostile.
465
+ //
466
+ // Reporting deliberately does NOT change behaviour: the clamp still stands and the
467
+ // match plays out identically. Throwing instead would alter results for bots already
468
+ // on the ladder and could feed crash-deactivation, which is far too large a
469
+ // consequence to attach to a diagnostic.
470
+ // The flag lives on the WIZARD, not in a Set here: tick() runs afresh every frame, so a
471
+ // Set declared inside it deduped nothing and the first version of this reported the
472
+ // same paragraph 500 times, straight into the error cap.
473
+ if (!wizard.advised?.nonFinite)
474
+ {
475
+ const bad = nonFiniteField(move, action, wizard);
476
+ if (bad)
477
+ {
478
+ wizard.advised = {...wizard.advised, nonFinite: true};
479
+ errors.push({
480
+ tick: nextTick,
481
+ entityId: wizard.id,
482
+ message: `${bad.what} is not a finite number (it was ${bad.value}). The engine ignored it `
483
+ + 'to keep the match running — the value is treated as zero, so your wizard simply does '
484
+ + 'not do what you asked. This is almost always a divide-by-zero or a subtraction of '
485
+ + 'two equal positions somewhere in your maths. Reported once per match.',
486
+ });
487
+ }
488
+ }
401
489
  wizard.position = moveWizard(wizard, move, 1);
402
490
 
403
491
  // Process delayed knockback (apply impulse after delay expires)
@@ -468,6 +556,32 @@ export function tick(
468
556
  startCast(wizard, castingSpell, castingSpell === 'missile' ? action.startCast.config : undefined);
469
557
  if (castingSpell === 'missile')
470
558
  {
559
+ // A cast longer than the whole match can never complete, so the wizard
560
+ // stands still until time runs out and NOTHING is reported. Every field is
561
+ // clamped by validateMissileConfig — nothing here is NaN — but turnRate is
562
+ // clamped only to `>= 0`, and turnRate 9999 prices the cast at 130,017
563
+ // ticks against a 30,000-tick match. Measured against TargetDummy, a bot
564
+ // that does nothing at all, the result was ten draws: the bot could not
565
+ // even lose in a way that hinted at the cause.
566
+ //
567
+ // The cast is still allowed — refusing it would silently change what bots
568
+ // do, and the defect was never the cast, it was the silence.
569
+ if ((wizard.castDuration ?? 0) > MATCH_DURATION && !wizard.advised?.unfinishableCast)
570
+ {
571
+ wizard.advised = {...wizard.advised, unfinishableCast: true};
572
+ const config = action.startCast.config;
573
+ errors.push({
574
+ tick: nextTick,
575
+ entityId: wizard.id,
576
+ message: `This missile takes ${wizard.castDuration} ticks to cast, but a match `
577
+ + `is only ${MATCH_DURATION} ticks long, so the cast can never finish and this `
578
+ + 'wizard will stand still for the rest of the match. The cost is dominated by '
579
+ + `turnRate (${config?.turnRate ?? '?'}) — it is the most expensive field by far, and `
580
+ + 'unlike the others it is not clamped to a sane maximum. Try a turnRate in the '
581
+ + 'single digits, then use missile-calc or getMissileCastTime() to check the cast '
582
+ + 'time before committing to a config.',
583
+ });
584
+ }
471
585
  wizard.missileConfig = action.startCast.config;
472
586
  wizard.missileAI = action.startCast.missileAI;
473
587
  if (action.startCast.direction !== undefined)
@@ -558,7 +672,7 @@ export function tick(
558
672
  }
559
673
  catch(e)
560
674
  {
561
- errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
675
+ errors.push({tick: nextTick, entityId: projectile.id, message: describeThrown(e)});
562
676
  }
563
677
 
564
678
  targetAngle = missileActions.turnToward
@@ -569,7 +683,13 @@ export function tick(
569
683
  }
570
684
  } // end else (not guided)
571
685
 
572
- if (targetAngle !== null)
686
+ // A missile AI's steering is untrusted input too. `turnToward(NaN, NaN)` sent a NaN
687
+ // angle into angleDiff, so the projectile's rotation and position went NaN and it
688
+ // could never hit anything — and nothing was reported, because returning nonsense is
689
+ // not throwing. A bot doing this LOST silently; the same bot throwing got a full
690
+ // report. Ignoring the instruction means the missile flies straight, which is what a
691
+ // missile with no steering does.
692
+ if (targetAngle !== null && Number.isFinite(targetAngle))
573
693
  {
574
694
  const diff = angleDiff(projectile.rotation, targetAngle);
575
695
  const turn = Math.max(-projectile.turnRate, Math.min(projectile.turnRate, diff));
@@ -668,6 +788,7 @@ export function tick(
668
788
  projectiles: remainingProjectiles,
669
789
  events,
670
790
  errors,
791
+ budgetExhausted,
671
792
  budgets: nextBudgets,
672
793
  };
673
794
  }
@@ -851,11 +972,44 @@ export type FightWinner = 'wizard-1' | 'wizard-2' | 'draw';
851
972
  * Result of a simulation.
852
973
  */
853
974
  /** A runtime error captured from a bot or missile AI function. */
975
+ /**
976
+ * A bot that ran out of its compute budget and stopped being called.
977
+ *
978
+ * Deliberately NOT a BotError. Errors feed `consecutiveCrashes` on the ladder and
979
+ * auto-deactivate a wizard at three; being slow on a loaded server must never cost someone
980
+ * their bot. But it was recorded NOWHERE, so a bot that burned its allowance stood still for
981
+ * the rest of the fight and reported a clean scoreline — measured, a certain 10-0 became 1
982
+ * win and 9 draws with nothing saying why.
983
+ */
984
+ export interface BudgetExhaustion
985
+ {
986
+ entityId: string;
987
+ /** The tick the bot was first refused. */
988
+ tick: number;
989
+ /** How much it had spent when it was cut off, in milliseconds. */
990
+ spentMs: number;
991
+ /** 1-based match index within a fight; set by `fight()`, absent from a lone simulate(). */
992
+ match?: number;
993
+ }
994
+
854
995
  export interface BotError
855
996
  {
856
997
  tick: number;
857
998
  entityId: string;
858
999
  message: string;
1000
+ /**
1001
+ * True when the wizard was ALREADY DEAD on the tick this was thrown.
1002
+ *
1003
+ * The engine keeps calling a bot after its health reaches zero and discards the action, so
1004
+ * these errors change nothing. Unmarked they are actively misleading in two ways: a player
1005
+ * sees a fault spanning hundreds of ticks with no hint the wizard was dead for all of
1006
+ * them, and the ladder counts them toward consecutiveCrashes — so a bot can be
1007
+ * auto-deactivated for errors the guide itself calls harmless.
1008
+ *
1009
+ * The engine is the only place that knows, so it is recorded here rather than guessed
1010
+ * downstream from a reconstructed death tick.
1011
+ */
1012
+ afterDeath?: boolean;
859
1013
  /**
860
1014
  * 1-based index within the fight's TEN matches, present only on `FightResult.allErrors`.
861
1015
  *
@@ -876,6 +1030,15 @@ export interface SimulateResult
876
1030
  history: GameState[];
877
1031
  /** Runtime errors thrown by bot or missile AI functions (empty if no errors). */
878
1032
  errors: BotError[];
1033
+ /**
1034
+ * Bots that ran out of compute and stopped being called (empty if nobody did).
1035
+ *
1036
+ * Separate from `errors` on purpose: errors feed consecutiveCrashes and deactivate a
1037
+ * wizard at three, and being slow must never do that. But it has to be reported SOMEWHERE
1038
+ * — a bot cut off in match 1 stands still for the rest of the fight, and without this the
1039
+ * player sees a clean scoreline and rewrites a strategy that was never the problem.
1040
+ */
1041
+ budgetExhausted: BudgetExhaustion[];
879
1042
  }
880
1043
 
881
1044
  /**
@@ -906,6 +1069,14 @@ export interface FightResult
906
1069
  * raw would blame each side's faults on the other.
907
1070
  */
908
1071
  allErrors: BotError[];
1072
+ /**
1073
+ * Bots cut off by the compute budget, across all ten matches.
1074
+ *
1075
+ * The budget is FIGHT-scoped, so this is where it belongs: exhausting it in match 1
1076
+ * freezes the bot for the other nine. Ids are in the caller's frame, mirrored out of the
1077
+ * swapped matches like allErrors.
1078
+ */
1079
+ budgetExhausted: BudgetExhaustion[];
909
1080
  }
910
1081
 
911
1082
  /**
@@ -956,6 +1127,7 @@ export function fight(
956
1127
  {
957
1128
  const matches: SimulateResult[] = [];
958
1129
  const allErrors: BotError[] = [];
1130
+ const allBudgetExhausted: BudgetExhaustion[] = [];
959
1131
  // 1..10 in the order matches are played, so the number a player is shown is the number
960
1132
  // every tool description promises.
961
1133
  let matchNumber = 1;
@@ -978,6 +1150,7 @@ export function fight(
978
1150
 
979
1151
  matches.push(result);
980
1152
  allErrors.push(...result.errors.map((e) => ({...e, match: matchNumber})));
1153
+ allBudgetExhausted.push(...result.budgetExhausted.map((b) => ({...b, match: matchNumber})));
981
1154
  matchNumber++;
982
1155
 
983
1156
  if (result.winner === 'wizard-1')
@@ -1021,6 +1194,7 @@ export function fight(
1021
1194
  // into the caller's frame first: this match ran bot 2 as "wizard-1", so passing the
1022
1195
  // records through raw would report each bot's faults against the other.
1023
1196
  allErrors.push(...swapped.errors.map((e) => ({...e, entityId: swapSide(e.entityId), match: matchNumber})));
1197
+ allBudgetExhausted.push(...swapped.budgetExhausted.map((b) => ({...b, entityId: swapSide(b.entityId), match: matchNumber})));
1024
1198
  matchNumber++;
1025
1199
 
1026
1200
  // Don't push swapped match to matches array (it's only for scoring)
@@ -1043,7 +1217,7 @@ export function fight(
1043
1217
  : wizard2Wins > wizard1Wins ? 'wizard-2'
1044
1218
  : 'draw';
1045
1219
 
1046
- return {wizard1Wins, wizard2Wins, draws, winner, matches, allErrors};
1220
+ return {wizard1Wins, wizard2Wins, draws, winner, matches, allErrors, budgetExhausted: allBudgetExhausted};
1047
1221
  }
1048
1222
 
1049
1223
  /**
@@ -1145,6 +1319,7 @@ export function simulate(
1145
1319
  let projectiles: ProjectileState[] = [];
1146
1320
  const missileAIs = new Map<string, MissileFunction>();
1147
1321
  const allErrors: BotError[] = [];
1322
+ const allBudgetExhausted: BudgetExhaustion[] = [];
1148
1323
 
1149
1324
  let deathTick: number | null = null;
1150
1325
 
@@ -1171,7 +1346,23 @@ export function simulate(
1171
1346
  currentTick = result.nextTick;
1172
1347
  wizards = result.wizards;
1173
1348
  projectiles = result.projectiles;
1174
- if (result.errors.length > 0) allErrors.push(...result.errors);
1349
+ // Bounded on purpose. This array lives for the whole match, so leaving it unbounded
1350
+ // lets a bot choose how much memory the engine allocates — and a ~2KB message thrown
1351
+ // every tick genuinely exhausts the isolate, after which the failure surfaces as an
1352
+ // infrastructure fault and, on the ladder, is blamed on nobody or on the opponent.
1353
+ for (const error of result.errors)
1354
+ {
1355
+ if (allErrors.length > MAX_ERRORS_PER_MATCH) break;
1356
+ if (allErrors.length === MAX_ERRORS_PER_MATCH)
1357
+ {
1358
+ // Announce the cap; going quiet here would recreate the silent failure that
1359
+ // surfacing bot errors existed to fix.
1360
+ allErrors.push(cappedNotice(error.entityId, error.tick));
1361
+ break;
1362
+ }
1363
+ allErrors.push(error);
1364
+ }
1365
+ if (result.budgetExhausted.length > 0) allBudgetExhausted.push(...result.budgetExhausted);
1175
1366
 
1176
1367
  if (!skipHistory)
1177
1368
  {
@@ -1216,5 +1407,6 @@ export function simulate(
1216
1407
  finalState,
1217
1408
  history: skipHistory ? [] : history,
1218
1409
  errors: allErrors,
1410
+ budgetExhausted: allBudgetExhausted,
1219
1411
  };
1220
1412
  }
@@ -4,6 +4,7 @@ import {
4
4
  TICKS_PER_SECOND,
5
5
  calculateMissileCastTime,
6
6
  calculateWarmupMultiplier,
7
+ validateMissileConfig,
7
8
  } from '../rules.js';
8
9
 
9
10
  /**
@@ -19,8 +20,19 @@ export function startCast(wizard: WizardState, spell: 'missile' | 'shield' | 'bl
19
20
  // Stryker disable next-line ConditionalExpression: `true && config` equivalent for valid calls (spell is always correct type)
20
21
  if (spell === 'missile' && config)
21
22
  {
23
+ // VALIDATE FIRST. This used to pass the raw config, and the raw formula returns NaN for
24
+ // a config a beginner can easily type — `{damage: -999, speed: 0, duration: -5}` —
25
+ // because of the powers and divisions inside it. `Math.max(1, Math.round(NaN))` is NaN,
26
+ // not 1, so castDuration became NaN and `castProgress >= castDuration` was never true:
27
+ // the cast could NEVER complete and the wizard stood still for the whole match, silently.
28
+ // Measured against TargetDummy, a bot that does nothing: ten draws, zero errors.
29
+ //
30
+ // getMissileCastTime — the function bots call to PREDICT this exact number — has always
31
+ // validated first, so the engine and its own prediction disagreed for precisely the
32
+ // configs where a player most needed them to agree.
33
+ const validated = validateMissileConfig(config);
22
34
  // calculateMissileCastTime includes warmup when lastMissileConfig is passed
23
- const castTimeSec = calculateMissileCastTime(config, wizard.lastMissileConfig);
35
+ const castTimeSec = calculateMissileCastTime(validated, wizard.lastMissileConfig);
24
36
  wizard.castDuration = Math.max(1, Math.round(castTimeSec * TICKS_PER_SECOND));
25
37
  // Store warmup multiplier for visualization
26
38
  wizard.warmupMultiplier = calculateWarmupMultiplier(wizard.lastMissileConfig, config);
@@ -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 = 4256327861353618;
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
@@ -276,12 +279,47 @@ export function flyStraight(): MissileAction
276
279
  };
277
280
  }
278
281
 
282
+ /**
283
+ * Name what a bot handed back, for an error message it can act on.
284
+ *
285
+ * Named specifically rather than by bare typeof: "a object" is both ungrammatical and
286
+ * unhelpful, and the realistic version of this mistake is `async function MyBot()`, where the
287
+ * value is a Promise and the fix is to remove the `async`.
288
+ */
289
+ function describeReturned(value: unknown): string
290
+ {
291
+ if (value === null) return 'null';
292
+ if (value === undefined) return 'undefined';
293
+ if (Array.isArray(value)) return 'an array';
294
+ if (typeof value === 'object')
295
+ {
296
+ if ('then' in value && typeof (value as {then?: unknown}).then === 'function')
297
+ {
298
+ return 'a Promise — your function is `async`, and it must not be: remove the async keyword';
299
+ }
300
+ return 'a plain object';
301
+ }
302
+ return `a ${typeof value}`;
303
+ }
304
+
279
305
  /**
280
306
  * Extract MissileActions from a MissileAction.
281
307
  * Used by the engine to get the actual missile action.
282
308
  */
283
309
  export function extractMissileAction(action: MissileAction): MissileActions
284
310
  {
311
+ // The wizard-level path got a readable message for a bad return; this one did not, and a
312
+ // missile AI is the SECOND function a player writes. Returning `{}` from it produced
313
+ // "TypeError: action._toMissileAction is not a function" over an engine-internal stack —
314
+ // naming a private field that appears nowhere in the API, 84 times in one match.
315
+ if (action === null || action === undefined || typeof action._toMissileAction !== 'function')
316
+ {
317
+ throw new Error(
318
+ `Your missile AI returned ${describeReturned(action)}. Return turnToward(x, y), `
319
+ + 'turnToAngle(degrees) or flyStraight(). The missile AI runs once per tick for every '
320
+ + 'missile you have in flight, and it must return one of those every time.',
321
+ );
322
+ }
285
323
  return action._toMissileAction();
286
324
  }
287
325
 
@@ -295,5 +333,94 @@ export function extractMissileAction(action: MissileAction): MissileActions
295
333
  */
296
334
  export function extractAction(finalAction: FinalAction): WizardActions
297
335
  {
298
- return finalAction._toAction();
336
+ // Checked rather than assumed, because the failure message is what a player debugs from.
337
+ //
338
+ // This used to be a bare `finalAction._toAction()`, so returning undefined gave "Cannot
339
+ // read properties of undefined (reading '_toAction')" and returning a plain object gave
340
+ // "finalAction._toAction is not a function". `_toAction` is internal and appears nowhere
341
+ // in the player-facing API, so the message named the one thing they cannot look up.
342
+ //
343
+ // Returning `{move: {x: 1, y: 0}}` is the most common version of this mistake — it is
344
+ // exactly the shape of WizardActions — which made the likeliest error the least useful.
345
+ if (finalAction === null || finalAction === undefined || typeof finalAction._toAction !== 'function')
346
+ {
347
+ // Named specifically rather than by bare typeof: "a object" is both ungrammatical and
348
+ // unhelpful, and the realistic version of this mistake is `async function MyBot()`,
349
+ // where the value is a Promise and the fix is to remove the `async`.
350
+ const got = describeReturned(finalAction);
351
+ throw new Error(
352
+ `Your bot returned ${got}. Return one of: idle(), move(x, y), missile(config, ai, angle), `
353
+ + 'shield(), blink(x, y), cancel() or aim(degrees). '
354
+ + 'A bot must return an action every tick — returning nothing throws, it does not skip the tick.',
355
+ );
356
+ }
357
+ // Read every number the engine will later use, HERE — inside the bot's own try/catch.
358
+ //
359
+ // A bot can hand back an object with a throwing `valueOf`, and the engine reads those
360
+ // coordinates later, outside runBot's try/catch: `wizard.blinkTarget.x - position.x` at
361
+ // blink completion. The throw then escaped tick -> simulate -> fight and surfaced as a
362
+ // bare fight failure with NO bot named. Two things followed, both serious:
363
+ //
364
+ // - the ladder blamed whichever wizard happened to be PICKED, giving an innocent bot a
365
+ // loss, a lastError and a crash strike toward deactivation;
366
+ // - the message was forgeable. `throw new Error('Script execution timed out')` from a
367
+ // valueOf never passes through the harness, so it matched the INFRASTRUCTURE markers
368
+ // and the ladder SKIPPED the match — an unlosable bot — while the fight tool printed a
369
+ // confident explanation about infinite loops for a bot containing no loop.
370
+ //
371
+ // Forcing the conversion now means a hostile valueOf throws where every other bot fault
372
+ // throws: inside the try, attributed to the bot that wrote it.
373
+ const action = finalAction._toAction();
374
+
375
+ // The shape was checked and the RESULT was trusted. `{_toAction: () => 'junk'}` therefore
376
+ // passed straight through: ten draws, zero errors, nothing reported — the same silent
377
+ // failure this area exists to remove, one level further in. An action without a `move` is
378
+ // not an action, whatever produced it.
379
+ if (action === null || typeof action !== 'object' || typeof (action as WizardActions).move !== 'object'
380
+ || (action as WizardActions).move === null)
381
+ {
382
+ throw new Error(
383
+ 'Your bot returned something that is not a valid action. Build actions with the provided '
384
+ + 'functions — idle(), move(x, y), missile(config, ai, angle), shield(), blink(x, y), '
385
+ + 'cancel() or aim(degrees) — rather than constructing the object yourself.',
386
+ );
387
+ }
388
+
389
+ return normaliseActionNumbers(action);
390
+ }
391
+
392
+ /**
393
+ * Coerce an action's numeric fields eagerly, so lazy getters cannot fire inside the engine.
394
+ *
395
+ * Deliberately NOT a validation pass — invalid values are still handled where they always
396
+ * were (moveWizard, the blink guard, validateMissileConfig). This only decides WHEN the
397
+ * numbers are read, and therefore who gets blamed when reading one throws.
398
+ */
399
+ function normaliseActionNumbers(action: WizardActions): WizardActions
400
+ {
401
+ const num = (value: unknown): number => Number(value);
402
+
403
+ const normalised: WizardActions = {
404
+ ...action,
405
+ move: {x: num(action.move?.x), y: num(action.move?.y)},
406
+ };
407
+
408
+ if (action.aimDirection !== undefined) normalised.aimDirection = num(action.aimDirection);
409
+
410
+ if (action.startCast?.spell === 'missile')
411
+ {
412
+ normalised.startCast = {
413
+ ...action.startCast,
414
+ ...(action.startCast.direction !== undefined ? {direction: num(action.startCast.direction)} : {}),
415
+ };
416
+ }
417
+ else if (action.startCast?.spell === 'blink')
418
+ {
419
+ normalised.startCast = {
420
+ ...action.startCast,
421
+ target: {x: num(action.startCast.target?.x), y: num(action.startCast.target?.y)},
422
+ };
423
+ }
424
+
425
+ return normalised;
299
426
  }