@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.
@@ -19,6 +19,14 @@ import {distanceTo} from '../utils/distance.js';
19
19
  import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
20
20
  import type {AnalyzedThreat} from './types.js';
21
21
 
22
+ /**
23
+ * Ticks lost to cancel() before a new cast can begin.
24
+ *
25
+ * The guide states it: cancel() "returns you to idle immediately and costs NO cooldown, but
26
+ * it takes effect after this tick's cast check — so you can shield on the NEXT tick".
27
+ */
28
+ const CANCEL_COST_TICKS = 1;
29
+
22
30
  // Maximum distance at which a missile is still tracked as a potential threat
23
31
  // (even if simulation says it will miss — homing missiles can change course)
24
32
  const THREAT_RELEVANCE_DISTANCE = 500;
@@ -30,6 +38,9 @@ const THREAT_RELEVANCE_DISTANCE = 500;
30
38
  * @param projectiles - All projectiles in the game
31
39
  * @param myProjectiles - Only the bot's own projectiles (used for filtering)
32
40
  * @param ticksUntilReady - Ticks until wizard can start a new action
41
+ * @param options - `canCancelCurrentCast` when the wizard is mid-CAST and could cancel()
42
+ * out of it for one tick's cost. Defaults to false, which is the safe
43
+ * reading for a caller that does not know its own state.
33
44
  * @returns Array of analyzed threats sorted by ticksToImpact (soonest first)
34
45
  */
