@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,134 @@
|
|
|
1
|
+
import {WizardFunction, MissileActions} from '../../types.js';
|
|
2
|
+
import {angleTo} from '../../utils/angles.js';
|
|
3
|
+
import {GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
4
|
+
import {getMissileCastTime} from '../../utils/combat.js';
|
|
5
|
+
import {
|
|
6
|
+
getThreats,
|
|
7
|
+
getMostUrgentThreat,
|
|
8
|
+
getBlinkToCenter,
|
|
9
|
+
} from '../shared.js';
|
|
10
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Bot: Sentinel
|
|
14
|
+
*
|
|
15
|
+
* BEHAVIOR: Stationary tank with last-moment shielding AND two-tier offense.
|
|
16
|
+
* Like Turtle, never moves and shields at the last moment. Unlike Turtle,
|
|
17
|
+
* fires bigger missiles (damage 20) when the safe window is large enough,
|
|
18
|
+
* falling back to Turtle's fast missile (damage 12) when pressed.
|
|
19
|
+
*
|
|
20
|
+
* PROGRESSION LINE: Turtle → Sentinel → Golem
|
|
21
|
+
* - Turtle (tier 1): Stationary, fixed missiles, reactive shield timing
|
|
22
|
+
* - Sentinel (tier 2): Stationary, two-tier offense (big + fast missiles)
|
|
23
|
+
* - Golem (tier 3): Immovable fortress, perfect shield timing
|
|
24
|
+
*
|
|
25
|
+
* TIER: 2 (enhanced Turtle)
|
|
26
|
+
*/
|
|
27
|
+
export const Sentinel: WizardFunction = ({state}) =>
|
|
28
|
+
{
|
|
29
|
+
const shieldHoldTicks = useParam('shieldHoldTicks', 2, {
|
|
30
|
+
range: 5,
|
|
31
|
+
min: 0,
|
|
32
|
+
max: 15,
|
|
33
|
+
steps: 5,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const enemy = state.enemies[0];
|
|
37
|
+
|
|
38
|
+
const threats = getThreats(state);
|
|
39
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
40
|
+
|
|
41
|
+
const SHIELD_BUFFER = 3;
|
|
42
|
+
const shouldStartShield =
|
|
43
|
+
urgentThreat !== null &&
|
|
44
|
+
urgentThreat.ticksToImpact <= SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
45
|
+
|
|
46
|
+
const TICKS_TO_RESHIELD = SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
47
|
+
const nextThreat = threats.filter((t) => t.willHit)[1] ?? null;
|
|
48
|
+
const safeToCancel =
|
|
49
|
+
!nextThreat || nextThreat.ticksToImpact > TICKS_TO_RESHIELD;
|
|
50
|
+
|
|
51
|
+
if (state.state === 'channeling' && state.channelingSpell === 'shield')
|
|
52
|
+
{
|
|
53
|
+
const imminentThreat = threats.find(
|
|
54
|
+
(t) => t.willHit && t.ticksToImpact <= shieldHoldTicks,
|
|
55
|
+
);
|
|
56
|
+
if (imminentThreat) return {move: {x: 0, y: 0}};
|
|
57
|
+
if (safeToCancel) return {move: {x: 0, y: 0}, cancel: true};
|
|
58
|
+
return {move: {x: 0, y: 0}};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (state.state === 'casting' && state.castingSpell === 'shield')
|
|
62
|
+
{
|
|
63
|
+
return {move: {x: 0, y: 0}};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (shouldStartShield)
|
|
67
|
+
{
|
|
68
|
+
if (state.state === 'casting') return {move: {x: 0, y: 0}, cancel: true};
|
|
69
|
+
if (state.state === 'idle')
|
|
70
|
+
{
|
|
71
|
+
return {move: {x: 0, y: 0}, startCast: {spell: 'shield'}};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
state.state === 'idle' &&
|
|
77
|
+
urgentThreat &&
|
|
78
|
+
urgentThreat.ticksToImpact < SHIELD_CAST_TIME + SHIELD_BUFFER &&
|
|
79
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
80
|
+
state.blinkCooldown === 0
|
|
81
|
+
)
|
|
82
|
+
{
|
|
83
|
+
return {
|
|
84
|
+
move: {x: 0, y: 0},
|
|
85
|
+
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Two-tier offense: big missile when safe, fast missile otherwise.
|
|
90
|
+
// Same speed/turnRate/duration → no warmup switching penalty.
|
|
91
|
+
if (state.state === 'idle' && enemy)
|
|
92
|
+
{
|
|
93
|
+
const FAST_MISSILE = {damage: 12, speed: 5, turnRate: 1, duration: 180};
|
|
94
|
+
const BIG_MISSILE = {damage: 20, speed: 5, turnRate: 1, duration: 180};
|
|
95
|
+
|
|
96
|
+
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
97
|
+
|
|
98
|
+
const bigCast = getMissileCastTime(BIG_MISSILE, state.lastMissileConfig);
|
|
99
|
+
const bigWindow = bigCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
100
|
+
if (!urgentThreat || nextThreatTime > bigWindow)
|
|
101
|
+
{
|
|
102
|
+
return {
|
|
103
|
+
move: {x: 0, y: 0},
|
|
104
|
+
startCast: {
|
|
105
|
+
spell: 'missile',
|
|
106
|
+
config: BIG_MISSILE,
|
|
107
|
+
missileAI: ({worldState}): MissileActions => ({
|
|
108
|
+
turnToward: worldState.enemies[0]?.position,
|
|
109
|
+
}),
|
|
110
|
+
direction: angleTo(state.position, enemy.position),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fastCast = getMissileCastTime(FAST_MISSILE, state.lastMissileConfig);
|
|
116
|
+
const fastWindow = fastCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
117
|
+
if (!urgentThreat || nextThreatTime > fastWindow)
|
|
118
|
+
{
|
|
119
|
+
return {
|
|
120
|
+
move: {x: 0, y: 0},
|
|
121
|
+
startCast: {
|
|
122
|
+
spell: 'missile',
|
|
123
|
+
config: FAST_MISSILE,
|
|
124
|
+
missileAI: ({worldState}): MissileActions => ({
|
|
125
|
+
turnToward: worldState.enemies[0]?.position,
|
|
126
|
+
}),
|
|
127
|
+
direction: angleTo(state.position, enemy.position),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {move: {x: 0, y: 0}};
|
|
134
|
+
};
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
3
|
+
import {angleTo} from '../../utils/angles.js';
|
|
4
|
+
import {
|
|
5
|
+
GCD_DURATION,
|
|
6
|
+
SHIELD_CAST_TIME,
|
|
7
|
+
BLINK_CAST_TIME,
|
|
8
|
+
} from '../../rules.js';
|
|
9
|
+
import {getMissileCastTime, getLeadPosition} from '../../utils/combat.js';
|
|
10
|
+
import {
|
|
11
|
+
getThreats,
|
|
12
|
+
getMostUrgentThreat,
|
|
13
|
+
getBlinkToCenter,
|
|
14
|
+
fitMissileToBudget,
|
|
15
|
+
isSafeToAttack,
|
|
16
|
+
MAX_CAST_BUDGET,
|
|
17
|
+
MIN_USEFUL_DAMAGE,
|
|
18
|
+
getEnemyVulnerabilityTicks,
|
|
19
|
+
} from '../shared.js';
|
|
20
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
21
|
+
|
|
22
|
+
const SHIELD_BUFFER = 3;
|
|
23
|
+
const SHIELD_HOLD_TICKS = 5;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Standard homing missile AI — tracks enemy position.
|
|
27
|
+
*/
|
|
28
|
+
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
29
|
+
{
|
|
30
|
+
const enemy = worldState.enemies[0];
|
|
31
|
+
return {turnToward: enemy?.position};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Straight missile AI — no homing, flies in initial direction.
|
|
36
|
+
*/
|
|
37
|
+
const StraightAI: MissileAIFunction = () => ({});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Bot: Golem
|
|
41
|
+
*
|
|
42
|
+
* BEHAVIOR: Stationary fortress with perfect shield timing and devastating
|
|
43
|
+
* counterattacks during enemy vulnerability windows. Reads enemy cast/GCD
|
|
44
|
+
* state to time punish missiles that land when the enemy can't shield.
|
|
45
|
+
* Handles multi-missile volleys by holding shield through consecutive impacts.
|
|
46
|
+
* Uses progressive cast-cancel thresholds for optimal damage trading.
|
|
47
|
+
*
|
|
48
|
+
* KEY IMPROVEMENTS OVER SENTINEL:
|
|
49
|
+
* - Counterattack punish: fires during enemy cast/GCD recovery
|
|
50
|
+
* - Multi-threat volley awareness: holds shield through consecutive hits
|
|
51
|
+
* - Progressive cast-cancel: graduated damage thresholds
|
|
52
|
+
* - Perfect shield timing: uses ticksToStartShield precisely
|
|
53
|
+
*
|
|
54
|
+
* PROGRESSION LINE: Turtle → Sentinel → Golem
|
|
55
|
+
* TIER: 3 (elite Defensive line)
|
|
56
|
+
*/
|
|
57
|
+
export const Golem: WizardFunction = ({state}) =>
|
|
58
|
+
{
|
|
59
|
+
// Positive turn rate ensures homing missiles that hit moving targets.
|
|
60
|
+
// Hardcoded — optimizer incorrectly pushes this negative for stationary matchups.
|
|
61
|
+
const minTurnRate = 0.5;
|
|
62
|
+
const preemptiveShieldRange = useParam('preemptiveShieldRange', 150, {
|
|
63
|
+
range: 85,
|
|
64
|
+
min: 0,
|
|
65
|
+
steps: 7,
|
|
66
|
+
});
|
|
67
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 100, {
|
|
68
|
+
range: 75,
|
|
69
|
+
min: 10,
|
|
70
|
+
});
|
|
71
|
+
const punishMinVulnerability = useParam('punishMinVulnerability', 310, {
|
|
72
|
+
range: 30,
|
|
73
|
+
min: 10,
|
|
74
|
+
steps: 5,
|
|
75
|
+
});
|
|
76
|
+
const preferBlinkDodge =
|
|
77
|
+
useParam('preferBlinkDodge', 1, {min: 0, max: 1, steps: 2}) >= 0.5;
|
|
78
|
+
const cancelHeavyDmg = useParam('cancelHeavyDmg', 14, {
|
|
79
|
+
range: 15,
|
|
80
|
+
min: 5,
|
|
81
|
+
max: 40,
|
|
82
|
+
steps: 5,
|
|
83
|
+
});
|
|
84
|
+
const cancelMediumDmg = useParam('cancelMediumDmg', 12, {
|
|
85
|
+
range: 10,
|
|
86
|
+
min: 3,
|
|
87
|
+
max: 25,
|
|
88
|
+
steps: 5,
|
|
89
|
+
});
|
|
90
|
+
const cancelCastRatio = useParam('cancelCastRatio', 0.8, {
|
|
91
|
+
range: 0.4,
|
|
92
|
+
min: 0.3,
|
|
93
|
+
max: 1.0,
|
|
94
|
+
steps: 5,
|
|
95
|
+
});
|
|
96
|
+
const cancelLightDmg = useParam('cancelLightDmg', 8, {
|
|
97
|
+
range: 5,
|
|
98
|
+
min: 1,
|
|
99
|
+
max: 15,
|
|
100
|
+
steps: 5,
|
|
101
|
+
});
|
|
102
|
+
const cancelEarlyCastRatio = useParam('cancelEarlyCastRatio', 0.7, {
|
|
103
|
+
range: 0.3,
|
|
104
|
+
min: 0.0,
|
|
105
|
+
max: 0.7,
|
|
106
|
+
steps: 5,
|
|
107
|
+
});
|
|
108
|
+
const blinkWindowMax = useParam('blinkWindowMax', 50, {
|
|
109
|
+
range: 40,
|
|
110
|
+
min: 20,
|
|
111
|
+
max: 120,
|
|
112
|
+
steps: 7,
|
|
113
|
+
});
|
|
114
|
+
const preemptiveShieldBuffer = useParam('preemptiveShieldBuffer', 11.3, {
|
|
115
|
+
range: 5,
|
|
116
|
+
min: 0,
|
|
117
|
+
max: 15,
|
|
118
|
+
steps: 5,
|
|
119
|
+
});
|
|
120
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 9.5, {
|
|
121
|
+
range: 4,
|
|
122
|
+
min: 2,
|
|
123
|
+
max: 12,
|
|
124
|
+
steps: 5,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const enemy = state.enemies[0];
|
|
128
|
+
const threats = getThreats(state);
|
|
129
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
130
|
+
const hittingThreats = threats.filter((t) => t.willHit);
|
|
131
|
+
|
|
132
|
+
// === STEP 1: CHANNELING — hold through volleys, cancel when safe ===
|
|
133
|
+
if (state.state === 'channeling' && state.channelingSpell === 'shield')
|
|
134
|
+
{
|
|
135
|
+
// Hold if any missile is about to hit
|
|
136
|
+
const imminentThreat = hittingThreats.find(
|
|
137
|
+
(t) => t.ticksToImpact <= SHIELD_HOLD_TICKS,
|
|
138
|
+
);
|
|
139
|
+
if (imminentThreat)
|
|
140
|
+
{
|
|
141
|
+
return {move: {x: 0, y: 0}};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Hold if next threat will arrive before we can reshield
|
|
145
|
+
const nextHitting = hittingThreats[0];
|
|
146
|
+
const ticksToReshield = SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
147
|
+
if (nextHitting && nextHitting.ticksToImpact <= ticksToReshield)
|
|
148
|
+
{
|
|
149
|
+
return {move: {x: 0, y: 0}};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Cancel shield if no threat or threat is far enough
|
|
153
|
+
if (!nextHitting || nextHitting.ticksToImpact > shieldCancelThreshold)
|
|
154
|
+
{
|
|
155
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
156
|
+
}
|
|
157
|
+
return {move: {x: 0, y: 0}};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// If casting shield, let it finish
|
|
161
|
+
if (state.state === 'casting' && state.castingSpell === 'shield')
|
|
162
|
+
{
|
|
163
|
+
return {move: {x: 0, y: 0}};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// === STEP 2: PROGRESSIVE CAST-CANCEL — evaluate damage tradeoff ===
|
|
167
|
+
if (
|
|
168
|
+
state.state === 'casting' &&
|
|
169
|
+
state.castingSpell === 'missile' &&
|
|
170
|
+
urgentThreat
|
|
171
|
+
)
|
|
172
|
+
{
|
|
173
|
+
// Sentinel is stationary — can't dodge. Must always consider canceling.
|
|
174
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
175
|
+
const defenseTime =
|
|
176
|
+
state.blinkCooldown === 0
|
|
177
|
+
? BLINK_CAST_TIME
|
|
178
|
+
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
179
|
+
const willArriveInWindow =
|
|
180
|
+
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION + defenseTime;
|
|
181
|
+
|
|
182
|
+
if (willArriveInWindow)
|
|
183
|
+
{
|
|
184
|
+
const incomingDamage = urgentThreat.projectile.damage;
|
|
185
|
+
const castRatio = (state.castProgress ?? 0) / (state.castDuration ?? 1);
|
|
186
|
+
|
|
187
|
+
// Always cancel for lethal or heavy damage
|
|
188
|
+
if (incomingDamage >= state.health || incomingDamage >= cancelHeavyDmg)
|
|
189
|
+
{
|
|
190
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
191
|
+
}
|
|
192
|
+
// Significant damage + early/mid cast: cancel
|
|
193
|
+
if (incomingDamage >= cancelMediumDmg && castRatio < cancelCastRatio)
|
|
194
|
+
{
|
|
195
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
196
|
+
}
|
|
197
|
+
// Medium damage + very early cast: cancel
|
|
198
|
+
if (
|
|
199
|
+
incomingDamage >= cancelLightDmg &&
|
|
200
|
+
castRatio < cancelEarlyCastRatio
|
|
201
|
+
)
|
|
202
|
+
{
|
|
203
|
+
return {move: {x: 0, y: 0}, cancel: true};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// === STEP 3: BLINK-DODGE — 100% damage avoidance (optimizer-controlled) ===
|
|
209
|
+
if (
|
|
210
|
+
preferBlinkDodge &&
|
|
211
|
+
state.state === 'idle' &&
|
|
212
|
+
urgentThreat &&
|
|
213
|
+
state.blinkCooldown === 0 &&
|
|
214
|
+
urgentThreat.ticksToImpact >= BLINK_CAST_TIME &&
|
|
215
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
216
|
+
)
|
|
217
|
+
{
|
|
218
|
+
return {
|
|
219
|
+
move: {x: 0, y: 0},
|
|
220
|
+
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// === STEP 3b: SHIELD — fallback when blink unavailable (90% block) ===
|
|
225
|
+
if (
|
|
226
|
+
state.state === 'idle' &&
|
|
227
|
+
urgentThreat &&
|
|
228
|
+
urgentThreat.canBlockInTime &&
|
|
229
|
+
urgentThreat.ticksToStartShield <= SHIELD_BUFFER
|
|
230
|
+
)
|
|
231
|
+
{
|
|
232
|
+
return {
|
|
233
|
+
move: {x: 0, y: 0},
|
|
234
|
+
startCast: {spell: 'shield'},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// === STEP 4: PREEMPTIVE SHIELD — enemy casting at close range ===
|
|
239
|
+
if (
|
|
240
|
+
state.state === 'idle' &&
|
|
241
|
+
enemy &&
|
|
242
|
+
enemy.state === 'casting' &&
|
|
243
|
+
enemy.castingSpell === 'missile'
|
|
244
|
+
)
|
|
245
|
+
{
|
|
246
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
247
|
+
if (distance < preemptiveShieldRange)
|
|
248
|
+
{
|
|
249
|
+
const remainingCast =
|
|
250
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
251
|
+
const estimatedFlightTicks = distance / flightTimeDivisor;
|
|
252
|
+
const ticksUntilHit = remainingCast + estimatedFlightTicks;
|
|
253
|
+
if (
|
|
254
|
+
ticksUntilHit <
|
|
255
|
+
SHIELD_CAST_TIME + SHIELD_BUFFER + preemptiveShieldBuffer
|
|
256
|
+
)
|
|
257
|
+
{
|
|
258
|
+
return {
|
|
259
|
+
move: {x: 0, y: 0},
|
|
260
|
+
startCast: {spell: 'shield'},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// === STEP 5: OFFENSE — punish mode + standard counterattack ===
|
|
267
|
+
if (state.state === 'idle' && enemy)
|
|
268
|
+
{
|
|
269
|
+
const offenseDefenseTime =
|
|
270
|
+
state.blinkCooldown === 0
|
|
271
|
+
? BLINK_CAST_TIME
|
|
272
|
+
: SHIELD_CAST_TIME + SHIELD_BUFFER;
|
|
273
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
274
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
275
|
+
const vulnerabilityTicks = getEnemyVulnerabilityTicks(enemy);
|
|
276
|
+
|
|
277
|
+
// --- PUNISH MODE: exploit enemy cast/GCD vulnerability ---
|
|
278
|
+
if (vulnerabilityTicks >= punishMinVulnerability)
|
|
279
|
+
{
|
|
280
|
+
const flightTimeEstimate = distance / flightTimeDivisor;
|
|
281
|
+
const punishBudget = vulnerabilityTicks - flightTimeEstimate;
|
|
282
|
+
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
283
|
+
const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
|
|
284
|
+
const effectiveBudget = Math.min(
|
|
285
|
+
punishBudget,
|
|
286
|
+
safeBudget,
|
|
287
|
+
MAX_CAST_BUDGET,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (effectiveBudget > 0)
|
|
291
|
+
{
|
|
292
|
+
const config = fitMissileToBudget(effectiveBudget, distance, {
|
|
293
|
+
minTurnRate: 0,
|
|
294
|
+
maxDamage: enemyHP,
|
|
295
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (config && config.damage >= MIN_USEFUL_DAMAGE)
|
|
299
|
+
{
|
|
300
|
+
const castTime = getMissileCastTime(config, state.lastMissileConfig);
|
|
301
|
+
const actualFlight = distance / config.speed;
|
|
302
|
+
|
|
303
|
+
if (
|
|
304
|
+
castTime + actualFlight < vulnerabilityTicks &&
|
|
305
|
+
isSafeToAttack(threats, castTime)
|
|
306
|
+
)
|
|
307
|
+
{
|
|
308
|
+
const aimTarget =
|
|
309
|
+
config.turnRate > 0
|
|
310
|
+
? enemy.position
|
|
311
|
+
: getLeadPosition(
|
|
312
|
+
enemy.position,
|
|
313
|
+
enemy.velocity,
|
|
314
|
+
config.speed,
|
|
315
|
+
state.position,
|
|
316
|
+
);
|
|
317
|
+
return {
|
|
318
|
+
move: {x: 0, y: 0},
|
|
319
|
+
startCast: {
|
|
320
|
+
spell: 'missile',
|
|
321
|
+
config,
|
|
322
|
+
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
323
|
+
direction: angleTo(state.position, aimTarget),
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- STANDARD MODE: adaptive homing in safe windows ---
|
|
332
|
+
const nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
333
|
+
const safeBudget = nextThreatTime - GCD_DURATION - offenseDefenseTime;
|
|
334
|
+
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
335
|
+
|
|
336
|
+
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
337
|
+
minTurnRate,
|
|
338
|
+
maxDamage: enemyHP,
|
|
339
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (config && config.damage >= Math.min(MIN_USEFUL_DAMAGE, enemyHP))
|
|
343
|
+
{
|
|
344
|
+
return {
|
|
345
|
+
move: {x: 0, y: 0},
|
|
346
|
+
startCast: {
|
|
347
|
+
spell: 'missile',
|
|
348
|
+
config,
|
|
349
|
+
missileAI: config.turnRate > 0 ? HomingAI : StraightAI,
|
|
350
|
+
direction: angleTo(state.position, enemy.position),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {move: {x: 0, y: 0}};
|
|
357
|
+
};
|