@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.
- package/dist/{chunk-OLGKLCCB.js → chunk-KGRG7TZS.js} +253 -32
- package/dist/chunk-KGRG7TZS.js.map +1 -0
- package/dist/{index-browser-BLioGWvO.d.ts → index-browser-Dc-Vl1HI.d.ts} +152 -19
- 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 +199 -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 +202 -10
- package/src/engine/spells.ts +13 -1
- package/src/engine-version.ts +1 -1
- package/src/hooks/action-builders.ts +132 -5
- package/src/hooks/state-hooks.ts +413 -407
- package/src/hooks/threat-analysis.ts +86 -25
- package/src/hooks/types.ts +2 -1
- package/src/rules.ts +8 -0
- package/src/types.ts +11 -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
|
{
|
|
@@ -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))
|
|
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
|
-
|
|
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
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/engine/spells.ts
CHANGED
|
@@ -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(
|
|
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);
|
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 = 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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
}
|