35
46
  export function analyzeThreats(
@@ -37,6 +48,7 @@ export function analyzeThreats(
37
48
  projectiles: ProjectileState[],
38
49
  myProjectiles: ProjectileState[],
39
50
  ticksUntilReady: number,
51
+ options?: {canCancelCurrentCast?: boolean},
40
52
  ): AnalyzedThreat[]
41
53
  {
42
54
  // Filter to enemy projectiles by excluding our own
@@ -48,7 +60,7 @@ export function analyzeThreats(
48
60
 
49
61
  for (const projectile of enemyProjectiles)
50
62
  {
51
- const analysis = analyzeOneThreat(myPos, projectile, ticksUntilReady);
63
+ const analysis = analyzeOneThreat(myPos, projectile, ticksUntilReady, options?.canCancelCurrentCast ?? false);
52
64
  // Only include if it will hit or missile is within reasonable distance
53
65
  const missileDistance = Math.sqrt(
54
66
  (projectile.position.x - myPos.x) ** 2 +
@@ -74,6 +86,7 @@ function analyzeOneThreat(
74
86
  targetPos: Position,
75
87
  projectile: ProjectileState,
76
88
  ticksUntilReady: number,
89
+ canCancelCurrentCast: boolean,
77
90
  ): AnalyzedThreat
78
91
  {
79
92
  // Ask the ENGINE for the collision radius rather than re-deriving it.
@@ -119,16 +132,26 @@ function analyzeOneThreat(
119
132
  canDodgeLeft,
120
133
  canDodgeRight,
121
134
  canOutrun,
135
+ ticksToImpact,
122
136
  );
123
137
  }
124
138
 
125
- // Calculate shield timing
126
- // Can block if: ticksUntilReady + shield cast time < ticksToImpact
127
- // Stryker disable next-line EqualityOperator,ArithmeticOperator: ±1 tick boundary is intentional buffer, not precisely testable
128
- const canBlockInTime = ticksToImpact > ticksUntilReady + RULES.SHIELD_CAST_TIME + 1;
139
+ // Shield timing. These two fields MUST be satisfiable together: a bot acts on
140
+ // `canBlockInTime && ticksToStartShield === 0`, so if they can never both hold the bot
141
+ // never shields. They used to disagree canBlockInTime charged the wizard the whole
142
+ // remainder of its cast while ticksToStartShield ignored the cast entirely — and mid-cast
143
+ // that made them mutually exclusive. Measured cost: zero shields raised in a full match.
144
+ //
145
+ // The pessimism was wrong as well as inconsistent. cancel() leaves a CAST for one tick's
146
+ // cost, so a casting wizard's real delay is 1, not the couple of hundred ticks remaining.
147
+ // A GCD is different: nothing can be started during it and it cannot be cancelled, so
148
+ // there the full ticksUntilReady genuinely does apply.
149
+ const delayBeforeShield = canCancelCurrentCast ? CANCEL_COST_TICKS : ticksUntilReady;
129
150
 
130
- // When to start casting shield (leave 1 tick buffer)
131
- const ticksToStartShield = Math.max(0, ticksToImpact - RULES.SHIELD_CAST_TIME - 1);
151
+ // One tick of slack, so the window is not a single exact frame.
152
+ const ticksNeededToBlock = delayBeforeShield + RULES.SHIELD_CAST_TIME + 1;
153
+ const canBlockInTime = ticksToImpact >= ticksNeededToBlock;
154
+ const ticksToStartShield = Math.max(0, ticksToImpact - ticksNeededToBlock);
132
155
 
133
156
  return {
134
157
  id: projectile.id,
@@ -244,12 +267,14 @@ function simulateDodge(
244
267
  // Stryker disable next-line ArithmeticOperator: same as above
245
268
  const newY = targetPos.y + dodgeDir.y * RULES.MOVEMENT_SPEED;
246
269
 
247
- // Stryker disable all: arena clamp sign +/- COLLISION_RADIUS equivalent (wizard rarely reaches exact wall boundary during dodge)
270
+ // Clamped to the survivable band, which models "dodge as far as you safely can"
271
+ // the wizard stops at the lava rather than walking into it. Feasibility is judged on
272
+ // that basis; whether the ADVICE points at the lava is decided in
273
+ // calculateBestDodgeDirection, which is where the real defect was.
248
274
  targetPos = {
249
275
  x: Math.max(ARENA_MIN + COLLISION_RADIUS, Math.min(ARENA_MAX - COLLISION_RADIUS, newX)),
250
276
  y: Math.max(ARENA_MIN + COLLISION_RADIUS, Math.min(ARENA_MAX - COLLISION_RADIUS, newY)),
251
277
  };
252
- // Stryker restore all
253
278
 
254
279
  // Stryker disable next-line ConditionalExpression,EqualityOperator: turnRate>=0 equivalent (homing with rate 0 is no-op)
255
280
  if (turnRate > 0)
@@ -341,35 +366,71 @@ function getDodgeDirection(
341
366
  /**
342
367
  * Calculate the best dodge direction based on available options.
343
368
  */
369
+ /**
370
+ * How far a wizard can travel from `from` along `dir` before its edge touches lava.
371
+ *
372
+ * Used only to break a tie between two dodges that both work. Larger is better: it is the
373
+ * margin the bot has if it keeps moving, which is what bots actually do.
374
+ */
375
+ function roomBeforeLava(from: Position, dir: Position): number
376
+ {
377
+ const min = ARENA_MIN + COLLISION_RADIUS;
378
+ const max = ARENA_MAX - COLLISION_RADIUS;
379
+ const along = (pos: number, d: number, lo: number, hi: number): number =>
380
+ {
381
+ // A component that is effectively zero means this axis never runs out — not that it
382
+ // runs out immediately. Comparing against exact zero was not enough: standing on the
383
+ // boundary, a direction of (1, -1.8e-16) gave (35 - 35) / -1.8e-16 = 0, so the SAFE
384
+ // direction scored no room and lost to the lava-ward one. And standing exactly on the
385
+ // boundary is precisely what clampToSafeZone hands back.
386
+ if (Math.abs(d) < 1e-9) return Number.POSITIVE_INFINITY;
387
+ return d > 0 ? (hi - pos) / d : (lo - pos) / d;
388
+ };
389
+ // The binding axis is whichever runs out first.
390
+ return Math.max(0, Math.min(along(from.x, dir.x, min, max), along(from.y, dir.y, min, max)));
391
+ }
392
+
344
393
  function calculateBestDodgeDirection(
345
394
  projectile: ProjectileState,
346
395
  targetPos: Position,
347
396
  canDodgeLeft: boolean,
348
397
  canDodgeRight: boolean,
349
398
  canOutrun: boolean,
399
+ ticksToImpact: number,
350
400
  ): Position | null
351
401
  {
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)
402
+ // Every feasible candidate is checked for whether you SURVIVE it, not just the two-sided
403
+ // case. The first version of this fix guarded only `canDodgeLeft && canDodgeRight`, so
404
+ // when one side was feasible it was returned unchecked — and simulateDodge clamps the
405
+ // wizard at the band edge, so it calls a dodge feasible when following it walks into the
406
+ // lava. A sweep of 38,280 suggestions found 114 still doing that.
407
+ // A dodge is only worth recommending if the wizard is still alive at impact.
408
+ const travel = RULES.MOVEMENT_SPEED * Math.max(1, Math.min(ticksToImpact, 600));
409
+ const survives = (dir: Position): boolean => roomBeforeLava(targetPos, dir) >= travel;
410
+
411
+ // Sidesteps first, outrunning last — a sidestep leaves the missile's path while outrunning
412
+ // merely delays it, and that ordering predates the lava work. Room only breaks the tie
413
+ // BETWEEN the two sidesteps.
414
+ const sides: Position[] = [];
415
+ if (canDodgeLeft) sides.push(getDodgeDirection(projectile, targetPos, 'left'));
416
+ if (canDodgeRight) sides.push(getDodgeDirection(projectile, targetPos, 'right'));
417
+
418
+ const safeSides = sides.filter(survives);
419
+ if (safeSides.length > 0)
354
420
  {
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');
421
+ return safeSides.reduce((best, dir) =>
422
+ (roomBeforeLava(targetPos, dir) > roomBeforeLava(targetPos, best) ? dir : best));
358
423
  }
359
- else if (canDodgeLeft)
360
- {
361
- return getDodgeDirection(projectile, targetPos, 'left');
362
- }
363
- else if (canDodgeRight)
364
- {
365
- return getDodgeDirection(projectile, targetPos, 'right');
366
- }
367
- else if (canOutrun)
424
+
425
+ if (canOutrun)
368
426
  {
369
- return getDodgeDirection(projectile, targetPos, 'away');
427
+ const away = getDodgeDirection(projectile, targetPos, 'away');
428
+ if (survives(away)) return away;
370
429
  }
371
430
 
372
- // No dodge available
431
+ // Nothing survivable. Saying so is the honest answer: a caller reading null shields or
432
+ // blinks instead, which is the right response to "there is nowhere to go". Naming a
433
+ // direction that kills you is not a dodge.
373
434
  return null;
374
435
  }
375
436
 
@@ -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;
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
@@ -44,6 +44,7 @@ export interface WizardState
44
44
  lastMissileConfig?: MissileConfig; // last fired missile (for warmup system)
45
45
  warmupMultiplier?: number; // warmup multiplier applied to most recent missile cast (0.80 = full bonus, 1.20 = full penalty)
46
46
  invincible?: boolean; // manual play mode: damage is ignored when true
47
+ advised?: {nonFinite?: boolean; unfinishableCast?: boolean}; // engine advisories already given, so each is reported once per match
47
48
  }
48
49
 
49
50
  /**
@@ -113,17 +114,21 @@ export interface GameState
113
114
  * Actions returned by wizard each tick.
114
115
  *
115
116
  * 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)
117
+ * - x: positive = right, negative = left
118
+ * - y: positive = down, negative = up
119
+ * - Only the DIRECTION matters: the vector is normalised, and magnitude is capped at 1.
120
+ * move(1, 0), move(5, 0) and move(100, 0) are identical; move(0.5, 0) is half speed.
119
121
  * - No rotation tracking - just output (x, y) direction
122
+ *
123
+ * The old wording here described a [-100, 100] scale, which has not been true for a long
124
+ * time and is 100x off.
120
125
  */
121
126
  export interface WizardActions
122
127
  {
123
128
  /**
124
- * Movement direction in world-space.
125
- * Values are clamped to [-100, 100] range.
126
- * Magnitude is normalized to max 100 for diagonal movement.
129
+ * Movement direction in world-space. Only the direction matters — the vector is
130
+ * normalised and its magnitude is capped at 1, so move(100, 0) and move(1, 0) are the
131
+ * same full-speed step. A magnitude below 1 moves proportionally slower.
127
132
  */
128
133
  move: {
129
134
  x: number;