@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.
@@ -213,7 +213,7 @@ function currentRuleset() {
213
213
  }
214
214
 
215
215
  // src/engine-version.ts
216
- var ENGINE_VERSION = 293192982913104;
216
+ var ENGINE_VERSION = 4256327861353618;
217
217
  var EQUIVALENT_ENGINE_VERSIONS = [1688330189011034];
218
218
 
219
219
  // src/replay-compat.ts
@@ -516,11 +516,60 @@ function flyStraight() {
516
516
  _toMissileAction: () => ({})
517
517
  };
518
518
  }
519
+ function describeReturned(value) {
520
+ if (value === null) return "null";
521
+ if (value === void 0) return "undefined";
522
+ if (Array.isArray(value)) return "an array";
523
+ if (typeof value === "object") {
524
+ if ("then" in value && typeof value.then === "function") {
525
+ return "a Promise \u2014 your function is `async`, and it must not be: remove the async keyword";
526
+ }
527
+ return "a plain object";
528
+ }
529
+ return `a ${typeof value}`;
530
+ }
519
531
  function extractMissileAction(action) {
532
+ if (action === null || action === void 0 || typeof action._toMissileAction !== "function") {
533
+ throw new Error(
534
+ `Your missile AI returned ${describeReturned(action)}. Return turnToward(x, y), turnToAngle(degrees) or flyStraight(). The missile AI runs once per tick for every missile you have in flight, and it must return one of those every time.`
535
+ );
536
+ }
520
537
  return action._toMissileAction();
521
538
  }
522
539
  function extractAction(finalAction) {
523
- return finalAction._toAction();
540
+ if (finalAction === null || finalAction === void 0 || typeof finalAction._toAction !== "function") {
541
+ const got = describeReturned(finalAction);
542
+ throw new Error(
543
+ `Your bot returned ${got}. Return one of: idle(), move(x, y), missile(config, ai, angle), shield(), blink(x, y), cancel() or aim(degrees). A bot must return an action every tick \u2014 returning nothing throws, it does not skip the tick.`
544
+ );
545
+ }
546
+ const action = finalAction._toAction();
547
+ if (action === null || typeof action !== "object" || typeof action.move !== "object" || action.move === null) {
548
+ throw new Error(
549
+ "Your bot returned something that is not a valid action. Build actions with the provided functions \u2014 idle(), move(x, y), missile(config, ai, angle), shield(), blink(x, y), cancel() or aim(degrees) \u2014 rather than constructing the object yourself."
550
+ );
551
+ }
552
+ return normaliseActionNumbers(action);
553
+ }
554
+ function normaliseActionNumbers(action) {
555
+ const num = (value) => Number(value);
556
+ const normalised = {
557
+ ...action,
558
+ move: { x: num(action.move?.x), y: num(action.move?.y) }
559
+ };
560
+ if (action.aimDirection !== void 0) normalised.aimDirection = num(action.aimDirection);
561
+ if (action.startCast?.spell === "missile") {
562
+ normalised.startCast = {
563
+ ...action.startCast,
564
+ ...action.startCast.direction !== void 0 ? { direction: num(action.startCast.direction) } : {}
565
+ };
566
+ } else if (action.startCast?.spell === "blink") {
567
+ normalised.startCast = {
568
+ ...action.startCast,
569
+ target: { x: num(action.startCast.target?.x), y: num(action.startCast.target?.y) }
570
+ };
571
+ }
572
+ return normalised;
524
573
  }
525
574
 
526
575
  // src/engine/physics.ts
@@ -551,6 +600,27 @@ function moveProjectile(projectile, deltaTicks) {
551
600
  y: projectile.position.y + Math.sin(radians) * projectile.speed * deltaTicks
552
601
  };
553
602
  }
