@vibemancer/core 1.0.7 → 1.0.9

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.
@@ -151,11 +151,17 @@ function combatMove(
151
151
  /**
152
152
  * Bot: Spellshot
153
153
  *
154
- * BEHAVIOR: Uses interceptAngle to calculate where the enemy will be and fires
155
- * fast, non-homing missiles (speed 8, turnRate 0) along the predicted path.
156
- * Strafes at medium range (300-400), shields undodgeable threats, emergency
157
- * blinks when shield isn't available. The key mechanic is PREDICTION — these
158
- * missiles don't track, they go exactly where you calculated the enemy would be.
154
+ * BEHAVIOR: fires lightly-homing missiles (turnRate defaults to 0.3) aimed at the enemy's
155
+ * CURRENT position, strafes at medium range (300-400), shields undodgeable threats, and
156
+ * blinks when a shield isn't available.
157
+ *
158
+ * This description used to claim it "uses interceptAngle to calculate where the enemy will
159
+ * be" and fires "non-homing missiles (speed 8, turnRate 0)" whose "key mechanic is
160
+ * PREDICTION". None of that was true — it calls angleTo(position, enemy.position) and lets
161
+ * the missile home. That mattered because this is the exemplar other snipers are read from,
162
+ * and until 2026-09-10 aiming ahead was impossible anyway: the engine auto-aimed at the
163
+ * enemy every tick and discarded whatever angle a bot passed. Leading is now possible with
164
+ * per-tick aim(), but this bot does not do it.
159
165
  *
160
166
  * NAMING RATIONALE: "Spellshot" — a spell that is a single, precisely aimed shot.
161
167
  * Like a sniper's "called shot" but magical. The defining feature is the intercept
@@ -185,7 +185,9 @@ export class ManualMatch
185
185
 
186
186
  this.history.push(getPlayerState(0, this.wizards, this.projectiles, this.currentTick, result.events));
187
187
 
188
- // Track death and add grace period (0.5s = 50 ticks at 100 tps)
188
+ // Track death and add grace period (2s = 200 ticks at 100 tps), matching
189
+ // simulation.ts. The window is what makes a Draw reachable: a second death inside
190
+ // it is still recorded. Pinned by tests/death-grace-period.test.ts.
189
191
  if (!this.deathTick && (this.wizards[0]!.health <= 0 || this.wizards[1]!.health <= 0))
190
192
  {
191
193
  this.deathTick = this.currentTick;
@@ -9,8 +9,18 @@ import {RULES, ARENA_SIZE, WIZARD_RADIUS, ARENA_MIN, ARENA_MAX} from '../rules.j
9
9
  */
10
10
  export function moveWizard(wizard: WizardState, move: {x: number; y: number}, deltaTicks: number): Position
11
11
  {
12
- let dx = move.x || 0;
13
- let dy = move.y || 0;
12
+ // A bot's move vector is untrusted input, and it must not be able to poison the
13
+ // simulation. This was `move.x || 0`, which neutralises NaN (NaN is falsy) and does
14
+ // nothing about Infinity — and `Infinity / magnitude` is NaN, so the position became NaN,
15
+ // `isInLava(NaN)` was false so the wizard never died, and `resolveWizardCollision` then
16
+ // divided by a NaN distance and wrote NaN into the OPPONENT's position too. Both wizards
17
+ // froze, nothing could hit anything, and every match was a silent draw.
18
+ //
19
+ // On a rated ladder that made `move(1/0, 0)` a one-line way to deny every opponent a win
20
+ // and never lose — and it is reachable by accident from any divide-by-zero scale factor.
21
+ // A non-finite component is treated as no input on that axis, exactly like move(0, 0).
22
+ let dx = Number.isFinite(move?.x) ? move.x : 0;
23
+ let dy = Number.isFinite(move?.y) ? move.y : 0;
14
24
 
15
25
  const magnitude = Math.sqrt(dx * dx + dy * dy);
16
26
  // Stryker disable next-line EqualityOperator: > vs >= equivalent (dividing by exactly 1 is identity)
@@ -47,6 +47,15 @@ export interface InternalWizardState extends WizardState
47
47
  knockbackVy?: number;
48
48
  // Delayed knockback (waits for explosion to expand)
49
49
  knockbackDelay?: number;
50
+ /**
51
+ * A heading this wizard LOCKED for the duration of a missile cast, via `lockAim()`.
52
+ *
53
+ * Auto-aim rewrites `rotation` every tick and a missile launches with the rotation at cast
54
+ * completion, so an angle applied only at cast start never survived. Holding it here is
55
+ * what lets a bot lead a moving target. Absent for every bot that does not opt in, which
56
+ * is why adding it changed no existing behaviour.
57
+ */
58
+ castAimDirection?: number;
50
59
  knockbackPendingVx?: number;
51
60
  knockbackPendingVy?: number;
52
61
  }
@@ -292,12 +301,19 @@ export function tick(
292
301
  }
293
302
  }
