@vibemancer/core 0.1.4 → 0.1.6
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/README.md +2 -2
- package/dist/chunk-G5T65F3W.js +10617 -0
- package/dist/chunk-G5T65F3W.js.map +1 -0
- package/dist/index-browser.d.ts +1328 -1051
- package/dist/index-browser.js +59 -17
- package/dist/index.d.ts +53 -3
- package/dist/index.js +204 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -15
- package/src/banned-bot-names.ts +53 -0
- package/src/bots/Hero.ts +867 -0
- package/src/bots/berserker/01_Stormchaser.ts +162 -120
- package/src/bots/berserker/02_Stormcaller.ts +160 -116
- package/src/bots/berserker/03_Stormforger.ts +162 -129
- package/src/bots/caster/01_Flamecaller.ts +126 -84
- package/src/bots/caster/02_Pyromancer.ts +148 -101
- package/src/bots/caster/03_Infernalist.ts +180 -160
- package/src/bots/defensive/01_Turtle.ts +48 -44
- package/src/bots/defensive/02_Sentinel.ts +57 -53
- package/src/bots/defensive/03_Golem.ts +85 -97
- package/src/bots/duelist/01_Battlemage.ts +157 -122
- package/src/bots/duelist/02_Warmage.ts +166 -121
- package/src/bots/duelist/03_Archmage.ts +198 -178
- package/src/bots/homing/01_Bonemancer.ts +20 -32
- package/src/bots/homing/02_Lich.ts +145 -93
- package/src/bots/homing/03_Archlich.ts +129 -60
- package/src/bots/kiter/01_Spellspinner.ts +145 -107
- package/src/bots/kiter/02_Spellweaver.ts +153 -105
- package/src/bots/kiter/03_Spellbinder.ts +168 -123
- package/src/bots/melee/01_Shadowblade.ts +131 -75
- package/src/bots/melee/02_Nightblade.ts +174 -130
- package/src/bots/melee/03_Voidblade.ts +266 -168
- package/src/bots/registry.ts +44 -37
- package/src/bots/shared.ts +185 -25
- package/src/bots/sniper/01_Spellshot.ts +151 -98
- package/src/bots/sniper/02_Spelltracer.ts +173 -128
- package/src/bots/sniper/03_Spellseeker.ts +177 -152
- package/src/bots/standalone/Critter.ts +46 -52
- package/src/bots/standalone/Doombringer.ts +36 -40
- package/src/bots/standalone/Hogger.ts +120 -89
- package/src/bots/standalone/Rookie.ts +21 -23
- package/src/bots/standalone/TargetDummy.ts +5 -6
- package/src/bots/test/cheater.ts +91 -85
- package/src/bots/test/crasher.ts +26 -28
- package/src/engine/bundle-fight.ts +242 -0
- package/src/engine/hooks-runtime.ts +58 -18
- package/src/engine/manual-match.ts +33 -25
- package/src/engine/missile-templates.ts +25 -43
- package/src/engine/optimizer.ts +7 -7
- package/src/engine/params-runtime.ts +3 -3
- package/src/engine/physics.ts +33 -23
- package/src/engine/sandbox-browser.ts +8 -7
- package/src/engine/sandbox-compile.ts +1 -1
- package/src/engine/sandbox-harness.ts +299 -367
- package/src/engine/sandbox.ts +3 -3
- package/src/engine/simulation.ts +291 -76
- package/src/engine/spells.ts +9 -12
- package/src/engine-version.ts +1 -1
- package/src/hooks/action-builders.ts +76 -21
- package/src/hooks/index.ts +17 -9
- package/src/hooks/state-hooks.ts +61 -25
- package/src/hooks/threat-analysis.ts +44 -20
- package/src/hooks/types.ts +62 -5
- package/src/index-browser.ts +1 -0
- package/src/index.ts +3 -0
- package/src/rules.ts +209 -63
- package/src/stats.ts +2 -2
- package/src/testing.ts +6 -30
- package/src/trace.ts +63 -3
- package/src/types.ts +127 -14
- package/src/utils/angles.ts +1 -0
- package/src/utils/combat.ts +98 -6
- package/src/utils/spatial.ts +3 -3
- package/dist/chunk-74FFJCFF.js +0 -9140
- package/dist/chunk-74FFJCFF.js.map +0 -1
- package/src/hooks/bot-wrapper.ts +0 -84
package/src/bots/registry.ts
CHANGED
|
@@ -20,12 +20,15 @@
|
|
|
20
20
|
* | Berserker | Stormchaser | Stormcaller | Stormforger |
|
|
21
21
|
* | Kiter | Spellspinner | Spellweaver | Spellbinder |
|
|
22
22
|
*
|
|
23
|
-
* Standalone: TargetDummy, Critter, Hogger, Rookie, Doombringer
|
|
23
|
+
* Standalone: TargetDummy, Critter, Hogger, Rookie, Doombringer, Hero
|
|
24
|
+
* (Hero is the adaptive SHOWCASE — the #1 reference bot users build their own to beat.
|
|
25
|
+
* Deliberately kept as a single isolated bot, NOT a tier line: writing adaptive bots is
|
|
26
|
+
* the game, so the roster ships one exemplar and players make the rest.)
|
|
24
27
|
* Reserved: Spellslinger (future volume-of-fire archetype)
|
|
25
28
|
*
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
|
-
import {WizardFunction} from '../types.js';
|
|
31
|
+
import type {WizardFunction} from '../hooks/types.js';
|
|
29
32
|
import {TargetDummy} from './standalone/TargetDummy.js';
|
|
30
33
|
import {Critter} from './standalone/Critter.js';
|
|
31
34
|
import {Rookie} from './standalone/Rookie.js';
|
|
@@ -55,8 +58,9 @@ import {Stormforger} from './berserker/03_Stormforger.js';
|
|
|
55
58
|
import {Spellspinner} from './kiter/01_Spellspinner.js';
|
|
56
59
|
import {Spellweaver} from './kiter/02_Spellweaver.js';
|
|
57
60
|
import {Spellbinder} from './kiter/03_Spellbinder.js';
|
|
61
|
+
import {Hero} from './Hero.js';
|
|
58
62
|
|
|
59
|
-
export interface
|
|
63
|
+
export interface WizardEntry
|
|
60
64
|
{
|
|
61
65
|
name: string;
|
|
62
66
|
ai: WizardFunction;
|
|
@@ -65,17 +69,17 @@ export interface BotEntry
|
|
|
65
69
|
group: string; // progression line name or 'Standalone'
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
export interface
|
|
72
|
+
export interface WizardGroup
|
|
69
73
|
{
|
|
70
74
|
label: string;
|
|
71
|
-
bots:
|
|
75
|
+
bots: WizardEntry[];
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/**
|
|
75
79
|
* All bots organized by progression line.
|
|
76
80
|
* Each group contains bots from the same archetype, ordered by tier.
|
|
77
81
|
*/
|
|
78
|
-
export const BOT_GROUPS:
|
|
82
|
+
export const BOT_GROUPS: WizardGroup[] = [
|
|
79
83
|
{
|
|
80
84
|
label: 'Standalone',
|
|
81
85
|
bots: [
|
|
@@ -84,6 +88,7 @@ export const BOT_GROUPS: BotGroup[] = [
|
|
|
84
88
|
{name: 'Rookie', ai: Rookie, description: 'Basic homing missiles', group: 'Standalone'},
|
|
85
89
|
{name: 'Hogger', ai: Hogger, description: 'Chaotic elite — random but dangerous', group: 'Standalone'},
|
|
86
90
|
{name: 'Doombringer', ai: Doombringer, description: 'Single max-damage shot benchmark', group: 'Standalone'},
|
|
91
|
+
{name: 'Hero', ai: Hero, description: 'Adaptive SHOWCASE (#1 of 30, beats 28/29) — the reference bot players build their own to beat. Reads each opponent and counters: range/missile-style classification, hard-homing defense, finisher, plus signature-triggered modes — center-hold vs the max-damage shot, anti-sniper (shield-piercing + cadence yomi + range buffer), and reactive delegation that switches strategy when it detects it is being hard-countered, and a mirror-detector (holds distance vs a foe firing its own missile config) that makes the Archmage mirror an even matchup. Has NO losing matchup — beats 28 of 29 and draws its own mirror.', group: 'Standalone'},
|
|
87
92
|
],
|
|
88
93
|
},
|
|
89
94
|
{
|
|
@@ -153,7 +158,7 @@ export const BOT_GROUPS: BotGroup[] = [
|
|
|
153
158
|
];
|
|
154
159
|
|
|
155
160
|
// Flat map for lookup by name
|
|
156
|
-
const BOT_MAP = new Map<string,
|
|
161
|
+
const BOT_MAP = new Map<string, WizardEntry>();
|
|
157
162
|
for (const group of BOT_GROUPS)
|
|
158
163
|
{
|
|
159
164
|
for (const bot of group.bots)
|
|
@@ -168,38 +173,40 @@ for (const group of BOT_GROUPS)
|
|
|
168
173
|
* To update: run `npm run tournament`
|
|
169
174
|
*/
|
|
170
175
|
const ALL_BOTS_ORDER = [
|
|
171
|
-
|
|
172
|
-
'
|
|
173
|
-
'
|
|
174
|
-
'Bonemancer', //
|
|
175
|
-
'
|
|
176
|
-
'
|
|
177
|
-
'
|
|
178
|
-
'Flamecaller', //
|
|
179
|
-
'
|
|
180
|
-
'
|
|
181
|
-
'
|
|
182
|
-
'
|
|
183
|
-
'
|
|
184
|
-
'
|
|
185
|
-
'
|
|
186
|
-
'
|
|
187
|
-
'
|
|
188
|
-
'Pyromancer', //
|
|
189
|
-
'
|
|
190
|
-
'Archlich', //
|
|
191
|
-
'
|
|
192
|
-
'
|
|
193
|
-
'
|
|
194
|
-
'
|
|
195
|
-
'
|
|
196
|
-
'
|
|
197
|
-
'
|
|
198
|
-
'
|
|
199
|
-
'
|
|
176
|
+
// Weakest → strongest, per the 30-bot round-robin (npm run tournament, 2026-06-16).
|
|
177
|
+
'TargetDummy', // 0 pts
|
|
178
|
+
'Critter', // 3
|
|
179
|
+
'Bonemancer', // 12
|
|
180
|
+
'Hogger', // 12
|
|
181
|
+
'Rookie', // 15
|
|
182
|
+
'Turtle', // 36
|
|
183
|
+
'Flamecaller', // 37
|
|
184
|
+
'Spellseeker', // 39
|
|
185
|
+
'Stormchaser', // 39 (dropped — Hero now beats it via the berserker counter)
|
|
186
|
+
'Nightblade', // 39
|
|
187
|
+
'Battlemage', // 40
|
|
188
|
+
'Spellweaver', // 42
|
|
189
|
+
'Stormforger', // 42
|
|
190
|
+
'Archmage', // 43 (dropped — Hero now DRAWS the mirror instead of losing it)
|
|
191
|
+
'Spellspinner', // 44
|
|
192
|
+
'Sentinel', // 45
|
|
193
|
+
'Pyromancer', // 45
|
|
194
|
+
'Shadowblade', // 45
|
|
195
|
+
'Archlich', // 46 (Hero beats it via reactive delegation to Spellshot)
|
|
196
|
+
'Spellbinder', // 48
|
|
197
|
+
'Warmage', // 51
|
|
198
|
+
'Doombringer', // 51
|
|
199
|
+
'Golem', // 55
|
|
200
|
+
'Stormcaller', // 59
|
|
201
|
+
'Voidblade', // 61
|
|
202
|
+
'Lich', // 62
|
|
203
|
+
'Infernalist', // 63
|
|
204
|
+
'Spelltracer', // 67
|
|
205
|
+
'Spellshot', // 70 (#2 — the sniper champion; Hero beats it head-to-head)
|
|
206
|
+
'Hero', // 85 (#1 — adaptive; beats 28 of 29 and DRAWS its Archmage mirror → no losing matchup)
|
|
200
207
|
];
|
|
201
208
|
|
|
202
|
-
export const ALL_BOTS:
|
|
209
|
+
export const ALL_BOTS: WizardEntry[] = ALL_BOTS_ORDER.map((name) =>
|
|
203
210
|
{
|
|
204
211
|
const bot = BOT_MAP.get(name);
|
|
205
212
|
if (!bot) throw new Error(`Bot "${name}" not found in BOT_GROUPS`);
|
package/src/bots/shared.ts
CHANGED
|
@@ -8,10 +8,166 @@ import {GameState, Position, ProjectileState} from '../types.js';
|
|
|
8
8
|
import {analyzeThreats} from '../hooks/threat-analysis.js';
|
|
9
9
|
import type {AnalyzedThreat} from '../hooks/types.js';
|
|
10
10
|
import {distanceTo} from '../utils/distance.js';
|
|
11
|
-
import {
|
|
11
|
+
import {RULES, WIZARD_RADIUS, ARENA_MIN, ARENA_MAX} from '../rules.js';
|
|
12
|
+
|
|
13
|
+
const LAVA_SAFE_MIN = ARENA_MIN + WIZARD_RADIUS + 2;
|
|
14
|
+
const LAVA_SAFE_MAX = ARENA_MAX - WIZARD_RADIUS - 2;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Clamp a movement vector so the wizard doesn't walk into lava.
|
|
18
|
+
*/
|
|
19
|
+
export function avoidLava(position: Position, move: {x: number; y: number}): {x: number; y: number}
|
|
20
|
+
{
|
|
21
|
+
let {x, y} = move;
|
|
22
|
+
if (position.x <= LAVA_SAFE_MIN && x < 0) x = 0;
|
|
23
|
+
if (position.x >= LAVA_SAFE_MAX && x > 0) x = 0;
|
|
24
|
+
if (position.y <= LAVA_SAFE_MIN && y < 0) y = 0;
|
|
25
|
+
if (position.y >= LAVA_SAFE_MAX && y > 0) y = 0;
|
|
26
|
+
return {x, y};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Distance from the lava-safe boundary at which a wizard starts steering back
|
|
31
|
+
* toward center. Sized to exceed a single missile's knockback reach so a bot that
|
|
32
|
+
* holds this margin can't be punched through the border by one hit.
|
|
33
|
+
*/
|
|
34
|
+
const WALL_DANGER = 110;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wall-recovery steering — the wall-awareness every bot needs so it never gets
|
|
38
|
+
* cornered into lava (by knockback drift or by tight kiting).
|
|
39
|
+
*
|
|
40
|
+
* In the open interior this returns `desiredMove` unchanged (a stationary bot
|
|
41
|
+
* passing {0,0} stays put). As the wizard nears a wall it blends in a push toward
|
|
42
|
+
* arena center, growing stronger the closer to the edge and fully overriding any
|
|
43
|
+
* outward move at the boundary, so the wizard actively un-corners itself instead
|
|
44
|
+
* of drifting in. The result never contains a component pointing further into lava.
|
|
45
|
+
*/
|
|
46
|
+
export function keepOffWalls(position: Position, desiredMove: {x: number; y: number} = {x: 0, y: 0}): {x: number; y: number}
|
|
47
|
+
{
|
|
48
|
+
let cx = 0;
|
|
49
|
+
let cy = 0;
|
|
50
|
+
const distLeft = position.x - LAVA_SAFE_MIN;
|
|
51
|
+
const distRight = LAVA_SAFE_MAX - position.x;
|
|
52
|
+
const distTop = position.y - LAVA_SAFE_MIN;
|
|
53
|
+
const distBottom = LAVA_SAFE_MAX - position.y;
|
|
54
|
+
if (distLeft < WALL_DANGER) cx += (WALL_DANGER - Math.max(0, distLeft)) / WALL_DANGER;
|
|
55
|
+
if (distRight < WALL_DANGER) cx -= (WALL_DANGER - Math.max(0, distRight)) / WALL_DANGER;
|
|
56
|
+
if (distTop < WALL_DANGER) cy += (WALL_DANGER - Math.max(0, distTop)) / WALL_DANGER;
|
|
57
|
+
if (distBottom < WALL_DANGER) cy -= (WALL_DANGER - Math.max(0, distBottom)) / WALL_DANGER;
|
|
58
|
+
|
|
59
|
+
const urgency = Math.min(1, Math.max(Math.abs(cx), Math.abs(cy)));
|
|
60
|
+
if (urgency <= 0) return desiredMove;
|
|
61
|
+
|
|
62
|
+
// Normalize an over-long desired move so the blend weights stay meaningful.
|
|
63
|
+
const mag = Math.hypot(desiredMove.x, desiredMove.y);
|
|
64
|
+
const dir = mag > 1 ? {x: desiredMove.x / mag, y: desiredMove.y / mag} : desiredMove;
|
|
65
|
+
const blended = {
|
|
66
|
+
x: dir.x * (1 - urgency) + cx * urgency,
|
|
67
|
+
y: dir.y * (1 - urgency) + cy * urgency,
|
|
68
|
+
};
|
|
69
|
+
return avoidLava(position, blended);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface LavaEvade
|
|
73
|
+
{
|
|
74
|
+
action: 'blink' | 'move';
|
|
75
|
+
x: number;
|
|
76
|
+
y: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Max distance a single missile of `damage` can knock a wizard (decaying-impulse sum). */
|
|
80
|
+
function knockbackReach(damage: number): number
|
|
81
|
+
{
|
|
82
|
+
const over = damage - RULES.KNOCKBACK_DAMAGE_THRESHOLD;
|
|
83
|
+
return over > 0 ? (over * RULES.KNOCKBACK_SPEED_PER_DAMAGE) / (1 - RULES.KNOCKBACK_DECAY) : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Distance from `pos` along (dx,dy) until it crosses the lava-safe boundary (∞ if never). */
|
|
87
|
+
function distToLavaAlong(pos: Position, dx: number, dy: number): number
|
|
88
|
+
{
|
|
89
|
+
let t = Infinity;
|
|
90
|
+
if (dx > 1e-6) t = Math.min(t, (LAVA_SAFE_MAX - pos.x) / dx);
|
|
91
|
+
else if (dx < -1e-6) t = Math.min(t, (LAVA_SAFE_MIN - pos.x) / dx);
|
|
92
|
+
if (dy > 1e-6) t = Math.min(t, (LAVA_SAFE_MAX - pos.y) / dy);
|
|
93
|
+
else if (dy < -1e-6) t = Math.min(t, (LAVA_SAFE_MIN - pos.y) / dy);
|
|
94
|
+
return Math.max(0, t);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Emergency lava-evade — the skill that makes a knockback lava death the bot's fault.
|
|
99
|
+
*
|
|
100
|
+
* Knockback ignores shields, so a damaging missile that would shove this wizard past
|
|
101
|
+
* the lava border (in the away-from-enemy direction it gets knocked) is a death you
|
|
102
|
+
* can only avoid by NOT being hit. When such a hit is imminent this returns a blink
|
|
103
|
+
* clear of it — toward center — if blink is ready, else a dodge; to be taken with TOP
|
|
104
|
+
* priority, AHEAD of shielding. Returns null when no incoming hit would reach lava
|
|
105
|
+
* (run the normal defense). If you're cornered with blink down and can't dodge, it
|
|
106
|
+
* returns null and you eat it — you messed up.
|
|
107
|
+
*/
|
|
108
|
+
export function getLavaEvade(
|
|
109
|
+
position: Position,
|
|
110
|
+
enemyPos: Position,
|
|
111
|
+
threats: AnalyzedThreat[],
|
|
112
|
+
blinkCooldown: number,
|
|
113
|
+
horizonTicks = 40,
|
|
114
|
+
): LavaEvade | null
|
|
115
|
+
{
|
|
116
|
+
const ax = position.x - enemyPos.x;
|
|
117
|
+
const ay = position.y - enemyPos.y;
|
|
118
|
+
const amag = Math.hypot(ax, ay) || 1;
|
|
119
|
+
const distToWall = distToLavaAlong(position, ax / amag, ay / amag);
|
|
120
|
+
|
|
121
|
+
for (const threat of threats)
|
|
122
|
+
{
|
|
123
|
+
if (!threat.willHit || threat.ticksToImpact > horizonTicks) continue;
|
|
124
|
+
const reach = knockbackReach(threat.projectile.damage);
|
|
125
|
+
if (reach <= 0 || reach + WIZARD_RADIUS < distToWall) continue; // this hit can't reach lava
|
|
126
|
+
|
|
127
|
+
// A lava-lethal hit is imminent — must not get hit (shields don't stop knockback).
|
|
128
|
+
if (blinkCooldown === 0 && threat.ticksToImpact >= RULES.BLINK_CAST_TIME)
|
|
129
|
+
{
|
|
130
|
+
const target = getBlinkToCenter(position);
|
|
131
|
+
return {action: 'blink', x: target.x, y: target.y};
|
|
132
|
+
}
|
|
133
|
+
const dodge = getDodgeDirectionForMovement([threat], position);
|
|
134
|
+
if (dodge)
|
|
135
|
+
{
|
|
136
|
+
const safe = keepOffWalls(position, dodge);
|
|
137
|
+
return {action: 'move', x: safe.x, y: safe.y};
|
|
138
|
+
}
|
|
139
|
+
const recenter = keepOffWalls(position);
|
|
140
|
+
if (recenter.x !== 0 || recenter.y !== 0) return {action: 'move', x: recenter.x, y: recenter.y};
|
|
141
|
+
return null; // cornered, blink down, can't dodge — you messed up
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pre-cast wall discipline. A bot committed to a long cast can't react for its whole
|
|
148
|
+
* cast time, so casting near a wall means a knockback lava death — its own fault. If
|
|
149
|
+
* there isn't `margin` units of room toward the away-from-enemy wall (where knockback
|
|
150
|
+
* shoves it), this returns a move toward arena center to take FIRST, before the cast;
|
|
151
|
+
* else null (safe to commit).
|
|
152
|
+
*/
|
|
153
|
+
export function getPreCastReposition(position: Position, enemyPos: Position, margin?: number): {x: number; y: number} | null
|
|
154
|
+
{
|
|
155
|
+
// Default margin = the reach of a solid (damage-30) hit + a buffer, so it scales
|
|
156
|
+
// with the ruleset's knockback (a heavier-knockback ruleset demands more room).
|
|
157
|
+
const safeMargin = margin ?? knockbackReach(30) + WIZARD_RADIUS + 30;
|
|
158
|
+
const ax = position.x - enemyPos.x;
|
|
159
|
+
const ay = position.y - enemyPos.y;
|
|
160
|
+
const amag = Math.hypot(ax, ay) || 1;
|
|
161
|
+
if (distToLavaAlong(position, ax / amag, ay / amag) >= safeMargin) return null;
|
|
162
|
+
const center = (ARENA_MIN + ARENA_MAX) / 2;
|
|
163
|
+
const cx = center - position.x;
|
|
164
|
+
const cy = center - position.y;
|
|
165
|
+
const cmag = Math.hypot(cx, cy) || 1;
|
|
166
|
+
return {x: cx / cmag, y: cy / cmag};
|
|
167
|
+
}
|
|
12
168
|
|
|
13
169
|
// Re-export public utility so bots can import from shared
|
|
14
|
-
export {fitMissileToBudget} from '../utils/combat.js';
|
|
170
|
+
export {fitMissileToBudget, fitMissileForEscapingTarget} from '../utils/combat.js';
|
|
15
171
|
|
|
16
172
|
// T2 bots: max cast time when no threats are incoming (~2s)
|
|
17
173
|
export const MAX_CAST_BUDGET = 200;
|
|
@@ -61,12 +217,14 @@ export function getMostUrgentThreat(threats: AnalyzedThreat[]): AnalyzedThreat |
|
|
|
61
217
|
|
|
62
218
|
/**
|
|
63
219
|
* Determine if a threat should force a shield even when a dodge direction exists.
|
|
64
|
-
* High-damage homing missiles are too risky to dodge
|
|
65
|
-
*
|
|
220
|
+
* - High-damage homing missiles are too risky to dodge (missile tracks you)
|
|
221
|
+
* - Close-range threats arrive before you can strafe clear (need ~10 ticks to dodge)
|
|
66
222
|
*/
|
|
67
223
|
export function shouldForceShield(threat: AnalyzedThreat): boolean
|
|
68
224
|
{
|
|
69
|
-
|
|
225
|
+
if (threat.projectile.damage >= 25 && threat.projectile.turnRate > 0) return true;
|
|
226
|
+
if (threat.ticksToImpact <= 12 && threat.projectile.damage >= 10) return true;
|
|
227
|
+
return false;
|
|
70
228
|
}
|
|
71
229
|
|
|
72
230
|
/**
|
|
@@ -126,8 +284,8 @@ export function getDodgeDirectionForMovement(
|
|
|
126
284
|
// Tiebreaker for dead-center missiles: pick the side further from walls
|
|
127
285
|
const futureA = {x: myPos.x + dodgeCCW.x * 100, y: myPos.y + dodgeCCW.y * 100};
|
|
128
286
|
const futureB = {x: myPos.x + dodgeCW.x * 100, y: myPos.y + dodgeCW.y * 100};
|
|
129
|
-
const scoreA = Math.min(futureA.x,
|
|
130
|
-
const scoreB = Math.min(futureB.x,
|
|
287
|
+
const scoreA = Math.min(futureA.x - ARENA_MIN, ARENA_MAX - futureA.x, futureA.y - ARENA_MIN, ARENA_MAX - futureA.y);
|
|
288
|
+
const scoreB = Math.min(futureB.x - ARENA_MIN, ARENA_MAX - futureB.x, futureB.y - ARENA_MIN, ARENA_MAX - futureB.y);
|
|
131
289
|
|
|
132
290
|
return scoreA >= scoreB ? dodgeCCW : dodgeCW;
|
|
133
291
|
}
|
|
@@ -143,8 +301,8 @@ export function getDodgeDirectionForMovement(
|
|
|
143
301
|
export function isSafeToAttack(
|
|
144
302
|
threats: AnalyzedThreat[],
|
|
145
303
|
castTicks: number,
|
|
146
|
-
gcdTicks: number = GCD_DURATION,
|
|
147
|
-
shieldCastTicks: number = SHIELD_CAST_TIME,
|
|
304
|
+
gcdTicks: number = RULES.GCD_DURATION,
|
|
305
|
+
shieldCastTicks: number = RULES.SHIELD_CAST_TIME,
|
|
148
306
|
): boolean
|
|
149
307
|
{
|
|
150
308
|
// No threats = safe
|
|
@@ -188,8 +346,8 @@ export function isSafeToAttack(
|
|
|
188
346
|
*/
|
|
189
347
|
export function getBlinkToCenter(pos: Position): Position
|
|
190
348
|
{
|
|
191
|
-
const centerX =
|
|
192
|
-
const centerY =
|
|
349
|
+
const centerX = (ARENA_MIN + ARENA_MAX) / 2;
|
|
350
|
+
const centerY = (ARENA_MIN + ARENA_MAX) / 2;
|
|
193
351
|
const dx = centerX - pos.x;
|
|
194
352
|
const dy = centerY - pos.y;
|
|
195
353
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
@@ -197,8 +355,8 @@ export function getBlinkToCenter(pos: Position): Position
|
|
|
197
355
|
if (dist === 0) return {x: centerX, y: centerY};
|
|
198
356
|
|
|
199
357
|
return {
|
|
200
|
-
x: pos.x + (dx / dist) * BLINK_RANGE,
|
|
201
|
-
y: pos.y + (dy / dist) * BLINK_RANGE,
|
|
358
|
+
x: Math.max(LAVA_SAFE_MIN, Math.min(LAVA_SAFE_MAX, pos.x + (dx / dist) * RULES.BLINK_RANGE)),
|
|
359
|
+
y: Math.max(LAVA_SAFE_MIN, Math.min(LAVA_SAFE_MAX, pos.y + (dy / dist) * RULES.BLINK_RANGE)),
|
|
202
360
|
};
|
|
203
361
|
}
|
|
204
362
|
|
|
@@ -207,7 +365,7 @@ export function getBlinkToCenter(pos: Position): Position
|
|
|
207
365
|
// ============================================================================
|
|
208
366
|
|
|
209
367
|
// Post-blink vulnerability: shield cast time before wizard can defend (blink has no GCD)
|
|
210
|
-
const POST_BLINK_VULNERABILITY = SHIELD_CAST_TIME;
|
|
368
|
+
const POST_BLINK_VULNERABILITY = RULES.SHIELD_CAST_TIME;
|
|
211
369
|
|
|
212
370
|
// Approximate hit radius for missile safety (wizard radius + typical missile radius)
|
|
213
371
|
const HIT_RADIUS = WIZARD_RADIUS + 3;
|
|
@@ -273,7 +431,7 @@ function findBestBlinkTarget(
|
|
|
273
431
|
if (currentDist < 0.01)
|
|
274
432
|
{
|
|
275
433
|
// Overlapping: fallback to toward center
|
|
276
|
-
baseAngle = Math.atan2(
|
|
434
|
+
baseAngle = Math.atan2((ARENA_MIN + ARENA_MAX) / 2 - myPos.y, (ARENA_MIN + ARENA_MAX) / 2 - myPos.x);
|
|
277
435
|
}
|
|
278
436
|
else
|
|
279
437
|
{
|
|
@@ -288,8 +446,8 @@ function findBestBlinkTarget(
|
|
|
288
446
|
|
|
289
447
|
// For decrease: don't overshoot past enemy
|
|
290
448
|
const maxBlinkDist = goal === 'decrease'
|
|
291
|
-
? Math.min(BLINK_RANGE, currentDist)
|
|
292
|
-
: BLINK_RANGE;
|
|
449
|
+
? Math.min(RULES.BLINK_RANGE, currentDist)
|
|
450
|
+
: RULES.BLINK_RANGE;
|
|
293
451
|
|
|
294
452
|
let bestTarget: Position | null = null;
|
|
295
453
|
let bestScore = -Infinity;
|
|
@@ -304,8 +462,8 @@ function findBestBlinkTarget(
|
|
|
304
462
|
const angle = baseAngle + (i * 2 * Math.PI) / ANGLE_CANDIDATES;
|
|
305
463
|
|
|
306
464
|
// Candidate position (clamped with wall buffer to prevent wall-hugging)
|
|
307
|
-
const cx = Math.max(BLINK_WALL_MARGIN, Math.min(
|
|
308
|
-
const cy = Math.max(BLINK_WALL_MARGIN, Math.min(
|
|
465
|
+
const cx = Math.max(ARENA_MIN + BLINK_WALL_MARGIN, Math.min(ARENA_MAX - BLINK_WALL_MARGIN, myPos.x + Math.cos(angle) * blinkDist));
|
|
466
|
+
const cy = Math.max(ARENA_MIN + BLINK_WALL_MARGIN, Math.min(ARENA_MAX - BLINK_WALL_MARGIN, myPos.y + Math.sin(angle) * blinkDist));
|
|
309
467
|
|
|
310
468
|
// Skip if clamping made this a non-blink
|
|
311
469
|
if (distanceTo(myPos, {x: cx, y: cy}) < 10) continue;
|
|
@@ -317,7 +475,7 @@ function findBestBlinkTarget(
|
|
|
317
475
|
const distScore = goal === 'increase' ? enemyDist : -enemyDist;
|
|
318
476
|
|
|
319
477
|
// Penalize distance from arena center (prevents corner-trapping)
|
|
320
|
-
const center =
|
|
478
|
+
const center = (ARENA_MIN + ARENA_MAX) / 2;
|
|
321
479
|
const distFromCenter = distanceTo(candidate, {x: center, y: center});
|
|
322
480
|
|
|
323
481
|
// Missile danger penalty
|
|
@@ -448,23 +606,25 @@ export function getClosingRate(
|
|
|
448
606
|
* GCD-locked → remaining GCD. Idle/channeling → 0.
|
|
449
607
|
*/
|
|
450
608
|
export function getEnemyVulnerabilityTicks(enemy: {
|
|
451
|
-
state
|
|
452
|
-
|
|
609
|
+
state?: string;
|
|
610
|
+
status?: string;
|
|
611
|
+
castingSpell?: string | null;
|
|
453
612
|
castProgress?: number;
|
|
454
613
|
castDuration?: number;
|
|
455
614
|
gcdRemaining?: number;
|
|
456
615
|
}): number
|
|
457
616
|
{
|
|
458
|
-
|
|
617
|
+
const enemyState = enemy.status ?? enemy.state;
|
|
618
|
+
if (enemyState === 'casting')
|
|
459
619
|
{
|
|
460
620
|
const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
461
621
|
if (enemy.castingSpell === 'missile')
|
|
462
622
|
{
|
|
463
|
-
return remainingCast + GCD_DURATION;
|
|
623
|
+
return remainingCast + RULES.GCD_DURATION;
|
|
464
624
|
}
|
|
465
625
|
return remainingCast;
|
|
466
626
|
}
|
|
467
|
-
if (
|
|
627
|
+
if (enemyState === 'gcd_locked')
|
|
468
628
|
{
|
|
469
629
|
return enemy.gcdRemaining ?? 0;
|
|
470
630
|
}
|