@vibemancer/core 1.0.9 → 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.
@@ -119,6 +119,7 @@ function analyzeOneThreat(
119
119
  canDodgeLeft,
120
120
  canDodgeRight,
121
121
  canOutrun,
122
+ ticksToImpact,
122
123
  );
123
124
  }
124
125
 
@@ -244,12 +245,14 @@ function simulateDodge(
244
245
  // Stryker disable next-line ArithmeticOperator: same as above
245
246
  const newY = targetPos.y + dodgeDir.y * RULES.MOVEMENT_SPEED;
246
247
 
247
- // Stryker disable all: arena clamp sign +/- COLLISION_RADIUS equivalent (wizard rarely reaches exact wall boundary during dodge)
248
+ // Clamped to the survivable band, which models "dodge as far as you safely can"
249
+ // the wizard stops at the lava rather than walking into it. Feasibility is judged on
250
+ // that basis; whether the ADVICE points at the lava is decided in
251
+ // calculateBestDodgeDirection, which is where the real defect was.
248
252
  targetPos = {
249
253
  x: Math.max(ARENA_MIN + COLLISION_RADIUS, Math.min(ARENA_MAX - COLLISION_RADIUS, newX)),
250
254
  y: Math.max(ARENA_MIN + COLLISION_RADIUS, Math.min(ARENA_MAX - COLLISION_RADIUS, newY)),
251
255
  };
252
- // Stryker restore all
253
256
 
254
257
  // Stryker disable next-line ConditionalExpression,EqualityOperator: turnRate>=0 equivalent (homing with rate 0 is no-op)
255
258
  if (turnRate > 0)
@@ -341,35 +344,71 @@ function getDodgeDirection(
341
344
  /**
342
345
  * Calculate the best dodge direction based on available options.
343
346
  */
347
+ /**
348
+ * How far a wizard can travel from `from` along `dir` before its edge touches lava.
349
+ *
350
+ * Used only to break a tie between two dodges that both work. Larger is better: it is the
351
+ * margin the bot has if it keeps moving, which is what bots actually do.
352
+ */
353
+ function roomBeforeLava(from: Position, dir: Position): number
354
+ {
355
+ const min = ARENA_MIN + COLLISION_RADIUS;
356
+ const max = ARENA_MAX - COLLISION_RADIUS;
357
+ const along = (pos: number, d: number, lo: number, hi: number): number =>
358
+ {
359
+ // A component that is effectively zero means this axis never runs out — not that it
360
+ // runs out immediately. Comparing against exact zero was not enough: standing on the
361
+ // boundary, a direction of (1, -1.8e-16) gave (35 - 35) / -1.8e-16 = 0, so the SAFE
362
+ // direction scored no room and lost to the lava-ward one. And standing exactly on the
363
+ // boundary is precisely what clampToSafeZone hands back.
364
+ if (Math.abs(d) < 1e-9) return Number.POSITIVE_INFINITY;
365
+ return d > 0 ? (hi - pos) / d : (lo - pos) / d;
366
+ };
367
+ // The binding axis is whichever runs out first.
368
+ return Math.max(0, Math.min(along(from.x, dir.x, min, max), along(from.y, dir.y, min, max)));
369
+ }
370
+
344
371
  function calculateBestDodgeDirection(
345
372
  projectile: ProjectileState,
346
373
  targetPos: Position,
347
374
  canDodgeLeft: boolean,
348
375
  canDodgeRight: boolean,
349
376
  canOutrun: boolean,
377
+ ticksToImpact: number,
350
378
  ): Position | null
351
379
  {
352
- // Stryker disable next-line ConditionalExpression: replacing with false just falls through to the `else if (canDodgeLeft)` which returns the same value
353
- if (canDodgeLeft && canDodgeRight)
354
- {
355
- // Both sides work - pick the one closer to where we want to be
356
- // For simplicity, just return left
357
- return getDodgeDirection(projectile, targetPos, 'left');
358
- }
359
- else if (canDodgeLeft)
380
+ // Every feasible candidate is checked for whether you SURVIVE it, not just the two-sided
381
+ // case. The first version of this fix guarded only `canDodgeLeft && canDodgeRight`, so
382
+ // when one side was feasible it was returned unchecked — and simulateDodge clamps the
383
+ // wizard at the band edge, so it calls a dodge feasible when following it walks into the
384
+ // lava. A sweep of 38,280 suggestions found 114 still doing that.
385
+ // A dodge is only worth recommending if the wizard is still alive at impact.
386
+ const travel = RULES.MOVEMENT_SPEED * Math.max(1, Math.min(ticksToImpact, 600));
387
+ const survives = (dir: Position): boolean => roomBeforeLava(targetPos, dir) >= travel;
388
+
389
+ // Sidesteps first, outrunning last — a sidestep leaves the missile's path while outrunning
390
+ // merely delays it, and that ordering predates the lava work. Room only breaks the tie
391
+ // BETWEEN the two sidesteps.
392
+ const sides: Position[] = [];
393
+ if (canDodgeLeft) sides.push(getDodgeDirection(projectile, targetPos, 'left'));
394
+ if (canDodgeRight) sides.push(getDodgeDirection(projectile, targetPos, 'right'));
395
+
396
+ const safeSides = sides.filter(survives);
397
+ if (safeSides.length > 0)
360
398
  {
361
- return getDodgeDirection(projectile, targetPos, 'left');
399
+ return safeSides.reduce((best, dir) =>
400
+ (roomBeforeLava(targetPos, dir) > roomBeforeLava(targetPos, best) ? dir : best));
362
401
  }
363
- else if (canDodgeRight)
364
- {
365
- return getDodgeDirection(projectile, targetPos, 'right');
366
- }
367
- else if (canOutrun)
402
+
403
+ if (canOutrun)
368
404
  {
369
- return getDodgeDirection(projectile, targetPos, 'away');
405
+ const away = getDodgeDirection(projectile, targetPos, 'away');
406
+ if (survives(away)) return away;
370
407
  }
371
408
 
372
- // No dodge available
409
+ // Nothing survivable. Saying so is the honest answer: a caller reading null shields or
410
+ // blinks instead, which is the right response to "there is nowhere to go". Naming a
411
+ // direction that kills you is not a dodge.
373
412
  return null;
374
413
  }
375
414
 
@@ -22,7 +22,8 @@ import {Position, Velocity, ProjectileState, MissileConfig, WizardActions, GameS
22
22
  */
23
23
  export interface EnemyState
24
24
  {
25
- /** Enemy position in world coordinates (0-800). */
25
+ /** Enemy position in world coordinates. The arena is 0-860; the survivable playfield is
26
+ * [30, 830], and outside that band is lava. */
26
27
  position: Position;
27
28
  /** Enemy velocity in units/tick. */
28
29
  velocity: Velocity;
@@ -108,9 +109,20 @@ export interface ActionBuilder extends FinalAction
108
109
  * to `missile()` is overwritten before the shot leaves. That makes the enemy impossible
109
110
  * to LEAD: you always fire where they are, never where they will be.
110
111
  *
111
- * `lockAim()` holds your angle for the whole cast, which is what makes `getLeadPosition`
112
- * and `interceptAngle` worth using. It is opt-in precisely because auto-aim is the right
113
- * default for most bots: lock your aim and a target that moves during the cast is missed.
112
+ * `lockAim()` holds your angle for the whole cast but it is USUALLY THE WRONG TOOL for
113
+ * leading, and this comment used to claim the opposite. Measured over 1,140 matches
114
+ * against the built-ins: auto-aim wins 80.5%, lockAim with a computed lead wins 29.3%,
115
+ * and it degrades monotonically the more shots you lock.
116
+ *
117
+ * The reason is cast time. A cast runs 88-240 ticks — up to 2.4 SECONDS — and lockAim
118
+ * freezes the angle at cast START, so it is stale long before the missile leaves. No bot
119
+ * holds a heading that long.
120
+ *
121
+ * TO LEAD A TARGET, call `aim(degrees)` on every tick of the cast and recompute the lead
122
+ * each time: same harness, 85.4%. That is what makes `getLeadPosition` worth calling.
123
+ *
124
+ * lockAim is for aiming at a PLACE rather than a bot — a lava edge, an escape lane, an
125
+ * area you want denied — where a fixed angle is the point.
114
126
  *
115
127
  * Chainable — `missile(cfg, ai, angle).lockAim().move(0, 1)` aims, fires and kites.
116
128
  */
package/src/rules.ts CHANGED
@@ -199,6 +199,14 @@ export function validateMissileConfig(config: MissileConfig): MissileConfig
199
199
  * If lastMissileConfig is a MissileConfig, applies warmup multiplier:
200
200
  * - Similar to previous: up to 20% faster
201
201
  * - Very different: up to 20% slower (switching penalty)
202
+ *
203
+ * @returns cast time in SECONDS — multiply by TICKS_PER_SECOND for ticks.
204
+ *
205
+ * Bot authors almost always want `getMissileCastTime` from utils/combat.ts instead, which
206
+ * has the identical signature and returns TICKS, the unit the guide and every other number in
207
+ * the API use. Both are exported and both appear in vibemancer_api as `(config, last?) =>
208
+ * number`, so the unit was impossible to tell apart: 2.747 read as three ticks rather than
209
+ * 275.
202
210
  */
203
211
  export function calculateMissileCastTime(config: MissileConfig, lastMissileConfig: MissileConfig | undefined | null = null): number
204
212
  {
package/src/types.ts CHANGED
@@ -113,17 +113,21 @@ export interface GameState
113
113
  * Actions returned by wizard each tick.
114
114
  *
115
115
  * Movement uses world-space coordinates:
116
- * - x: +100 = right, -100 = left
117
- * - y: +100 = down, -100 = up
118
- * - Diagonal movement is normalized (magnitude capped at 100)
116
+ * - x: positive = right, negative = left
117
+ * - y: positive = down, negative = up
118
+ * - Only the DIRECTION matters: the vector is normalised, and magnitude is capped at 1.
119
+ * move(1, 0), move(5, 0) and move(100, 0) are identical; move(0.5, 0) is half speed.
119
120
  * - No rotation tracking - just output (x, y) direction
121
+ *
122
+ * The old wording here described a [-100, 100] scale, which has not been true for a long
123
+ * time and is 100x off.
120
124
  */
121
125
  export interface WizardActions
122
126
  {
123
127
  /**
124
- * Movement direction in world-space.
125
- * Values are clamped to [-100, 100] range.
126
- * Magnitude is normalized to max 100 for diagonal movement.
128
+ * Movement direction in world-space. Only the direction matters — the vector is
129
+ * normalised and its magnitude is capped at 1, so move(100, 0) and move(1, 0) are the
130
+ * same full-speed step. A magnitude below 1 moves proportionally slower.
127
131
  */
128
132
  move: {
129
133
  x: number;