603
+ function clampToSafeZone(position, radius) {
604
+ const min = ARENA_MIN + radius;
605
+ const max = ARENA_MAX - radius;
606
+ const centre = ARENA_SIZE / 2;
607
+ const axis = (value) => {
608
+ if (!Number.isFinite(value)) return centre;
609
+ return Math.max(min, Math.min(max, value));
610
+ };
611
+ return { x: axis(position?.x), y: axis(position?.y) };
612
+ }
613
+ function moveTowardSafely(from, target) {
614
+ const dx = target?.x - from?.x;
615
+ const dy = target?.y - from?.y;
616
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return { x: 0, y: 0 };
617
+ const distance = Math.sqrt(dx * dx + dy * dy);
618
+ if (distance === 0) return { x: 0, y: 0 };
619
+ if (distance >= RULES.MOVEMENT_SPEED) return { x: dx / distance, y: dy / distance };
620
+ const travel = Math.max(0, distance - 1e-6);
621
+ const fraction = travel / RULES.MOVEMENT_SPEED;
622
+ return { x: dx / distance * fraction, y: dy / distance * fraction };
623
+ }
554
624
  function clampToArena(position, radius) {
555
625
  return {
556
626
  x: Math.max(radius, Math.min(ARENA_SIZE - radius, position.x)),
@@ -605,7 +675,8 @@ function startCast(wizard, spell, config) {
605
675
  wizard.castingSpell = spell;
606
676
  wizard.castProgress = 0;
607
677
  if (spell === "missile" && config) {
608
- const castTimeSec = calculateMissileCastTime(config, wizard.lastMissileConfig);
678
+ const validated = validateMissileConfig(config);
679
+ const castTimeSec = calculateMissileCastTime(validated, wizard.lastMissileConfig);
609
680
  wizard.castDuration = Math.max(1, Math.round(castTimeSec * TICKS_PER_SECOND));
610
681
  wizard.warmupMultiplier = calculateWarmupMultiplier(wizard.lastMissileConfig, config);
611
682
  } else if (spell === "shield") {
@@ -729,6 +800,70 @@ function createEntitySeed(matchSeed, entityId, tick2) {
729
800
  return combined;
730
801
  }
731
802
 
803
+ // src/engine/bot-error-capture.ts
804
+ var MAX_MESSAGE_LENGTH = 1e3;
805
+ var MAX_ERRORS_PER_MATCH = 500;
806
+ function describeThrown(thrown) {
807
+ const { text, alreadyBounded } = rawDescription(thrown);
808
+ return alreadyBounded ? text : truncate(text);
809
+ }
810
+ function rawDescription(thrown) {
811
+ if (thrown instanceof Error) {
812
+ const message = typeof thrown.message === "string" ? thrown.message : "";
813
+ if (message.length > MAX_MESSAGE_LENGTH) {
814
+ return { text: truncate(message) + " [stack omitted: message too large]", alreadyBounded: true };
815
+ }
816
+ const stack = typeof thrown.stack === "string" ? stripBundleCoordinates(thrown.stack.trim()) : "";
817
+ if (stack) return { text: stack, alreadyBounded: false };
818
+ return { text: message || `${thrown.name || "Error"} (thrown with no message)`, alreadyBounded: false };
819
+ }
820
+ if (typeof thrown === "string") return { text: thrown || "(empty string thrown)", alreadyBounded: false };
821
+ if (thrown === null) return { text: "null was thrown", alreadyBounded: false };
822
+ if (thrown === void 0) return { text: "undefined was thrown", alreadyBounded: false };
823
+ try {
824
+ const json = JSON.stringify(thrown);
825
+ if (typeof json === "string" && json !== "{}") return { text: json, alreadyBounded: false };
826
+ } catch {
827
+ }
828
+ try {
829
+ const text = String(thrown);
830
+ if (text === "[object Object]") return { text: describeOpaqueObject(thrown), alreadyBounded: false };
831
+ return { text, alreadyBounded: false };
832
+ } catch {
833
+ return { text: "(a value was thrown that could not be described)", alreadyBounded: false };
834
+ }
835
+ }
836
+ function describeOpaqueObject(thrown) {
837
+ let name = "object";
838
+ try {
839
+ const ctor = thrown.constructor;
840
+ if (ctor && typeof ctor.name === "string" && ctor.name) name = ctor.name;
841
+ } catch {
842
+ }
843
+ let keys = [];
844
+ try {
845
+ keys = Object.keys(thrown).slice(0, 12);
846
+ } catch {
847
+ }
848
+ const shape = keys.length > 0 ? ` with keys: ${keys.join(", ")}` : " with no enumerable keys";
849
+ return `a non-serialisable ${name} was thrown${shape} (it could not be converted to text \u2014 usually a circular reference, or a getter that throws)`;
850
+ }
851
+ function stripBundleCoordinates(stack) {
852
+ return stack.replace(/\s*\((?:[^()\s]*match-bundle\.js|<isolated-vm>)[^()]*\)/g, "").replace(/(\s*at )(?:[^()\s]*match-bundle\.js|<isolated-vm>)[^\s)]*/g, "$1<your bot>");
853
+ }
854
+ function truncate(message) {
855
+ if (message.length <= MAX_MESSAGE_LENGTH) return message;
856
+ const dropped = message.length - MAX_MESSAGE_LENGTH;
857
+ return `${message.slice(0, MAX_MESSAGE_LENGTH)}\u2026 [truncated, ${dropped} more characters]`;
858
+ }
859
+ function cappedNotice(entityId, tick2) {
860
+ return {
861
+ tick: tick2,
862
+ entityId,
863
+ message: `\u2026 stopped recording errors after ${MAX_ERRORS_PER_MATCH} in this match. The bot is still being called and still failing; only the recording stopped.`
864
+ };
865
+ }
866
+
732
867
  // src/engine/simulation.ts
733
868
  function createInitialState(_seed, spawnDist = SPAWN_DISTANCE) {
734
869
  const center = { x: ARENA_SIZE / 2, y: ARENA_SIZE / 2 };
@@ -812,9 +947,22 @@ function buildWizardContext(state, config, random) {
812
947
  lastHitTick: state.lastHitTick
813
948
  };
814
949
  }
950
+ function nonFiniteField(move2, action, wizard) {
951
+ const bad = (v) => typeof v === "number" && !Number.isFinite(v);
952
+ if (bad(move2?.x)) return { what: "move(x, y): x", value: String(move2.x) };
953
+ if (bad(move2?.y)) return { what: "move(x, y): y", value: String(move2.y) };
954
+ const target = action.startCast?.spell === "blink" ? action.startCast.target : void 0;
955
+ if (bad(target?.x)) return { what: "blink(x, y): x", value: String(target.x) };
956
+ if (bad(target?.y)) return { what: "blink(x, y): y", value: String(target.y) };
957
+ if (bad(wizard.blinkTarget?.x)) return { what: "blink(x, y): x", value: String(wizard.blinkTarget.x) };
958
+ if (bad(wizard.blinkTarget?.y)) return { what: "blink(x, y): y", value: String(wizard.blinkTarget.y) };
959
+ if (bad(action.aimDirection)) return { what: "aim(degrees)", value: String(action.aimDirection) };
960
+ return null;
961
+ }
815
962
  function tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, matchSeed, budgets) {
816
963
  const nextTick = currentTick + 1;
817
964
  const errors = [];
965
+ const budgetExhausted = [];
818
966
  const events = [];
819
967
  const random1 = createRandom(createEntitySeed(matchSeed, wizards[0].id, nextTick));
820
968
  const random2 = createRandom(createEntitySeed(matchSeed, wizards[1].id, nextTick));
@@ -822,13 +970,25 @@ function tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, m
822
970
  const nextBudgets = budgets ? [budgets.states[0], budgets.states[1]] : void 0;
823
971
  const runBot = (index, entityId, invoke) => {
824
972
  const state = nextBudgets?.[index];
825
- if (state && !mayAct(state)) return IDLE_ACTION();
973
+ if (state && !mayAct(state)) {
974
+ if (!state.reported) {
975
+ state.reported = true;
976
+ budgetExhausted.push({ tick: nextTick, entityId, spentMs: state.spentMs });
977
+ }
978
+ return IDLE_ACTION();
979
+ }
826
980
  const startedAt = state ? Date.now() : 0;
827
981
  let action;
828
982
  try {
829
983
  action = invoke();
830
984
  } catch (e) {
831
- errors.push({ tick: nextTick, entityId, message: e instanceof Error ? e.message : String(e) });
985
+ const alreadyDead = (wizards[index]?.health ?? 1) <= 0;
986
+ errors.push({
987
+ tick: nextTick,
988
+ entityId,
989
+ message: describeThrown(e),
990
+ ...alreadyDead ? { afterDeath: true } : {}
991
+ });
832
992
  action = IDLE_ACTION();
833
993
  }
834
994
  if (state && budgets) {
@@ -911,10 +1071,12 @@ function tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, m
911
1071
  delete wizard.missileConfig;
912
1072
  delete wizard.missileAI;
913
1073
  } else if (spell === "blink" && wizard.blinkTarget) {
914
- const dx = wizard.blinkTarget.x - wizard.position.x;
915
- const dy = wizard.blinkTarget.y - wizard.position.y;
1074
+ const targetX = Number.isFinite(wizard.blinkTarget.x) ? wizard.blinkTarget.x : wizard.position.x;
1075
+ const targetY = Number.isFinite(wizard.blinkTarget.y) ? wizard.blinkTarget.y : wizard.position.y;
1076
+ const dx = targetX - wizard.position.x;
1077
+ const dy = targetY - wizard.position.y;
916
1078
  const distance = Math.sqrt(dx * dx + dy * dy);
917
- let targetPos = wizard.blinkTarget;
1079
+ let targetPos = { x: targetX, y: targetY };
918
1080
  if (distance > RULES.BLINK_RANGE) {
919
1081
  const scale = RULES.BLINK_RANGE / distance;
920
1082
  targetPos = {
@@ -940,6 +1102,17 @@ function tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, m
940
1102
  }
941
1103
  const oldPos = wizard.position;
942
1104
  const move2 = action.move ?? { x: 0, y: 0 };
1105
+ if (!wizard.advised?.nonFinite) {
1106
+ const bad = nonFiniteField(move2, action, wizard);
1107
+ if (bad) {
1108
+ wizard.advised = { ...wizard.advised, nonFinite: true };
1109
+ errors.push({
1110
+ tick: nextTick,
1111
+ entityId: wizard.id,
1112
+ message: `${bad.what} is not a finite number (it was ${bad.value}). The engine ignored it to keep the match running \u2014 the value is treated as zero, so your wizard simply does not do what you asked. This is almost always a divide-by-zero or a subtraction of two equal positions somewhere in your maths. Reported once per match.`
1113
+ });
1114
+ }
1115
+ }
943
1116
  wizard.position = moveWizard(wizard, move2, 1);
944
1117
  if (wizard.knockbackDelay !== void 0 && wizard.knockbackDelay > 0) {
945
1118
  wizard.knockbackDelay--;
@@ -986,6 +1159,15 @@ function tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, m
986
1159
  const castingSpell = action.startCast.spell;
987
1160
  startCast(wizard, castingSpell, castingSpell === "missile" ? action.startCast.config : void 0);
988
1161
  if (castingSpell === "missile") {
1162
+ if ((wizard.castDuration ?? 0) > MATCH_DURATION && !wizard.advised?.unfinishableCast) {
1163
+ wizard.advised = { ...wizard.advised, unfinishableCast: true };
1164
+ const config2 = action.startCast.config;
1165
+ errors.push({
1166
+ tick: nextTick,
1167
+ entityId: wizard.id,
1168
+ message: `This missile takes ${wizard.castDuration} ticks to cast, but a match is only ${MATCH_DURATION} ticks long, so the cast can never finish and this wizard will stand still for the rest of the match. The cost is dominated by turnRate (${config2?.turnRate ?? "?"}) \u2014 it is the most expensive field by far, and unlike the others it is not clamped to a sane maximum. Try a turnRate in the single digits, then use missile-calc or getMissileCastTime() to check the cast time before committing to a config.`
1169
+ });
1170
+ }
989
1171
  wizard.missileConfig = action.startCast.config;
990
1172
  wizard.missileAI = action.startCast.missileAI;
991
1173
  if (action.startCast.direction !== void 0) {
@@ -1043,12 +1225,12 @@ function tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, m
1043
1225
  () => extractMissileAction(withMissileContext(missileCtx, ai))
1044
1226
  ) ?? {};
1045
1227
  } catch (e) {
1046
- errors.push({ tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e) });
1228
+ errors.push({ tick: nextTick, entityId: projectile.id, message: describeThrown(e) });
1047
1229
  }
1048
1230
  targetAngle = missileActions.turnToward ? angleTo(projectile.position, missileActions.turnToward) : missileActions.turnToAngle !== void 0 ? missileActions.turnToAngle : null;
1049
1231
  }
1050
1232
  }
1051
- if (targetAngle !== null) {
1233
+ if (targetAngle !== null && Number.isFinite(targetAngle)) {
1052
1234
  const diff = angleDiff(projectile.rotation, targetAngle);
1053
1235
  const turn = Math.max(-projectile.turnRate, Math.min(projectile.turnRate, diff));
1054
1236
  projectile.rotation = normalizeAngle(projectile.rotation + turn);
@@ -1109,6 +1291,7 @@ function tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, m
1109
1291
  projectiles: remainingProjectiles,
1110
1292
  events,
1111
1293
  errors,
1294
+ budgetExhausted,
1112
1295
  budgets: nextBudgets
1113
1296
  };
1114
1297
  }
@@ -1248,6 +1431,7 @@ var FIGHT_SPAWN_DISTANCES = [500, 550, 600, 650, 700];
1248
1431
  function fight(wizard1AI, wizard2AI, options = {}) {
1249
1432
  const matches = [];
1250
1433
  const allErrors = [];
1434
+ const allBudgetExhausted = [];
1251
1435
  let matchNumber = 1;
1252
1436
  let wizard1Wins = 0;
1253
1437
  let wizard2Wins = 0;
@@ -1262,6 +1446,7 @@ function fight(wizard1AI, wizard2AI, options = {}) {
1262
1446
  });
1263
1447
  matches.push(result);
1264
1448
  allErrors.push(...result.errors.map((e) => ({ ...e, match: matchNumber })));
1449
+ allBudgetExhausted.push(...result.budgetExhausted.map((b) => ({ ...b, match: matchNumber })));
1265
1450
  matchNumber++;
1266
1451
  if (result.winner === "wizard-1") {
1267
1452
  wizard1Wins++;
@@ -1282,6 +1467,7 @@ function fight(wizard1AI, wizard2AI, options = {}) {
1282
1467
  budget.states = [swappedBudget.states[1], swappedBudget.states[0]];
1283
1468
  }
1284
1469
  allErrors.push(...swapped.errors.map((e) => ({ ...e, entityId: swapSide(e.entityId), match: matchNumber })));
1470
+ allBudgetExhausted.push(...swapped.budgetExhausted.map((b) => ({ ...b, entityId: swapSide(b.entityId), match: matchNumber })));
1285
1471
  matchNumber++;
1286
1472
  if (swapped.winner === "wizard-1") {
1287
1473
  wizard2Wins++;
@@ -1292,7 +1478,7 @@ function fight(wizard1AI, wizard2AI, options = {}) {
1292
1478
  }
1293
1479
  }
1294
1480
  const winner = wizard1Wins > wizard2Wins ? "wizard-1" : wizard2Wins > wizard1Wins ? "wizard-2" : "draw";
1295
- return { wizard1Wins, wizard2Wins, draws, winner, matches, allErrors };
1481
+ return { wizard1Wins, wizard2Wins, draws, winner, matches, allErrors, budgetExhausted: allBudgetExhausted };
1296
1482
  }
1297
1483
  function simulate(wizard1AI, wizard2AI, options = {}) {
1298
1484
  if (typeof wizard1AI !== "function") {
@@ -1345,6 +1531,7 @@ function simulate(wizard1AI, wizard2AI, options = {}) {
1345
1531
  let projectiles = [];
1346
1532
  const missileAIs = /* @__PURE__ */ new Map();
1347
1533
  const allErrors = [];
1534
+ const allBudgetExhausted = [];
1348
1535
  let deathTick = null;
1349
1536
  const sharedBudget = options.budget;
1350
1537
  const budgetLimits = sharedBudget?.limits ?? options.budgetLimits;
@@ -1368,7 +1555,15 @@ function simulate(wizard1AI, wizard2AI, options = {}) {
1368
1555
  currentTick = result.nextTick;
1369
1556
  wizards = result.wizards;
1370
1557
  projectiles = result.projectiles;
1371
- if (result.errors.length > 0) allErrors.push(...result.errors);
1558
+ for (const error of result.errors) {
1559
+ if (allErrors.length > MAX_ERRORS_PER_MATCH) break;
1560
+ if (allErrors.length === MAX_ERRORS_PER_MATCH) {
1561
+ allErrors.push(cappedNotice(error.entityId, error.tick));
1562
+ break;
1563
+ }
1564
+ allErrors.push(error);
1565
+ }
1566
+ if (result.budgetExhausted.length > 0) allBudgetExhausted.push(...result.budgetExhausted);
1372
1567
  if (!skipHistory) {
1373
1568
  history.push(getPlayerState(0, wizards, projectiles, currentTick, result.events));
1374
1569
  }
@@ -1393,7 +1588,8 @@ function simulate(wizard1AI, wizard2AI, options = {}) {
1393
1588
  ticks: currentTick,
1394
1589
  finalState,
1395
1590
  history: skipHistory ? [] : history,
1396
- errors: allErrors
1591
+ errors: allErrors,
1592
+ budgetExhausted: allBudgetExhausted
1397
1593
  };
1398
1594
  }
1399
1595
 
@@ -1567,7 +1763,9 @@ var ManualMatch = class {
1567
1763
  ticks: this.currentTick,
1568
1764
  finalState: this.getGameState(),
1569
1765
  history: this.history,
1570
- errors: this.allErrors
1766
+ errors: this.allErrors,
1767
+ // Manual play is unbudgeted — the clock is never read, so nobody can be cut off.
1768
+ budgetExhausted: []
1571
1769
  };
1572
1770
  }
1573
1771
  dispose() {
@@ -1936,13 +2134,14 @@ function fitMissileForEscapingTarget(currentDistance, budgetTicks, options) {
1936
2134
  }
1937
2135
 
1938
2136
  // src/hooks/threat-analysis.ts
2137
+ var CANCEL_COST_TICKS = 1;
1939
2138
  var THREAT_RELEVANCE_DISTANCE = 500;
1940
- function analyzeThreats(myPos, projectiles, myProjectiles, ticksUntilReady) {
2139
+ function analyzeThreats(myPos, projectiles, myProjectiles, ticksUntilReady, options) {
1941
2140
  const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
1942
2141
  const enemyProjectiles = projectiles.filter((p) => !myProjectileIds.has(p.id));
1943
2142
  const threats = [];
1944
2143
  for (const projectile of enemyProjectiles) {
1945
- const analysis = analyzeOneThreat(myPos, projectile, ticksUntilReady);
2144
+ const analysis = analyzeOneThreat(myPos, projectile, ticksUntilReady, options?.canCancelCurrentCast ?? false);
1946
2145
  const missileDistance = Math.sqrt(
1947
2146
  (projectile.position.x - myPos.x) ** 2 + (projectile.position.y - myPos.y) ** 2
1948
2147
  );
@@ -1953,7 +2152,7 @@ function analyzeThreats(myPos, projectiles, myProjectiles, ticksUntilReady) {
1953
2152
  threats.sort((a, b) => a.ticksToImpact - b.ticksToImpact);
1954
2153
  return threats;
1955
2154
  }
1956
- function analyzeOneThreat(targetPos, projectile, ticksUntilReady) {
2155
+ function analyzeOneThreat(targetPos, projectile, ticksUntilReady, canCancelCurrentCast) {
1957
2156
  const missileRadius = calculateMissileRadius(projectile.damage);
1958
2157
  const collisionDist = COLLISION_RADIUS + missileRadius;
1959
2158
  const dodgeCollisionDist = collisionDist + projectile.speed * 0.5;
@@ -1975,11 +2174,14 @@ function analyzeOneThreat(targetPos, projectile, ticksUntilReady) {
1975
2174
  targetPos,
1976
2175
  canDodgeLeft,
1977
2176
  canDodgeRight,
1978
- canOutrun
2177
+ canOutrun,
2178
+ ticksToImpact
1979
2179
  );
1980
2180
  }
1981
- const canBlockInTime = ticksToImpact > ticksUntilReady + RULES.SHIELD_CAST_TIME + 1;
1982
- const ticksToStartShield = Math.max(0, ticksToImpact - RULES.SHIELD_CAST_TIME - 1);
2181
+ const delayBeforeShield = canCancelCurrentCast ? CANCEL_COST_TICKS : ticksUntilReady;
2182
+ const ticksNeededToBlock = delayBeforeShield + RULES.SHIELD_CAST_TIME + 1;
2183
+ const canBlockInTime = ticksToImpact >= ticksNeededToBlock;
2184
+ const ticksToStartShield = Math.max(0, ticksToImpact - ticksNeededToBlock);
1983
2185
  return {
1984
2186
  id: projectile.id,
1985
2187
  projectile,
@@ -2090,15 +2292,28 @@ function getDodgeDirection(projectile, targetPos, direction) {
2090
2292
  return { x: 0, y: 0 };
2091
2293
  }
2092
2294
  }
2093
- function calculateBestDodgeDirection(projectile, targetPos, canDodgeLeft, canDodgeRight, canOutrun) {
2094
- if (canDodgeLeft && canDodgeRight) {
2095
- return getDodgeDirection(projectile, targetPos, "left");
2096
- } else if (canDodgeLeft) {
2097
- return getDodgeDirection(projectile, targetPos, "left");
2098
- } else if (canDodgeRight) {
2099
- return getDodgeDirection(projectile, targetPos, "right");
2100
- } else if (canOutrun) {
2101
- return getDodgeDirection(projectile, targetPos, "away");
2295
+ function roomBeforeLava(from, dir) {
2296
+ const min = ARENA_MIN + COLLISION_RADIUS;
2297
+ const max = ARENA_MAX - COLLISION_RADIUS;
2298
+ const along = (pos, d, lo, hi) => {
2299
+ if (Math.abs(d) < 1e-9) return Number.POSITIVE_INFINITY;
2300
+ return d > 0 ? (hi - pos) / d : (lo - pos) / d;
2301
+ };
2302
+ return Math.max(0, Math.min(along(from.x, dir.x, min, max), along(from.y, dir.y, min, max)));
2303
+ }
2304
+ function calculateBestDodgeDirection(projectile, targetPos, canDodgeLeft, canDodgeRight, canOutrun, ticksToImpact) {
2305
+ const travel = RULES.MOVEMENT_SPEED * Math.max(1, Math.min(ticksToImpact, 600));
2306
+ const survives = (dir) => roomBeforeLava(targetPos, dir) >= travel;
2307
+ const sides = [];
2308
+ if (canDodgeLeft) sides.push(getDodgeDirection(projectile, targetPos, "left"));
2309
+ if (canDodgeRight) sides.push(getDodgeDirection(projectile, targetPos, "right"));
2310
+ const safeSides = sides.filter(survives);
2311
+ if (safeSides.length > 0) {
2312
+ return safeSides.reduce((best, dir) => roomBeforeLava(targetPos, dir) > roomBeforeLava(targetPos, best) ? dir : best);
2313
+ }
2314
+ if (canOutrun) {
2315
+ const away = getDodgeDirection(projectile, targetPos, "away");
2316
+ if (survives(away)) return away;
2102
2317
  }
2103
2318
  return null;
2104
2319
  }
@@ -2241,9 +2456,13 @@ function useThreats() {
2241
2456
  ctx.position,
2242
2457
  ctx.projectiles,
2243
2458
  ctx.myProjectiles,
2244
- ticksUntilReady
2459
+ ticksUntilReady,
2460
+ // Mid-CAST you are one cancel() away from being able to shield, so the analysis
2461
+ // must not charge you the whole remainder of the cast. A GCD cannot be cancelled,
2462
+ // so it keeps the pessimistic reading.
2463
+ { canCancelCurrentCast: ctx.state === "casting" }
2245
2464
  );
2246
- }, [ctx.projectiles, ctx.myProjectiles, ctx.position.x, ctx.position.y, ticksUntilReady]);
2465
+ }, [ctx.projectiles, ctx.myProjectiles, ctx.position.x, ctx.position.y, ticksUntilReady, ctx.state]);
2247
2466
  }
2248
2467
  function useClosestThreat() {
2249
2468
  const threats = useThreats();
@@ -10585,6 +10804,8 @@ export {
10585
10804
  extractAction,
10586
10805
  moveWizard,
10587
10806
  moveProjectile,
10807
+ clampToSafeZone,
10808
+ moveTowardSafely,
10588
10809
  clampToArena,
10589
10810
  isInLava,
10590
10811
  resolveWizardCollision,
@@ -10723,4 +10944,4 @@ export {
10723
10944
  diagnoseTrace,
10724
10945
  formatDiagnosis
10725
10946
  };
10726
- //# sourceMappingURL=chunk-OLGKLCCB.js.map
10947
+ //# sourceMappingURL=chunk-KGRG7TZS.js.map