294
303
 
295
- // Auto-aim: face the enemy by default, bot can override with aimDirection
304
+ // Auto-aim: face the enemy by default; a bot overrides with aimDirection (`aim()`), and
305
+ // a missile cast that opted in with `lockAim()` holds its heading until it launches.
306
+ // Without that hold the override ran every tick and the requested angle never reached
307
+ // cast completion, which is the rotation the missile is actually fired with.
296
308
  const enemy = wizards[1 - i]!;
297
309
  if (action.aimDirection !== undefined && Number.isFinite(action.aimDirection))
298
310
  {
299
311
  wizard.rotation = action.aimDirection;
300
312
  }
313
+ else if (wizard.state === 'casting' && wizard.castingSpell === 'missile' && wizard.castAimDirection !== undefined)
314
+ {
315
+ wizard.rotation = wizard.castAimDirection;
316
+ }
301
317
  else
302
318
  {
303
319
  wizard.rotation = angleTo(wizard.position, enemy.position);
@@ -328,6 +344,9 @@ export function tick(
328
344
  remainingTicks: validConfig.duration,
329
345
  };
330
346
  projectiles.push(projectile);
347
+ // The lock belonged to this cast; the next action auto-aims again unless it
348
+ // asks for something else.
349
+ wizard.castAimDirection = undefined;
331
350
  missileAIs.set(id, wizard.missileAI);
332
351
  events.push({type: 'missile-launch', position: {...wizard.position}, damage: validConfig.damage, speed: validConfig.speed, ownerId: wizard.id, rotation: wizard.rotation});
333
352
  // Track last missile for warmup system
@@ -455,6 +474,11 @@ export function tick(
455
474
  {
456
475
  wizard.rotation = action.startCast.direction;
457
476
  }
477
+ // Only a bot that called lockAim() sends aimDirection, so auto-aim stays
478
+ // the default for everything that came before this existed.
479
+ wizard.castAimDirection = (action.aimDirection !== undefined && Number.isFinite(action.aimDirection))
480
+ ? action.aimDirection
481
+ : undefined;
458
482
  }
459
483
  else if (castingSpell === 'blink')
460
484
  {
@@ -832,6 +856,15 @@ export interface BotError
832
856
  tick: number;
833
857
  entityId: string;
834
858
  message: string;
859
+ /**
860
+ * 1-based index within the fight's TEN matches, present only on `FightResult.allErrors`.
861
+ *
862
+ * A single `simulate()` knows nothing about a series, so it does not set this. `fight()`
863
+ * stamps it, numbering 1..10 in the order matches are played. Every tool description
864
+ * promises "a 10-match series (5 spawn distances, each played twice with sides swapped)",
865
+ * so a match index that only ever reached 5 was uninterpretable to the player reading it.
866
+ */
867
+ match?: number;
835
868
  }
836
869
 
837
870
  export interface SimulateResult
@@ -860,6 +893,37 @@ export interface FightResult
860
893
  * Used for visual playback in the web viewer. Scoring includes both sides.
861
894
  */
862
895
  matches: SimulateResult[];
896
+ /**
897
+ * Every bot error from ALL TEN matches, including the swapped half that `matches` drops.
898
+ *
899
+ * `matches` exists for visual playback, so it holds only the five non-swapped results.
900
+ * Anything asking "did this bot crash?" needs the whole series: a bot that throws only
901
+ * when it spawns on one side was previously invisible, reporting a clean `success: true`
902
+ * while losing every match.
903
+ *
904
+ * Entity ids are in the CALLER's frame of reference. In a swapped match the engine calls
905
+ * bot 2 "wizard-1", so those records are remapped on the way out — passing them through
906
+ * raw would blame each side's faults on the other.
907
+ */
908
+ allErrors: BotError[];
909
+ }
910
+
911
+ /**
912
+ * Flip an entity id between the two sides.
913
+ *
914
+ * A swapped match runs bot 2 in the wizard-1 slot, so every id it produces is the mirror of
915
+ * what the caller means by it. Projectile ids carry their owner (`missile-<wizard>-<tick>`),
916
+ * so they are mirrored too — otherwise a missile-AI fault from the swapped half would be
917
+ * attributed to the wrong bot, which is exactly the defect that made a passive bot look like
918
+ * it had thrown 3,695 times.
919
+ */
920
+ function swapSide(entityId: string): string
921
+ {
922
+ if (entityId === 'wizard-1') return 'wizard-2';
923
+ if (entityId === 'wizard-2') return 'wizard-1';
924
+ if (entityId.startsWith('missile-wizard-1-')) return 'missile-wizard-2-' + entityId.slice('missile-wizard-1-'.length);
925
+ if (entityId.startsWith('missile-wizard-2-')) return 'missile-wizard-1-' + entityId.slice('missile-wizard-2-'.length);
926
+ return entityId;
863
927
  }
