@vibemancer/core 0.1.0
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 +28 -0
- package/dist/chunk-L7Z7OFXD.js +9140 -0
- package/dist/chunk-L7Z7OFXD.js.map +1 -0
- package/dist/index-browser.d.ts +2602 -0
- package/dist/index-browser.js +407 -0
- package/dist/index-browser.js.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +750 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
- package/src/bots/berserker/01_Stormchaser.ts +457 -0
- package/src/bots/berserker/02_Stormcaller.ts +417 -0
- package/src/bots/berserker/03_Stormforger.ts +481 -0
- package/src/bots/caster/01_Flamecaller.ts +286 -0
- package/src/bots/caster/02_Pyromancer.ts +350 -0
- package/src/bots/caster/03_Infernalist.ts +492 -0
- package/src/bots/defensive/01_Turtle.ts +151 -0
- package/src/bots/defensive/02_Sentinel.ts +134 -0
- package/src/bots/defensive/03_Golem.ts +357 -0
- package/src/bots/duelist/01_Battlemage.ts +433 -0
- package/src/bots/duelist/02_Warmage.ts +438 -0
- package/src/bots/duelist/03_Archmage.ts +588 -0
- package/src/bots/homing/01_Bonemancer.ts +67 -0
- package/src/bots/homing/02_Lich.ts +356 -0
- package/src/bots/homing/03_Archlich.ts +220 -0
- package/src/bots/index.ts +30 -0
- package/src/bots/kiter/01_Spellspinner.ts +398 -0
- package/src/bots/kiter/02_Spellweaver.ts +378 -0
- package/src/bots/kiter/03_Spellbinder.ts +448 -0
- package/src/bots/melee/01_Shadowblade.ts +270 -0
- package/src/bots/melee/02_Nightblade.ts +437 -0
- package/src/bots/melee/03_Voidblade.ts +582 -0
- package/src/bots/registry.ts +207 -0
- package/src/bots/shared.ts +472 -0
- package/src/bots/sniper/01_Spellshot.ts +385 -0
- package/src/bots/sniper/02_Spelltracer.ts +441 -0
- package/src/bots/sniper/03_Spellseeker.ts +546 -0
- package/src/bots/standalone/Critter.ts +89 -0
- package/src/bots/standalone/Doombringer.ts +91 -0
- package/src/bots/standalone/Hogger.ts +228 -0
- package/src/bots/standalone/Rookie.ts +50 -0
- package/src/bots/standalone/TargetDummy.ts +21 -0
- package/src/bots/test/cheater.ts +405 -0
- package/src/bots/test/crasher.ts +81 -0
- package/src/engine/hooks-runtime.ts +394 -0
- package/src/engine/manual-match.ts +289 -0
- package/src/engine/missile-templates.ts +155 -0
- package/src/engine/optimizer.ts +220 -0
- package/src/engine/params-runtime.ts +189 -0
- package/src/engine/physics.ts +143 -0
- package/src/engine/sandbox-browser.ts +671 -0
- package/src/engine/sandbox-compile.ts +197 -0
- package/src/engine/sandbox-harness.ts +367 -0
- package/src/engine/sandbox.ts +332 -0
- package/src/engine/simulation.ts +828 -0
- package/src/engine/spells.ts +128 -0
- package/src/engine-version.ts +11 -0
- package/src/hooks/action-builders.ts +210 -0
- package/src/hooks/bot-wrapper.ts +84 -0
- package/src/hooks/index.ts +75 -0
- package/src/hooks/state-hooks.ts +354 -0
- package/src/hooks/threat-analysis.ts +365 -0
- package/src/hooks/types.ts +142 -0
- package/src/index-browser.ts +30 -0
- package/src/index.ts +24 -0
- package/src/rules.ts +254 -0
- package/src/stats.ts +262 -0
- package/src/testing.ts +207 -0
- package/src/trace.ts +430 -0
- package/src/types.ts +193 -0
- package/src/utils/angles.ts +47 -0
- package/src/utils/combat.ts +279 -0
- package/src/utils/distance.ts +21 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/movement.ts +108 -0
- package/src/utils/random.ts +65 -0
- package/src/utils/spatial.ts +63 -0
- package/src/utils/targeting.ts +45 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER - BOT REGISTRY
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for all bots, ordered from weakest to strongest.
|
|
5
|
+
* Run the tournament test to determine the correct ordering.
|
|
6
|
+
*
|
|
7
|
+
* To reorder: run `npx vitest run tests/bots/tournament.test.ts`
|
|
8
|
+
* and update the list below based on the results.
|
|
9
|
+
*
|
|
10
|
+
* BOT NAMING SCHEME (3-tier progression):
|
|
11
|
+
*
|
|
12
|
+
* | Group | Tier 1 (base) | Tier 2 (enhanced) | Tier 3 (elite) |
|
|
13
|
+
* |------------|----------------|-------------------|-----------------|
|
|
14
|
+
* | Defensive | Turtle | Sentinel | Golem |
|
|
15
|
+
* | Duelist | Battlemage | Warmage | Archmage |
|
|
16
|
+
* | Homing | Bonemancer | Lich | Archlich |
|
|
17
|
+
* | Caster | Flamecaller | Pyromancer | Infernalist |
|
|
18
|
+
* | Melee | Shadowblade | Nightblade | Voidblade |
|
|
19
|
+
* | Sniper | Spellshot | Spelltracer | Spellseeker |
|
|
20
|
+
* | Berserker | Stormchaser | Stormcaller | Stormforger |
|
|
21
|
+
* | Kiter | Spellspinner | Spellweaver | Spellbinder |
|
|
22
|
+
*
|
|
23
|
+
* Standalone: TargetDummy, Critter, Hogger, Rookie, Doombringer
|
|
24
|
+
* Reserved: Spellslinger (future volume-of-fire archetype)
|
|
25
|
+
*
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {WizardFunction} from '../types.js';
|
|
29
|
+
import {TargetDummy} from './standalone/TargetDummy.js';
|
|
30
|
+
import {Critter} from './standalone/Critter.js';
|
|
31
|
+
import {Rookie} from './standalone/Rookie.js';
|
|
32
|
+
import {Hogger} from './standalone/Hogger.js';
|
|
33
|
+
import {Doombringer} from './standalone/Doombringer.js';
|
|
34
|
+
import {Turtle} from './defensive/01_Turtle.js';
|
|
35
|
+
import {Sentinel} from './defensive/02_Sentinel.js';
|
|
36
|
+
import {Golem} from './defensive/03_Golem.js';
|
|
37
|
+
import {Battlemage} from './duelist/01_Battlemage.js';
|
|
38
|
+
import {Warmage} from './duelist/02_Warmage.js';
|
|
39
|
+
import {Archmage} from './duelist/03_Archmage.js';
|
|
40
|
+
import {Bonemancer} from './homing/01_Bonemancer.js';
|
|
41
|
+
import {Lich} from './homing/02_Lich.js';
|
|
42
|
+
import {Archlich} from './homing/03_Archlich.js';
|
|
43
|
+
import {Flamecaller} from './caster/01_Flamecaller.js';
|
|
44
|
+
import {Pyromancer} from './caster/02_Pyromancer.js';
|
|
45
|
+
import {Infernalist} from './caster/03_Infernalist.js';
|
|
46
|
+
import {Shadowblade} from './melee/01_Shadowblade.js';
|
|
47
|
+
import {Nightblade} from './melee/02_Nightblade.js';
|
|
48
|
+
import {Voidblade} from './melee/03_Voidblade.js';
|
|
49
|
+
import {Spellshot} from './sniper/01_Spellshot.js';
|
|
50
|
+
import {Spelltracer} from './sniper/02_Spelltracer.js';
|
|
51
|
+
import {Spellseeker} from './sniper/03_Spellseeker.js';
|
|
52
|
+
import {Stormchaser} from './berserker/01_Stormchaser.js';
|
|
53
|
+
import {Stormcaller} from './berserker/02_Stormcaller.js';
|
|
54
|
+
import {Stormforger} from './berserker/03_Stormforger.js';
|
|
55
|
+
import {Spellspinner} from './kiter/01_Spellspinner.js';
|
|
56
|
+
import {Spellweaver} from './kiter/02_Spellweaver.js';
|
|
57
|
+
import {Spellbinder} from './kiter/03_Spellbinder.js';
|
|
58
|
+
|
|
59
|
+
export interface BotEntry
|
|
60
|
+
{
|
|
61
|
+
name: string;
|
|
62
|
+
ai: WizardFunction;
|
|
63
|
+
description: string;
|
|
64
|
+
tier?: number; // 1, 2, 3 for progression bots; undefined for standalone
|
|
65
|
+
group: string; // progression line name or 'Standalone'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface BotGroup
|
|
69
|
+
{
|
|
70
|
+
label: string;
|
|
71
|
+
bots: BotEntry[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* All bots organized by progression line.
|
|
76
|
+
* Each group contains bots from the same archetype, ordered by tier.
|
|
77
|
+
*/
|
|
78
|
+
export const BOT_GROUPS: BotGroup[] = [
|
|
79
|
+
{
|
|
80
|
+
label: 'Standalone',
|
|
81
|
+
bots: [
|
|
82
|
+
{name: 'TargetDummy', ai: TargetDummy, description: 'Does nothing (for testing)', group: 'Standalone'},
|
|
83
|
+
{name: 'Critter', ai: Critter, description: 'Random actions (chaos test)', group: 'Standalone'},
|
|
84
|
+
{name: 'Rookie', ai: Rookie, description: 'Basic homing missiles', group: 'Standalone'},
|
|
85
|
+
{name: 'Hogger', ai: Hogger, description: 'Chaotic elite — random but dangerous', group: 'Standalone'},
|
|
86
|
+
{name: 'Doombringer', ai: Doombringer, description: 'Single max-damage shot benchmark', group: 'Standalone'},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
label: 'Defensive',
|
|
91
|
+
bots: [
|
|
92
|
+
{name: 'Turtle', ai: Turtle, description: 'Stationary tank, last-moment shields', tier: 1, group: 'Defensive'},
|
|
93
|
+
{name: 'Sentinel', ai: Sentinel, description: 'Two-tier offense (big + fast missiles) + timed shields', tier: 2, group: 'Defensive'},
|
|
94
|
+
{name: 'Golem', ai: Golem, description: 'Stationary fortress, counterattack punish', tier: 3, group: 'Defensive'},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: 'Melee',
|
|
99
|
+
bots: [
|
|
100
|
+
{name: 'Shadowblade', ai: Shadowblade, description: 'Blink-in melee assassin', tier: 1, group: 'Melee'},
|
|
101
|
+
{name: 'Nightblade', ai: Nightblade, description: '+ preemptive shields, smart stabs', tier: 2, group: 'Melee'},
|
|
102
|
+
{name: 'Voidblade', ai: Voidblade, description: '+ vulnerability engage, disengage mode', tier: 3, group: 'Melee'},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
label: 'Homing',
|
|
107
|
+
bots: [
|
|
108
|
+
{name: 'Bonemancer', ai: Bonemancer, description: 'Slow homing missile spam', tier: 1, group: 'Homing'},
|
|
109
|
+
{name: 'Lich', ai: Lich, description: 'Slow missile swarm + defense', tier: 2, group: 'Homing'},
|
|
110
|
+
{name: 'Archlich', ai: Archlich, description: '+ vulnerability punish missiles during enemy GCD', tier: 3, group: 'Homing'},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
label: 'Caster',
|
|
115
|
+
bots: [
|
|
116
|
+
{name: 'Flamecaller', ai: Flamecaller, description: 'Fixed homing missiles, long range', tier: 1, group: 'Caster'},
|
|
117
|
+
{name: 'Pyromancer', ai: Pyromancer, description: '+ adaptive fitting, cast canceling', tier: 2, group: 'Caster'},
|
|
118
|
+
{name: 'Infernalist', ai: Infernalist, description: '+ warmup exploitation, punish mode', tier: 3, group: 'Caster'},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
label: 'Sniper',
|
|
123
|
+
bots: [
|
|
124
|
+
{name: 'Spellshot', ai: Spellshot, description: 'Intercept prediction shots', tier: 1, group: 'Sniper'},
|
|
125
|
+
{name: 'Spelltracer', ai: Spelltracer, description: '+ adaptive fitting, full defense', tier: 2, group: 'Sniper'},
|
|
126
|
+
{name: 'Spellseeker', ai: Spellseeker, description: '+ intercept-aimed punish, proactive kiting', tier: 3, group: 'Sniper'},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
label: 'Duelist',
|
|
131
|
+
bots: [
|
|
132
|
+
{name: 'Battlemage', ai: Battlemage, description: 'Close-range fighter, cast interrupts', tier: 1, group: 'Duelist'},
|
|
133
|
+
{name: 'Warmage', ai: Warmage, description: '+ adaptive missile fitting', tier: 2, group: 'Duelist'},
|
|
134
|
+
{name: 'Archmage', ai: Archmage, description: '+ range-adaptive, HP-aware aggression', tier: 3, group: 'Duelist'},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
label: 'Berserker',
|
|
139
|
+
bots: [
|
|
140
|
+
{name: 'Stormchaser', ai: Stormchaser, description: 'Aggressive trader, predictive homing', tier: 1, group: 'Berserker'},
|
|
141
|
+
{name: 'Stormcaller', ai: Stormcaller, description: '+ budget-fitted homing, smarter defense', tier: 2, group: 'Berserker'},
|
|
142
|
+
{name: 'Stormforger', ai: Stormforger, description: '+ vulnerability punish missiles during enemy GCD', tier: 3, group: 'Berserker'},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
label: 'Kiter',
|
|
147
|
+
bots: [
|
|
148
|
+
{name: 'Spellspinner', ai: Spellspinner, description: 'Kiting with homing missiles', tier: 1, group: 'Kiter'},
|
|
149
|
+
{name: 'Spellweaver', ai: Spellweaver, description: '+ budget-fitted adaptive missiles', tier: 2, group: 'Kiter'},
|
|
150
|
+
{name: 'Spellbinder', ai: Spellbinder, description: '+ vulnerability punish missiles during enemy GCD', tier: 3, group: 'Kiter'},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
// Flat map for lookup by name
|
|
156
|
+
const BOT_MAP = new Map<string, BotEntry>();
|
|
157
|
+
for (const group of BOT_GROUPS)
|
|
158
|
+
{
|
|
159
|
+
for (const bot of group.bots)
|
|
160
|
+
{
|
|
161
|
+
BOT_MAP.set(bot.name, bot);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* All bots ordered from weakest (#1) to strongest (#29).
|
|
167
|
+
* Based on round-robin tournament results.
|
|
168
|
+
* To update: run `npm run tournament`
|
|
169
|
+
*/
|
|
170
|
+
const ALL_BOTS_ORDER = [
|
|
171
|
+
'TargetDummy', // #1 (1 pts, 0W)
|
|
172
|
+
'Critter', // #2 (1 pts, 0W)
|
|
173
|
+
'Hogger', // #3 (9 pts, 29W)
|
|
174
|
+
'Bonemancer', // #4 (9 pts, 32W)
|
|
175
|
+
'Rookie', // #5 (9 pts, 38W)
|
|
176
|
+
'Turtle', // #6 (27 pts, 90W)
|
|
177
|
+
'Spellspinner', // #7 (27 pts, 104W)
|
|
178
|
+
'Flamecaller', // #8 (28 pts, 103W)
|
|
179
|
+
'Doombringer', // #9 (30 pts, 108W)
|
|
180
|
+
'Golem', // #10 (30 pts, 126W)
|
|
181
|
+
'Sentinel', // #11 (30 pts, 98W)
|
|
182
|
+
'Shadowblade', // #12 (33 pts, 120W)
|
|
183
|
+
'Stormchaser', // #13 (36 pts, 116W)
|
|
184
|
+
'Warmage', // #14 (39 pts, 144W)
|
|
185
|
+
'Battlemage', // #15 (45 pts, 141W)
|
|
186
|
+
'Spellweaver', // #16 (45 pts, 164W)
|
|
187
|
+
'Stormcaller', // #17 (48 pts, 170W)
|
|
188
|
+
'Pyromancer', // #18 (48 pts, 161W)
|
|
189
|
+
'Lich', // #19 (51 pts, 165W)
|
|
190
|
+
'Archlich', // #20 (58 pts, 184W)
|
|
191
|
+
'Infernalist', // #21 (60 pts, 197W)
|
|
192
|
+
'Stormforger', // #22 (60 pts, 211W)
|
|
193
|
+
'Spellshot', // #23 (63 pts, 196W)
|
|
194
|
+
'Spelltracer', // #24 (63 pts, 186W)
|
|
195
|
+
'Spellbinder', // #25 (66 pts, 199W)
|
|
196
|
+
'Archmage', // #26 (72 pts, 229W)
|
|
197
|
+
'Nightblade', // #27 (72 pts, 230W)
|
|
198
|
+
'Spellseeker', // #28 (76 pts, 243W)
|
|
199
|
+
'Voidblade', // #29 (78 pts, 258W)
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
export const ALL_BOTS: BotEntry[] = ALL_BOTS_ORDER.map((name) =>
|
|
203
|
+
{
|
|
204
|
+
const bot = BOT_MAP.get(name);
|
|
205
|
+
if (!bot) throw new Error(`Bot "${name}" not found in BOT_GROUPS`);
|
|
206
|
+
return bot;
|
|
207
|
+
});
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER - SHARED BOT UTILITIES
|
|
3
|
+
*
|
|
4
|
+
* Common helpers used by multiple bots for threat analysis and defense.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {GameState, Position, ProjectileState} from '../types.js';
|
|
8
|
+
import {analyzeThreats} from '../hooks/threat-analysis.js';
|
|
9
|
+
import type {AnalyzedThreat} from '../hooks/types.js';
|
|
10
|
+
import {distanceTo} from '../utils/distance.js';
|
|
11
|
+
import {ARENA_SIZE, BLINK_RANGE, GCD_DURATION, SHIELD_CAST_TIME, WIZARD_RADIUS} from '../rules.js';
|
|
12
|
+
|
|
13
|
+
// Re-export public utility so bots can import from shared
|
|
14
|
+
export {fitMissileToBudget} from '../utils/combat.js';
|
|
15
|
+
|
|
16
|
+
// T2 bots: max cast time when no threats are incoming (~2s)
|
|
17
|
+
export const MAX_CAST_BUDGET = 200;
|
|
18
|
+
|
|
19
|
+
// Minimum useful damage — d=1 to d=4 missiles waste cast time for negligible output.
|
|
20
|
+
// When budget only allows this, fall back to firing a bigger missile and accept the hit.
|
|
21
|
+
export const MIN_USEFUL_DAMAGE = 5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compute how many ticks until the wizard can start a new action.
|
|
25
|
+
*/
|
|
26
|
+
export function computeTicksUntilReady(state: GameState): number
|
|
27
|
+
{
|
|
28
|
+
switch (state.state)
|
|
29
|
+
{
|
|
30
|
+
case 'idle':
|
|
31
|
+
return 0;
|
|
32
|
+
case 'casting':
|
|
33
|
+
return (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
34
|
+
case 'channeling':
|
|
35
|
+
return 0; // can cancel instantly
|
|
36
|
+
case 'gcd_locked':
|
|
37
|
+
return state.gcdRemaining ?? 0;
|
|
38
|
+
default:
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get all threats from enemy projectiles using the simulation-based system.
|
|
45
|
+
*/
|
|
46
|
+
export function getThreats(state: GameState): AnalyzedThreat[]
|
|
47
|
+
{
|
|
48
|
+
const ticksUntilReady = computeTicksUntilReady(state);
|
|
49
|
+
return analyzeThreats(state.position, state.projectiles, state.myProjectiles, ticksUntilReady);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the most urgent threat that will actually hit us.
|
|
54
|
+
* Returns null if no threats will hit.
|
|
55
|
+
*/
|
|
56
|
+
export function getMostUrgentThreat(threats: AnalyzedThreat[]): AnalyzedThreat | null
|
|
57
|
+
{
|
|
58
|
+
// Threats are already sorted by ticksToImpact (soonest first)
|
|
59
|
+
return threats.find((t) => t.willHit) ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Determine if a threat should force a shield even when a dodge direction exists.
|
|
64
|
+
* High-damage homing missiles are too risky to dodge — the missile tracks the wizard
|
|
65
|
+
* and the dodge often fails. Shield instead to guarantee survival.
|
|
66
|
+
*/
|
|
67
|
+
export function shouldForceShield(threat: AnalyzedThreat): boolean
|
|
68
|
+
{
|
|
69
|
+
return threat.projectile.damage >= 25 && threat.projectile.turnRate > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get a dodge direction for movement, even if no threat will directly hit.
|
|
74
|
+
* Homing missiles continuously adjust course, so even missiles the simulation
|
|
75
|
+
* says will "miss" should be dodged if they're close.
|
|
76
|
+
*
|
|
77
|
+
* Uses the missile's heading to determine which side it will pass the wizard,
|
|
78
|
+
* then dodges perpendicular to the heading, AWAY from the approach side.
|
|
79
|
+
* When the missile is heading dead-center at us (ambiguous), uses wall
|
|
80
|
+
* avoidance as a stable tiebreaker.
|
|
81
|
+
*/
|
|
82
|
+
export function getDodgeDirectionForMovement(
|
|
83
|
+
threats: AnalyzedThreat[],
|
|
84
|
+
myPos: Position,
|
|
85
|
+
): Position | null
|
|
86
|
+
{
|
|
87
|
+
// Only dodge missiles the simulation predicts will hit
|
|
88
|
+
const target = threats.find((t) => t.willHit) ?? null;
|
|
89
|
+
|
|
90
|
+
if (!target) return null;
|
|
91
|
+
|
|
92
|
+
// Missile heading direction (from its rotation)
|
|
93
|
+
const rad = target.projectile.rotation * (Math.PI / 180);
|
|
94
|
+
const missileDir = {x: Math.cos(rad), y: Math.sin(rad)};
|
|
95
|
+
|
|
96
|
+
// Vector from missile to wizard
|
|
97
|
+
const toWizard = {
|
|
98
|
+
x: myPos.x - target.projectile.position.x,
|
|
99
|
+
y: myPos.y - target.projectile.position.y,
|
|
100
|
+
};
|
|
101
|
+
const len = Math.sqrt(toWizard.x * toWizard.x + toWizard.y * toWizard.y);
|
|
102
|
+
if (len < 0.01) return null;
|
|
103
|
+
|
|
104
|
+
// Cross product: which side of the missile's path is the wizard on?
|
|
105
|
+
// cross > 0 → wizard is clockwise from heading → missile passes CCW → dodge CW (away)
|
|
106
|
+
// cross < 0 → wizard is counter-clockwise → missile passes CW → dodge CCW (away)
|
|
107
|
+
const cross = missileDir.x * toWizard.y - missileDir.y * toWizard.x;
|
|
108
|
+
|
|
109
|
+
// Perpendicular options to missile heading
|
|
110
|
+
const dodgeCW = {x: missileDir.y, y: -missileDir.x};
|
|
111
|
+
const dodgeCCW = {x: -missileDir.y, y: missileDir.x};
|
|
112
|
+
|
|
113
|
+
// Deadzone: missile heading nearly straight at us (~8° cone).
|
|
114
|
+
// Both sides are equally good, so use a stable tiebreaker.
|
|
115
|
+
const DEADZONE = 0.15 * len;
|
|
116
|
+
|
|
117
|
+
if (cross > DEADZONE)
|
|
118
|
+
{
|
|
119
|
+
return dodgeCCW;
|
|
120
|
+
}
|
|
121
|
+
if (cross < -DEADZONE)
|
|
122
|
+
{
|
|
123
|
+
return dodgeCW;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Tiebreaker for dead-center missiles: pick the side further from walls
|
|
127
|
+
const futureA = {x: myPos.x + dodgeCCW.x * 100, y: myPos.y + dodgeCCW.y * 100};
|
|
128
|
+
const futureB = {x: myPos.x + dodgeCW.x * 100, y: myPos.y + dodgeCW.y * 100};
|
|
129
|
+
const scoreA = Math.min(futureA.x, ARENA_SIZE - futureA.x, futureA.y, ARENA_SIZE - futureA.y);
|
|
130
|
+
const scoreB = Math.min(futureB.x, ARENA_SIZE - futureB.x, futureB.y, ARENA_SIZE - futureB.y);
|
|
131
|
+
|
|
132
|
+
return scoreA >= scoreB ? dodgeCCW : dodgeCW;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if it's safe to start an attack (no imminent undodgeable threats).
|
|
137
|
+
*
|
|
138
|
+
* @param threats - Analyzed threats
|
|
139
|
+
* @param castTicks - How many ticks the attack cast will take
|
|
140
|
+
* @param gcdTicks - GCD ticks after cast (default 100)
|
|
141
|
+
* @param shieldCastTicks - Shield cast time (default 20)
|
|
142
|
+
*/
|
|
143
|
+
export function isSafeToAttack(
|
|
144
|
+
threats: AnalyzedThreat[],
|
|
145
|
+
castTicks: number,
|
|
146
|
+
gcdTicks: number = GCD_DURATION,
|
|
147
|
+
shieldCastTicks: number = SHIELD_CAST_TIME,
|
|
148
|
+
): boolean
|
|
149
|
+
{
|
|
150
|
+
// No threats = safe
|
|
151
|
+
const hittingThreats = threats.filter((t) => t.willHit);
|
|
152
|
+
|
|
153
|
+
for (const threat of hittingThreats)
|
|
154
|
+
{
|
|
155
|
+
if (threat.bestDodgeDirection !== null)
|
|
156
|
+
{
|
|
157
|
+
// Homing missiles track during GCD, but the wizard moves at full speed
|
|
158
|
+
// during GCD and can dodge normally. Only the cast (half speed) plus a
|
|
159
|
+
// transition buffer is truly dangerous for dodgeable homing missiles.
|
|
160
|
+
// Non-homing missiles only need dodging during the cast itself.
|
|
161
|
+
if (threat.projectile.turnRate > 0)
|
|
162
|
+
{
|
|
163
|
+
if (threat.ticksToImpact < castTicks + HOMING_DODGE_BUFFER)
|
|
164
|
+
{
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else if (threat.ticksToImpact < castTicks)
|
|
169
|
+
{
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Undodgeable threat: we need time to finish cast + GCD + shield cast
|
|
176
|
+
const timeNeeded = castTicks + gcdTicks + shieldCastTicks + 1;
|
|
177
|
+
if (threat.ticksToImpact < timeNeeded)
|
|
178
|
+
{
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Calculate a blink target toward arena center (for emergency escapes).
|
|
188
|
+
*/
|
|
189
|
+
export function getBlinkToCenter(pos: Position): Position
|
|
190
|
+
{
|
|
191
|
+
const centerX = ARENA_SIZE / 2;
|
|
192
|
+
const centerY = ARENA_SIZE / 2;
|
|
193
|
+
const dx = centerX - pos.x;
|
|
194
|
+
const dy = centerY - pos.y;
|
|
195
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
196
|
+
|
|
197
|
+
if (dist === 0) return {x: centerX, y: centerY};
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
x: pos.x + (dx / dist) * BLINK_RANGE,
|
|
201
|
+
y: pos.y + (dy / dist) * BLINK_RANGE,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// BLINK DISTANCE FUNCTIONS
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
// Post-blink vulnerability: shield cast time before wizard can defend (blink has no GCD)
|
|
210
|
+
const POST_BLINK_VULNERABILITY = SHIELD_CAST_TIME;
|
|
211
|
+
|
|
212
|
+
// Approximate hit radius for missile safety (wizard radius + typical missile radius)
|
|
213
|
+
const HIT_RADIUS = WIZARD_RADIUS + 3;
|
|
214
|
+
|
|
215
|
+
// Homing missiles need extra buffer beyond cast time because they track during GCD
|
|
216
|
+
const HOMING_DODGE_BUFFER = 20;
|
|
217
|
+
|
|
218
|
+
// Minimum wall margin when evaluating blink targets (prevents blinking into walls)
|
|
219
|
+
const BLINK_WALL_MARGIN = 20;
|
|
220
|
+
|
|
221
|
+
// Minimum post-blink distance from enemy for escape blinks to be worthwhile
|
|
222
|
+
export const MIN_BLINK_ESCAPE_DISTANCE = 250;
|
|
223
|
+
|
|
224
|
+
// Scoring constants
|
|
225
|
+
const ANGLE_CANDIDATES = 24; // every 15°
|
|
226
|
+
const DISTANCE_FRACTIONS = [1.0, 0.7, 0.4];
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Find the best blink target to increase distance from an enemy.
|
|
231
|
+
* Avoids positions where missiles could arrive during the post-blink GCD window.
|
|
232
|
+
* Returns null if minResultDistance can't be achieved.
|
|
233
|
+
*/
|
|
234
|
+
export function getBlinkToIncreaseDistance(
|
|
235
|
+
myPos: Position,
|
|
236
|
+
enemyPos: Position,
|
|
237
|
+
projectiles: ProjectileState[],
|
|
238
|
+
minResultDistance?: number,
|
|
239
|
+
): Position | null
|
|
240
|
+
{
|
|
241
|
+
return findBestBlinkTarget(myPos, enemyPos, projectiles, 'increase', minResultDistance);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Find the best blink target to decrease distance to an enemy.
|
|
246
|
+
* Avoids positions where missiles could arrive during the post-blink GCD window.
|
|
247
|
+
*/
|
|
248
|
+
export function getBlinkToDecreaseDistance(
|
|
249
|
+
myPos: Position,
|
|
250
|
+
enemyPos: Position,
|
|
251
|
+
projectiles: ProjectileState[],
|
|
252
|
+
): Position | null
|
|
253
|
+
{
|
|
254
|
+
return findBestBlinkTarget(myPos, enemyPos, projectiles, 'decrease');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Core blink target finder. Evaluates candidates across multiple angles and distances,
|
|
259
|
+
* scoring by distance goal + wall safety + missile avoidance.
|
|
260
|
+
*/
|
|
261
|
+
function findBestBlinkTarget(
|
|
262
|
+
myPos: Position,
|
|
263
|
+
enemyPos: Position,
|
|
264
|
+
projectiles: ProjectileState[],
|
|
265
|
+
goal: 'increase' | 'decrease',
|
|
266
|
+
minResultDistance?: number,
|
|
267
|
+
): Position | null
|
|
268
|
+
{
|
|
269
|
+
const currentDist = distanceTo(myPos, enemyPos);
|
|
270
|
+
|
|
271
|
+
// Base direction: away from enemy (increase) or toward enemy (decrease)
|
|
272
|
+
let baseAngle: number;
|
|
273
|
+
if (currentDist < 0.01)
|
|
274
|
+
{
|
|
275
|
+
// Overlapping: fallback to toward center
|
|
276
|
+
baseAngle = Math.atan2(ARENA_SIZE / 2 - myPos.y, ARENA_SIZE / 2 - myPos.x);
|
|
277
|
+
}
|
|
278
|
+
else
|
|
279
|
+
{
|
|
280
|
+
const dx = goal === 'increase'
|
|
281
|
+
? myPos.x - enemyPos.x
|
|
282
|
+
: enemyPos.x - myPos.x;
|
|
283
|
+
const dy = goal === 'increase'
|
|
284
|
+
? myPos.y - enemyPos.y
|
|
285
|
+
: enemyPos.y - myPos.y;
|
|
286
|
+
baseAngle = Math.atan2(dy, dx);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// For decrease: don't overshoot past enemy
|
|
290
|
+
const maxBlinkDist = goal === 'decrease'
|
|
291
|
+
? Math.min(BLINK_RANGE, currentDist)
|
|
292
|
+
: BLINK_RANGE;
|
|
293
|
+
|
|
294
|
+
let bestTarget: Position | null = null;
|
|
295
|
+
let bestScore = -Infinity;
|
|
296
|
+
|
|
297
|
+
for (const fraction of DISTANCE_FRACTIONS)
|
|
298
|
+
{
|
|
299
|
+
const blinkDist = maxBlinkDist * fraction;
|
|
300
|
+
if (blinkDist < 10) continue;
|
|
301
|
+
|
|
302
|
+
for (let i = 0; i < ANGLE_CANDIDATES; i++)
|
|
303
|
+
{
|
|
304
|
+
const angle = baseAngle + (i * 2 * Math.PI) / ANGLE_CANDIDATES;
|
|
305
|
+
|
|
306
|
+
// Candidate position (clamped with wall buffer to prevent wall-hugging)
|
|
307
|
+
const cx = Math.max(BLINK_WALL_MARGIN, Math.min(ARENA_SIZE - BLINK_WALL_MARGIN, myPos.x + Math.cos(angle) * blinkDist));
|
|
308
|
+
const cy = Math.max(BLINK_WALL_MARGIN, Math.min(ARENA_SIZE - BLINK_WALL_MARGIN, myPos.y + Math.sin(angle) * blinkDist));
|
|
309
|
+
|
|
310
|
+
// Skip if clamping made this a non-blink
|
|
311
|
+
if (distanceTo(myPos, {x: cx, y: cy}) < 10) continue;
|
|
312
|
+
|
|
313
|
+
const candidate = {x: cx, y: cy};
|
|
314
|
+
|
|
315
|
+
// Score: distance from enemy (maximize for increase, minimize for decrease)
|
|
316
|
+
const enemyDist = distanceTo(candidate, enemyPos);
|
|
317
|
+
const distScore = goal === 'increase' ? enemyDist : -enemyDist;
|
|
318
|
+
|
|
319
|
+
// Penalize distance from arena center (prevents corner-trapping)
|
|
320
|
+
const center = ARENA_SIZE / 2;
|
|
321
|
+
const distFromCenter = distanceTo(candidate, {x: center, y: center});
|
|
322
|
+
|
|
323
|
+
// Missile danger penalty
|
|
324
|
+
const missilePenalty = getMissileDangerAtPosition(candidate, projectiles);
|
|
325
|
+
|
|
326
|
+
const score = distScore - distFromCenter * 0.5 - missilePenalty;
|
|
327
|
+
|
|
328
|
+
if (score > bestScore)
|
|
329
|
+
{
|
|
330
|
+
bestScore = score;
|
|
331
|
+
bestTarget = candidate;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Check minResultDistance threshold
|
|
337
|
+
if (minResultDistance !== undefined && bestTarget !== null)
|
|
338
|
+
{
|
|
339
|
+
if (distanceTo(bestTarget, enemyPos) < minResultDistance)
|
|
340
|
+
{
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return bestTarget;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Estimate missile danger at a position during the post-blink vulnerability window.
|
|
350
|
+
* Returns a penalty score (0 = safe, high = dangerous).
|
|
351
|
+
*
|
|
352
|
+
* Uses the missile's actual heading, speed, and turn rate to estimate arrival time.
|
|
353
|
+
* During GCD the wizard moves at full speed (1 u/tick), so missiles only threaten
|
|
354
|
+
* the blink destination if they arrive quickly enough that the wizard can't dodge.
|
|
355
|
+
*/
|
|
356
|
+
function getMissileDangerAtPosition(
|
|
357
|
+
pos: Position,
|
|
358
|
+
projectiles: ProjectileState[],
|
|
359
|
+
): number
|
|
360
|
+
{
|
|
361
|
+
let danger = 0;
|
|
362
|
+
|
|
363
|
+
for (const proj of projectiles)
|
|
364
|
+
{
|
|
365
|
+
const dx = pos.x - proj.position.x;
|
|
366
|
+
const dy = pos.y - proj.position.y;
|
|
367
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
368
|
+
|
|
369
|
+
// Can the missile physically reach this position in time?
|
|
370
|
+
if (dist / proj.speed > POST_BLINK_VULNERABILITY) continue;
|
|
371
|
+
|
|
372
|
+
if (proj.turnRate > 0)
|
|
373
|
+
{
|
|
374
|
+
// Homing: estimate arrival using heading + turn rate + distance.
|
|
375
|
+
// The missile must turn toward the position AND travel to it.
|
|
376
|
+
// Minimum arrival ≈ max(turnTime, travelTime) since they overlap.
|
|
377
|
+
const angleToPos = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
378
|
+
let angleDiff = angleToPos - proj.rotation;
|
|
379
|
+
while (angleDiff > 180) angleDiff -= 360;
|
|
380
|
+
while (angleDiff < -180) angleDiff += 360;
|
|
381
|
+
|
|
382
|
+
const turnTicks = Math.abs(angleDiff) / proj.turnRate;
|
|
383
|
+
const travelTicks = dist / proj.speed;
|
|
384
|
+
const estimatedArrival = Math.max(turnTicks, travelTicks);
|
|
385
|
+
|
|
386
|
+
// Only dangerous if it arrives before the wizard can move away.
|
|
387
|
+
// At full speed (1 u/tick), the wizard needs ~HIT_RADIUS ticks to
|
|
388
|
+
// sidestep out of the collision zone. Add a buffer for reaction.
|
|
389
|
+
if (estimatedArrival < HIT_RADIUS * 3)
|
|
390
|
+
{
|
|
391
|
+
danger += 1000;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else
|
|
395
|
+
{
|
|
396
|
+
// Non-homing: check if straight-line path passes near the position
|
|
397
|
+
const rad = proj.rotation * (Math.PI / 180);
|
|
398
|
+
const mx = Math.cos(rad);
|
|
399
|
+
const my = Math.sin(rad);
|
|
400
|
+
|
|
401
|
+
// Project position onto missile path (dot product)
|
|
402
|
+
const dot = dx * mx + dy * my;
|
|
403
|
+
if (dot < 0) continue; // missile heading away
|
|
404
|
+
|
|
405
|
+
// Closest point on missile path to candidate
|
|
406
|
+
const closestX = proj.position.x + mx * dot;
|
|
407
|
+
const closestY = proj.position.y + my * dot;
|
|
408
|
+
const closestDist = Math.sqrt(
|
|
409
|
+
(pos.x - closestX) ** 2 + (pos.y - closestY) ** 2,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Check if path passes close enough to hit, within time window
|
|
413
|
+
if (closestDist < HIT_RADIUS * 3 && dot / proj.speed <= POST_BLINK_VULNERABILITY)
|
|
414
|
+
{
|
|
415
|
+
danger += 1000;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return danger;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
// ============================================================================
|
|
425
|
+
// T3 SHARED HELPERS
|
|
426
|
+
// ============================================================================
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Compute how fast the enemy is closing distance (positive = approaching).
|
|
430
|
+
* Projects enemy velocity onto the vector from enemy to us.
|
|
431
|
+
*/
|
|
432
|
+
export function getClosingRate(
|
|
433
|
+
myPos: {x: number; y: number},
|
|
434
|
+
enemyPos: {x: number; y: number},
|
|
435
|
+
enemyVel: {x: number; y: number},
|
|
436
|
+
): number
|
|
437
|
+
{
|
|
438
|
+
const dx = myPos.x - enemyPos.x;
|
|
439
|
+
const dy = myPos.y - enemyPos.y;
|
|
440
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
441
|
+
if (dist < 0.01) return 0;
|
|
442
|
+
return (enemyVel.x * dx + enemyVel.y * dy) / dist;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Compute how many ticks the enemy remains unable to shield.
|
|
447
|
+
* Casting missile → remaining cast + GCD. Casting blink/shield → remaining cast only.
|
|
448
|
+
* GCD-locked → remaining GCD. Idle/channeling → 0.
|
|
449
|
+
*/
|
|
450
|
+
export function getEnemyVulnerabilityTicks(enemy: {
|
|
451
|
+
state: string;
|
|
452
|
+
castingSpell?: string;
|
|
453
|
+
castProgress?: number;
|
|
454
|
+
castDuration?: number;
|
|
455
|
+
gcdRemaining?: number;
|
|
456
|
+
}): number
|
|
457
|
+
{
|
|
458
|
+
if (enemy.state === 'casting')
|
|
459
|
+
{
|
|
460
|
+
const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
461
|
+
if (enemy.castingSpell === 'missile')
|
|
462
|
+
{
|
|
463
|
+
return remainingCast + GCD_DURATION;
|
|
464
|
+
}
|
|
465
|
+
return remainingCast;
|
|
466
|
+
}
|
|
467
|
+
if (enemy.state === 'gcd_locked')
|
|
468
|
+
{
|
|
469
|
+
return enemy.gcdRemaining ?? 0;
|
|
470
|
+
}
|
|
471
|
+
return 0;
|
|
472
|
+
}
|