@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.
- package/dist/{chunk-OLGKLCCB.js → chunk-EO7JO2RZ.js} +199 -24
- package/dist/chunk-EO7JO2RZ.js.map +1 -0
- package/dist/{index-browser-BLioGWvO.d.ts → index-browser-CM0uuzWp.d.ts} +142 -15
- package/dist/index-browser.d.ts +1 -1
- package/dist/index-browser.js +5 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.js +51 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/bot-compute-budget.ts +10 -0
- package/src/engine/bot-error-capture.ts +179 -0
- package/src/engine/manual-match.ts +2 -0
- package/src/engine/physics.ts +76 -0
- package/src/engine/sandbox-harness.ts +46 -0
- package/src/engine/sandbox.ts +347 -341
- package/src/engine/simulation.ts +120 -10
- package/src/engine-version.ts +1 -1
- package/src/hooks/action-builders.ts +112 -5
- package/src/hooks/state-hooks.ts +409 -407
- package/src/hooks/threat-analysis.ts +57 -18
- package/src/hooks/types.ts +2 -1
- package/src/rules.ts +8 -0
- package/src/types.ts +10 -6
- package/src/utils/combat.ts +379 -371
- package/dist/chunk-OLGKLCCB.js.map +0 -1
package/src/engine/simulation.ts
CHANGED
|
@@ -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))
|
|
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
|
-
|
|
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
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/engine-version.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
* -
|
|
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-
|
|
167
|
-
*
|
|
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
|
-
|
|
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
|
}
|