864
928
 
865
929
  /** Spawn distances for the 5 matches in a fight (creates butterfly effect variation) */
@@ -891,6 +955,10 @@ export function fight(
891
955
  ): FightResult
892
956
  {
893
957
  const matches: SimulateResult[] = [];
958
+ const allErrors: BotError[] = [];
959
+ // 1..10 in the order matches are played, so the number a player is shown is the number
960
+ // every tool description promises.
961
+ let matchNumber = 1;
894
962
  let wizard1Wins = 0;
895
963
  let wizard2Wins = 0;
896
964
  let draws = 0;
@@ -909,6 +977,8 @@ export function fight(
909
977
  });
910
978
 
911
979
  matches.push(result);
980
+ allErrors.push(...result.errors.map((e) => ({...e, match: matchNumber})));
981
+ matchNumber++;
912
982
 
913
983
  if (result.winner === 'wizard-1')
914
984
  {
@@ -946,6 +1016,13 @@ export function fight(
946
1016
  budget.states = [swappedBudget.states[1], swappedBudget.states[0]];
947
1017
  }
948
1018
 
1019
+ // The swapped match is dropped from `matches` (playback only), but its errors are not
1020
+ // dropped — half a fight's worth of crashes used to vanish here. Ids are flipped back
1021
+ // into the caller's frame first: this match ran bot 2 as "wizard-1", so passing the
1022
+ // records through raw would report each bot's faults against the other.
1023
+ allErrors.push(...swapped.errors.map((e) => ({...e, entityId: swapSide(e.entityId), match: matchNumber})));
1024
+ matchNumber++;
1025
+
949
1026
  // Don't push swapped match to matches array (it's only for scoring)
950
1027
  if (swapped.winner === 'wizard-1')
951
1028
  {
@@ -966,7 +1043,7 @@ export function fight(
966
1043
  : wizard2Wins > wizard1Wins ? 'wizard-2'
967
1044
  : 'draw';
968
1045
 
969
- return {wizard1Wins, wizard2Wins, draws, winner, matches};
1046
+ return {wizard1Wins, wizard2Wins, draws, winner, matches, allErrors};
970
1047
  }
971
1048
 
972
1049
  /**
@@ -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 = 4204441759696527;
12
+ export const ENGINE_VERSION = 293192982913104;
13
13
 
14
14
  /**
15
15
  * Historical versions that denote the SAME engine as `ENGINE_VERSION`.