@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
|
@@ -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
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
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
|
-
//
|
|
131
|
-
const
|
|
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
|
-
//
|
|
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
|
-
//
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
return getDodgeDirection(projectile, targetPos, 'left');
|
|
421
|
+
return safeSides.reduce((best, dir) =>
|
|
422
|
+
(roomBeforeLava(targetPos, dir) > roomBeforeLava(targetPos, best) ? dir : best));
|
|
358
423
|
}
|
|
359
|
-
|
|
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
|
-
|
|
427
|
+
const away = getDodgeDirection(projectile, targetPos, 'away');
|
|
428
|
+
if (survives(away)) return away;
|
|
370
429
|
}
|
|
371
430
|
|
|
372
|
-
//
|
|
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
|
|
package/src/hooks/types.ts
CHANGED
|
@@ -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
|
|
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:
|
|
117
|
-
* - y:
|
|
118
|
-
* -
|
|
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
|
-
*
|
|
126
|
-
*
|
|
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;
|