@vibemancer/core 0.1.0 → 0.1.2
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 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-OL7ETV6V.js} +3 -3
- package/dist/chunk-OL7ETV6V.js.map +1 -0
- package/dist/index-browser.d.ts +1 -1
- package/dist/index-browser.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +79 -78
- package/src/bots/berserker/01_Stormchaser.ts +457 -457
- package/src/bots/berserker/02_Stormcaller.ts +417 -417
- package/src/bots/berserker/03_Stormforger.ts +481 -481
- package/src/bots/caster/01_Flamecaller.ts +286 -286
- package/src/bots/caster/02_Pyromancer.ts +350 -350
- package/src/bots/caster/03_Infernalist.ts +492 -492
- package/src/bots/defensive/01_Turtle.ts +151 -151
- package/src/bots/defensive/02_Sentinel.ts +134 -134
- package/src/bots/defensive/03_Golem.ts +357 -357
- package/src/bots/duelist/01_Battlemage.ts +433 -433
- package/src/bots/duelist/02_Warmage.ts +438 -438
- package/src/bots/duelist/03_Archmage.ts +588 -588
- package/src/bots/homing/01_Bonemancer.ts +67 -67
- package/src/bots/homing/02_Lich.ts +356 -356
- package/src/bots/homing/03_Archlich.ts +220 -220
- package/src/bots/kiter/01_Spellspinner.ts +398 -398
- package/src/bots/kiter/02_Spellweaver.ts +378 -378
- package/src/bots/kiter/03_Spellbinder.ts +448 -448
- package/src/bots/melee/01_Shadowblade.ts +270 -270
- package/src/bots/melee/02_Nightblade.ts +437 -437
- package/src/bots/melee/03_Voidblade.ts +582 -582
- package/src/bots/sniper/01_Spellshot.ts +385 -385
- package/src/bots/sniper/02_Spelltracer.ts +441 -441
- package/src/bots/sniper/03_Spellseeker.ts +546 -546
- package/src/bots/standalone/Critter.ts +89 -89
- package/src/bots/standalone/Doombringer.ts +91 -91
- package/src/bots/standalone/Hogger.ts +228 -228
- package/src/bots/standalone/Rookie.ts +50 -50
- package/src/bots/standalone/TargetDummy.ts +21 -21
- package/src/bots/test/cheater.ts +405 -405
- package/src/bots/test/crasher.ts +81 -81
- package/src/engine/hooks-runtime.ts +394 -394
- package/src/engine/manual-match.ts +289 -289
- package/src/engine/missile-templates.ts +155 -155
- package/src/engine/physics.ts +143 -143
- package/src/engine/simulation.ts +828 -828
- package/src/engine/spells.ts +128 -128
- package/src/engine-version.ts +1 -1
- package/src/index.ts +23 -23
- package/src/rules.ts +254 -254
- package/src/types.ts +193 -193
- package/src/utils/index.ts +6 -6
- package/dist/chunk-L7Z7OFXD.js.map +0 -1
|
@@ -1,356 +1,356 @@
|
|
|
1
|
-
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
-
import {angleTo} from '../../utils/angles.js';
|
|
3
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
-
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
5
|
-
import {
|
|
6
|
-
getThreats,
|
|
7
|
-
getMostUrgentThreat,
|
|
8
|
-
getDodgeDirectionForMovement,
|
|
9
|
-
getBlinkToCenter,
|
|
10
|
-
fitMissileToBudget,
|
|
11
|
-
shouldForceShield,
|
|
12
|
-
MAX_CAST_BUDGET,
|
|
13
|
-
MIN_USEFUL_DAMAGE,
|
|
14
|
-
} from '../shared.js';
|
|
15
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
16
|
-
|
|
17
|
-
const WALL_BUFFER = 60;
|
|
18
|
-
|
|
19
|
-
const SHIELD_BUFFER = 3;
|
|
20
|
-
|
|
21
|
-
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
22
|
-
{
|
|
23
|
-
const enemy = worldState.enemies[0];
|
|
24
|
-
return {turnToward: enemy?.position};
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
function isNearWall(position: {x: number; y: number}): boolean
|
|
28
|
-
{
|
|
29
|
-
return (
|
|
30
|
-
position.x < WALL_BUFFER ||
|
|
31
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
32
|
-
position.y < WALL_BUFFER ||
|
|
33
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function smartStrafe(
|
|
38
|
-
position: {x: number; y: number},
|
|
39
|
-
enemy: {position: {x: number; y: number}},
|
|
40
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
41
|
-
tick: number,
|
|
42
|
-
strafeCyclePeriod: number,
|
|
43
|
-
strafeIntensity: number,
|
|
44
|
-
): {x: number; y: number}
|
|
45
|
-
{
|
|
46
|
-
const deltaX = enemy.position.x - position.x;
|
|
47
|
-
const deltaY = enemy.position.y - position.y;
|
|
48
|
-
const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
49
|
-
if (normalizedDistance === 0) return {x: 0, y: 0};
|
|
50
|
-
|
|
51
|
-
const strafeClockwise = {
|
|
52
|
-
x: -deltaY / normalizedDistance,
|
|
53
|
-
y: deltaX / normalizedDistance,
|
|
54
|
-
};
|
|
55
|
-
const strafeCounterClockwise = {
|
|
56
|
-
x: deltaY / normalizedDistance,
|
|
57
|
-
y: -deltaX / normalizedDistance,
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
let strafeDir: {x: number; y: number};
|
|
61
|
-
|
|
62
|
-
if (dodgeDirection)
|
|
63
|
-
{
|
|
64
|
-
strafeDir = dodgeDirection;
|
|
65
|
-
}
|
|
66
|
-
else if (isNearWall(position))
|
|
67
|
-
{
|
|
68
|
-
const futureClockwise = {
|
|
69
|
-
x: position.x + strafeClockwise.x * 100,
|
|
70
|
-
y: position.y + strafeClockwise.y * 100,
|
|
71
|
-
};
|
|
72
|
-
const futureCounterClockwise = {
|
|
73
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
74
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
75
|
-
};
|
|
76
|
-
const scoreClockwise = Math.min(
|
|
77
|
-
futureClockwise.x,
|
|
78
|
-
ARENA_SIZE - futureClockwise.x,
|
|
79
|
-
futureClockwise.y,
|
|
80
|
-
ARENA_SIZE - futureClockwise.y,
|
|
81
|
-
);
|
|
82
|
-
const scoreCounterClockwise = Math.min(
|
|
83
|
-
futureCounterClockwise.x,
|
|
84
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
85
|
-
futureCounterClockwise.y,
|
|
86
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
87
|
-
);
|
|
88
|
-
strafeDir =
|
|
89
|
-
scoreClockwise > scoreCounterClockwise
|
|
90
|
-
? strafeClockwise
|
|
91
|
-
: strafeCounterClockwise;
|
|
92
|
-
}
|
|
93
|
-
else
|
|
94
|
-
{
|
|
95
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
96
|
-
strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const intensity = dodgeDirection ? 100 : strafeIntensity;
|
|
100
|
-
return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Bot: Lich
|
|
105
|
-
*
|
|
106
|
-
* BEHAVIOR: Homing missile specialist with strong-tracking adaptive missiles.
|
|
107
|
-
* Uses fitMissileToBudget with minTurnRate 1.0 — higher than other bots (0.5) —
|
|
108
|
-
* producing missiles with superior tracking at the cost of some damage/speed.
|
|
109
|
-
* Strafing launches missiles from different angles, creating multi-angle pressure.
|
|
110
|
-
*
|
|
111
|
-
* Shields undodgeable/critical threats, emergency blinks. Cancels missile cast
|
|
112
|
-
* only for lethal incoming damage.
|
|
113
|
-
*
|
|
114
|
-
* KEY DIFFERENCES FROM BONEMANCER:
|
|
115
|
-
* - Bonemancer: stationary, no defense, fixed d=7/s=3/t=2/dur=300
|
|
116
|
-
* - Lich: mobile, full defense, adaptive strong-tracking homing missiles
|
|
117
|
-
*
|
|
118
|
-
* PROGRESSION LINE: Bonemancer → Lich → Archlich
|
|
119
|
-
* - Bonemancer (tier 1): Stationary, spams fixed slow homing missiles, no defense
|
|
120
|
-
* - Lich (tier 2): Mobile + defense, adaptive strong-tracking missiles (minTurnRate 1.0)
|
|
121
|
-
* - Archlich (tier 3): Future — converging web patterns, impossible to escape
|
|
122
|
-
*
|
|
123
|
-
* TIER: 2 (enhanced Bonemancer)
|
|
124
|
-
*/
|
|
125
|
-
export const Lich: WizardFunction = ({state}) =>
|
|
126
|
-
{
|
|
127
|
-
const targetDistance = useParam('targetDistance', 768, {
|
|
128
|
-
range: 175,
|
|
129
|
-
min: 0,
|
|
130
|
-
});
|
|
131
|
-
const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, steps: 5});
|
|
132
|
-
const shieldHealthThreshold = useParam('shieldHealthThreshold', 99, {
|
|
133
|
-
range: 23,
|
|
134
|
-
min: 1,
|
|
135
|
-
steps: 7,
|
|
136
|
-
});
|
|
137
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 138, {
|
|
138
|
-
range: 75,
|
|
139
|
-
min: 80,
|
|
140
|
-
max: 300,
|
|
141
|
-
});
|
|
142
|
-
const strafeIntensity = useParam('strafeIntensity', 100, {
|
|
143
|
-
range: 40,
|
|
144
|
-
min: 20,
|
|
145
|
-
max: 100,
|
|
146
|
-
steps: 7,
|
|
147
|
-
});
|
|
148
|
-
const approachSpeed = useParam('approachSpeed', 62, {
|
|
149
|
-
range: 30,
|
|
150
|
-
min: 10,
|
|
151
|
-
max: 70,
|
|
152
|
-
steps: 7,
|
|
153
|
-
});
|
|
154
|
-
const distanceDeadZone = useParam('distanceDeadZone', 7, {
|
|
155
|
-
range: 30,
|
|
156
|
-
min: 5,
|
|
157
|
-
max: 80,
|
|
158
|
-
steps: 7,
|
|
159
|
-
});
|
|
160
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
161
|
-
range: 75,
|
|
162
|
-
min: 50,
|
|
163
|
-
max: 300,
|
|
164
|
-
});
|
|
165
|
-
const blinkWindowMax = useParam('blinkWindowMax', 27, {
|
|
166
|
-
range: 30,
|
|
167
|
-
min: 15,
|
|
168
|
-
max: 80,
|
|
169
|
-
steps: 7,
|
|
170
|
-
});
|
|
171
|
-
const flightTimeDivisor = useParam('flightTimeDivisor', 5.8, {
|
|
172
|
-
range: 4,
|
|
173
|
-
min: 2,
|
|
174
|
-
max: 12,
|
|
175
|
-
steps: 5,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const enemy = state.enemies[0];
|
|
179
|
-
if (!enemy)
|
|
180
|
-
{
|
|
181
|
-
return {move: {x: 0, y: 0}};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
185
|
-
|
|
186
|
-
const threats = getThreats(state);
|
|
187
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
188
|
-
|
|
189
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
190
|
-
const strafe = smartStrafe(
|
|
191
|
-
state.position,
|
|
192
|
-
enemy,
|
|
193
|
-
dodgeDirection,
|
|
194
|
-
state.tick,
|
|
195
|
-
strafeCyclePeriod,
|
|
196
|
-
strafeIntensity,
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
// Distance management (retreat if too close, approach if too far)
|
|
200
|
-
let moveX = strafe.x;
|
|
201
|
-
let moveY = strafe.y;
|
|
202
|
-
|
|
203
|
-
if (!dodgeDirection)
|
|
204
|
-
{
|
|
205
|
-
const deltaX = enemy.position.x - state.position.x;
|
|
206
|
-
const deltaY = enemy.position.y - state.position.y;
|
|
207
|
-
const normalizedDistance = distance > 0 ? distance : 1;
|
|
208
|
-
|
|
209
|
-
if (distance > targetDistance + distanceDeadZone)
|
|
210
|
-
{
|
|
211
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
212
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
213
|
-
}
|
|
214
|
-
else if (distance < targetDistance - distanceDeadZone)
|
|
215
|
-
{
|
|
216
|
-
moveX -= (deltaX / normalizedDistance) * approachSpeed;
|
|
217
|
-
moveY -= (deltaY / normalizedDistance) * approachSpeed;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
222
|
-
if (magnitude > 100)
|
|
223
|
-
{
|
|
224
|
-
moveX = (moveX / magnitude) * 100;
|
|
225
|
-
moveY = (moveY / magnitude) * 100;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const move = {x: moveX, y: moveY};
|
|
229
|
-
|
|
230
|
-
// === DEFENSE: Handle channeling ===
|
|
231
|
-
if (state.state === 'channeling')
|
|
232
|
-
{
|
|
233
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
234
|
-
{
|
|
235
|
-
return {move, cancel: true};
|
|
236
|
-
}
|
|
237
|
-
return {move: {x: 0, y: 0}};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
|
|
241
|
-
if (
|
|
242
|
-
state.state === 'casting' &&
|
|
243
|
-
state.castingSpell === 'missile' &&
|
|
244
|
-
urgentThreat &&
|
|
245
|
-
urgentThreat.projectile.damage >= state.health
|
|
246
|
-
)
|
|
247
|
-
{
|
|
248
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
249
|
-
if (
|
|
250
|
-
urgentThreat.ticksToImpact <=
|
|
251
|
-
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
|
|
252
|
-
)
|
|
253
|
-
{
|
|
254
|
-
return {move, cancel: true};
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// === BLINK: Dodge missile + reposition (first priority) ===
|
|
259
|
-
// Blink dodges the incoming missile — no GCD after, so can immediately resume casting.
|
|
260
|
-
// Only shield when blink is on cooldown.
|
|
261
|
-
if (
|
|
262
|
-
state.state === 'idle' &&
|
|
263
|
-
urgentThreat &&
|
|
264
|
-
state.blinkCooldown === 0 &&
|
|
265
|
-
urgentThreat.ticksToImpact >= 10 &&
|
|
266
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
267
|
-
)
|
|
268
|
-
{
|
|
269
|
-
return {
|
|
270
|
-
move: {x: 0, y: 0},
|
|
271
|
-
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// === DEFENSE: Shield threats (when blink on cooldown) ===
|
|
276
|
-
// Shield undodgeable, high-damage homing, or when health is critical
|
|
277
|
-
if (state.state === 'idle' && urgentThreat)
|
|
278
|
-
{
|
|
279
|
-
const forceShield = shouldForceShield(urgentThreat);
|
|
280
|
-
const undodgeable = !urgentThreat.bestDodgeDirection;
|
|
281
|
-
const healthLow = state.health < shieldHealthThreshold;
|
|
282
|
-
const healthCritical =
|
|
283
|
-
state.health <= urgentThreat.projectile.damage * 2 + 1;
|
|
284
|
-
const doShield =
|
|
285
|
-
forceShield || (undodgeable && healthLow) || healthCritical;
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
doShield &&
|
|
289
|
-
urgentThreat.canBlockInTime &&
|
|
290
|
-
urgentThreat.ticksToStartShield <= SHIELD_BUFFER
|
|
291
|
-
)
|
|
292
|
-
{
|
|
293
|
-
return {
|
|
294
|
-
move: {x: 0, y: 0},
|
|
295
|
-
startCast: {spell: 'shield'},
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// === OFFENSE: Adaptive strong-tracking homing missiles ===
|
|
301
|
-
// minTurnRate 1.0 gives better tracking than other bots (0.5) at the cost of damage/speed.
|
|
302
|
-
// Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
|
|
303
|
-
const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
|
|
304
|
-
const hasNearbyLethalMissile = state.projectiles.some(
|
|
305
|
-
(p) =>
|
|
306
|
-
!myProjectileIds.has(p.id) &&
|
|
307
|
-
p.damage >= state.health &&
|
|
308
|
-
distanceTo(state.position, p.position) / p.speed <
|
|
309
|
-
MAX_CAST_BUDGET + GCD_DURATION,
|
|
310
|
-
);
|
|
311
|
-
if (state.state === 'idle' && !hasNearbyLethalMissile)
|
|
312
|
-
{
|
|
313
|
-
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
314
|
-
|
|
315
|
-
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
316
|
-
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
317
|
-
{
|
|
318
|
-
const remainingEnemyCast =
|
|
319
|
-
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
320
|
-
const estimatedFlightTime = distance / flightTimeDivisor;
|
|
321
|
-
nextThreatTime = Math.min(
|
|
322
|
-
nextThreatTime,
|
|
323
|
-
remainingEnemyCast + estimatedFlightTime,
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const safeBudget =
|
|
328
|
-
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
329
|
-
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
330
|
-
|
|
331
|
-
// Lich specializes in strong tracking (minTurnRate 1.0 vs typical 0.5)
|
|
332
|
-
const enemyHP = Math.ceil(enemy.health);
|
|
333
|
-
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
334
|
-
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
335
|
-
minTurnRate,
|
|
336
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
if (config && config.damage >= minDmg)
|
|
340
|
-
{
|
|
341
|
-
return {
|
|
342
|
-
move,
|
|
343
|
-
startCast: {
|
|
344
|
-
spell: 'missile',
|
|
345
|
-
config,
|
|
346
|
-
missileAI: HomingAI,
|
|
347
|
-
direction: angleTo(state.position, enemy.position),
|
|
348
|
-
},
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Budget too small for useful missile — wait for threat to pass, then fire
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return {move};
|
|
356
|
-
};
|
|
1
|
+
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
+
import {angleTo} from '../../utils/angles.js';
|
|
3
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
+
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
5
|
+
import {
|
|
6
|
+
getThreats,
|
|
7
|
+
getMostUrgentThreat,
|
|
8
|
+
getDodgeDirectionForMovement,
|
|
9
|
+
getBlinkToCenter,
|
|
10
|
+
fitMissileToBudget,
|
|
11
|
+
shouldForceShield,
|
|
12
|
+
MAX_CAST_BUDGET,
|
|
13
|
+
MIN_USEFUL_DAMAGE,
|
|
14
|
+
} from '../shared.js';
|
|
15
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
16
|
+
|
|
17
|
+
const WALL_BUFFER = 60;
|
|
18
|
+
|
|
19
|
+
const SHIELD_BUFFER = 3;
|
|
20
|
+
|
|
21
|
+
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
22
|
+
{
|
|
23
|
+
const enemy = worldState.enemies[0];
|
|
24
|
+
return {turnToward: enemy?.position};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isNearWall(position: {x: number; y: number}): boolean
|
|
28
|
+
{
|
|
29
|
+
return (
|
|
30
|
+
position.x < WALL_BUFFER ||
|
|
31
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
32
|
+
position.y < WALL_BUFFER ||
|
|
33
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function smartStrafe(
|
|
38
|
+
position: {x: number; y: number},
|
|
39
|
+
enemy: {position: {x: number; y: number}},
|
|
40
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
41
|
+
tick: number,
|
|
42
|
+
strafeCyclePeriod: number,
|
|
43
|
+
strafeIntensity: number,
|
|
44
|
+
): {x: number; y: number}
|
|
45
|
+
{
|
|
46
|
+
const deltaX = enemy.position.x - position.x;
|
|
47
|
+
const deltaY = enemy.position.y - position.y;
|
|
48
|
+
const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
49
|
+
if (normalizedDistance === 0) return {x: 0, y: 0};
|
|
50
|
+
|
|
51
|
+
const strafeClockwise = {
|
|
52
|
+
x: -deltaY / normalizedDistance,
|
|
53
|
+
y: deltaX / normalizedDistance,
|
|
54
|
+
};
|
|
55
|
+
const strafeCounterClockwise = {
|
|
56
|
+
x: deltaY / normalizedDistance,
|
|
57
|
+
y: -deltaX / normalizedDistance,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let strafeDir: {x: number; y: number};
|
|
61
|
+
|
|
62
|
+
if (dodgeDirection)
|
|
63
|
+
{
|
|
64
|
+
strafeDir = dodgeDirection;
|
|
65
|
+
}
|
|
66
|
+
else if (isNearWall(position))
|
|
67
|
+
{
|
|
68
|
+
const futureClockwise = {
|
|
69
|
+
x: position.x + strafeClockwise.x * 100,
|
|
70
|
+
y: position.y + strafeClockwise.y * 100,
|
|
71
|
+
};
|
|
72
|
+
const futureCounterClockwise = {
|
|
73
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
74
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
75
|
+
};
|
|
76
|
+
const scoreClockwise = Math.min(
|
|
77
|
+
futureClockwise.x,
|
|
78
|
+
ARENA_SIZE - futureClockwise.x,
|
|
79
|
+
futureClockwise.y,
|
|
80
|
+
ARENA_SIZE - futureClockwise.y,
|
|
81
|
+
);
|
|
82
|
+
const scoreCounterClockwise = Math.min(
|
|
83
|
+
futureCounterClockwise.x,
|
|
84
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
85
|
+
futureCounterClockwise.y,
|
|
86
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
87
|
+
);
|
|
88
|
+
strafeDir =
|
|
89
|
+
scoreClockwise > scoreCounterClockwise
|
|
90
|
+
? strafeClockwise
|
|
91
|
+
: strafeCounterClockwise;
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
{
|
|
95
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
96
|
+
strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const intensity = dodgeDirection ? 100 : strafeIntensity;
|
|
100
|
+
return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Bot: Lich
|
|
105
|
+
*
|
|
106
|
+
* BEHAVIOR: Homing missile specialist with strong-tracking adaptive missiles.
|
|
107
|
+
* Uses fitMissileToBudget with minTurnRate 1.0 — higher than other bots (0.5) —
|
|
108
|
+
* producing missiles with superior tracking at the cost of some damage/speed.
|
|
109
|
+
* Strafing launches missiles from different angles, creating multi-angle pressure.
|
|
110
|
+
*
|
|
111
|
+
* Shields undodgeable/critical threats, emergency blinks. Cancels missile cast
|
|
112
|
+
* only for lethal incoming damage.
|
|
113
|
+
*
|
|
114
|
+
* KEY DIFFERENCES FROM BONEMANCER:
|
|
115
|
+
* - Bonemancer: stationary, no defense, fixed d=7/s=3/t=2/dur=300
|
|
116
|
+
* - Lich: mobile, full defense, adaptive strong-tracking homing missiles
|
|
117
|
+
*
|
|
118
|
+
* PROGRESSION LINE: Bonemancer → Lich → Archlich
|
|
119
|
+
* - Bonemancer (tier 1): Stationary, spams fixed slow homing missiles, no defense
|
|
120
|
+
* - Lich (tier 2): Mobile + defense, adaptive strong-tracking missiles (minTurnRate 1.0)
|
|
121
|
+
* - Archlich (tier 3): Future — converging web patterns, impossible to escape
|
|
122
|
+
*
|
|
123
|
+
* TIER: 2 (enhanced Bonemancer)
|
|
124
|
+
*/
|
|
125
|
+
export const Lich: WizardFunction = ({state}) =>
|
|
126
|
+
{
|
|
127
|
+
const targetDistance = useParam('targetDistance', 768, {
|
|
128
|
+
range: 175,
|
|
129
|
+
min: 0,
|
|
130
|
+
});
|
|
131
|
+
const minTurnRate = useParam('minTurnRate', 0.4, {range: 1.5, steps: 5});
|
|
132
|
+
const shieldHealthThreshold = useParam('shieldHealthThreshold', 99, {
|
|
133
|
+
range: 23,
|
|
134
|
+
min: 1,
|
|
135
|
+
steps: 7,
|
|
136
|
+
});
|
|
137
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 138, {
|
|
138
|
+
range: 75,
|
|
139
|
+
min: 80,
|
|
140
|
+
max: 300,
|
|
141
|
+
});
|
|
142
|
+
const strafeIntensity = useParam('strafeIntensity', 100, {
|
|
143
|
+
range: 40,
|
|
144
|
+
min: 20,
|
|
145
|
+
max: 100,
|
|
146
|
+
steps: 7,
|
|
147
|
+
});
|
|
148
|
+
const approachSpeed = useParam('approachSpeed', 62, {
|
|
149
|
+
range: 30,
|
|
150
|
+
min: 10,
|
|
151
|
+
max: 70,
|
|
152
|
+
steps: 7,
|
|
153
|
+
});
|
|
154
|
+
const distanceDeadZone = useParam('distanceDeadZone', 7, {
|
|
155
|
+
range: 30,
|
|
156
|
+
min: 5,
|
|
157
|
+
max: 80,
|
|
158
|
+
steps: 7,
|
|
159
|
+
});
|
|
160
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
161
|
+
range: 75,
|
|
162
|
+
min: 50,
|
|
163
|
+
max: 300,
|
|
164
|
+
});
|
|
165
|
+
const blinkWindowMax = useParam('blinkWindowMax', 27, {
|
|
166
|
+
range: 30,
|
|
167
|
+
min: 15,
|
|
168
|
+
max: 80,
|
|
169
|
+
steps: 7,
|
|
170
|
+
});
|
|
171
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 5.8, {
|
|
172
|
+
range: 4,
|
|
173
|
+
min: 2,
|
|
174
|
+
max: 12,
|
|
175
|
+
steps: 5,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const enemy = state.enemies[0];
|
|
179
|
+
if (!enemy)
|
|
180
|
+
{
|
|
181
|
+
return {move: {x: 0, y: 0}};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
185
|
+
|
|
186
|
+
const threats = getThreats(state);
|
|
187
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
188
|
+
|
|
189
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
190
|
+
const strafe = smartStrafe(
|
|
191
|
+
state.position,
|
|
192
|
+
enemy,
|
|
193
|
+
dodgeDirection,
|
|
194
|
+
state.tick,
|
|
195
|
+
strafeCyclePeriod,
|
|
196
|
+
strafeIntensity,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Distance management (retreat if too close, approach if too far)
|
|
200
|
+
let moveX = strafe.x;
|
|
201
|
+
let moveY = strafe.y;
|
|
202
|
+
|
|
203
|
+
if (!dodgeDirection)
|
|
204
|
+
{
|
|
205
|
+
const deltaX = enemy.position.x - state.position.x;
|
|
206
|
+
const deltaY = enemy.position.y - state.position.y;
|
|
207
|
+
const normalizedDistance = distance > 0 ? distance : 1;
|
|
208
|
+
|
|
209
|
+
if (distance > targetDistance + distanceDeadZone)
|
|
210
|
+
{
|
|
211
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
212
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
213
|
+
}
|
|
214
|
+
else if (distance < targetDistance - distanceDeadZone)
|
|
215
|
+
{
|
|
216
|
+
moveX -= (deltaX / normalizedDistance) * approachSpeed;
|
|
217
|
+
moveY -= (deltaY / normalizedDistance) * approachSpeed;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
222
|
+
if (magnitude > 100)
|
|
223
|
+
{
|
|
224
|
+
moveX = (moveX / magnitude) * 100;
|
|
225
|
+
moveY = (moveY / magnitude) * 100;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const move = {x: moveX, y: moveY};
|
|
229
|
+
|
|
230
|
+
// === DEFENSE: Handle channeling ===
|
|
231
|
+
if (state.state === 'channeling')
|
|
232
|
+
{
|
|
233
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
234
|
+
{
|
|
235
|
+
return {move, cancel: true};
|
|
236
|
+
}
|
|
237
|
+
return {move: {x: 0, y: 0}};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// === DEFENSE: Cancel missile cast only for LETHAL incoming damage ===
|
|
241
|
+
if (
|
|
242
|
+
state.state === 'casting' &&
|
|
243
|
+
state.castingSpell === 'missile' &&
|
|
244
|
+
urgentThreat &&
|
|
245
|
+
urgentThreat.projectile.damage >= state.health
|
|
246
|
+
)
|
|
247
|
+
{
|
|
248
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
249
|
+
if (
|
|
250
|
+
urgentThreat.ticksToImpact <=
|
|
251
|
+
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
|
|
252
|
+
)
|
|
253
|
+
{
|
|
254
|
+
return {move, cancel: true};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// === BLINK: Dodge missile + reposition (first priority) ===
|
|
259
|
+
// Blink dodges the incoming missile — no GCD after, so can immediately resume casting.
|
|
260
|
+
// Only shield when blink is on cooldown.
|
|
261
|
+
if (
|
|
262
|
+
state.state === 'idle' &&
|
|
263
|
+
urgentThreat &&
|
|
264
|
+
state.blinkCooldown === 0 &&
|
|
265
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
266
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
267
|
+
)
|
|
268
|
+
{
|
|
269
|
+
return {
|
|
270
|
+
move: {x: 0, y: 0},
|
|
271
|
+
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// === DEFENSE: Shield threats (when blink on cooldown) ===
|
|
276
|
+
// Shield undodgeable, high-damage homing, or when health is critical
|
|
277
|
+
if (state.state === 'idle' && urgentThreat)
|
|
278
|
+
{
|
|
279
|
+
const forceShield = shouldForceShield(urgentThreat);
|
|
280
|
+
const undodgeable = !urgentThreat.bestDodgeDirection;
|
|
281
|
+
const healthLow = state.health < shieldHealthThreshold;
|
|
282
|
+
const healthCritical =
|
|
283
|
+
state.health <= urgentThreat.projectile.damage * 2 + 1;
|
|
284
|
+
const doShield =
|
|
285
|
+
forceShield || (undodgeable && healthLow) || healthCritical;
|
|
286
|
+
|
|
287
|
+
if (
|
|
288
|
+
doShield &&
|
|
289
|
+
urgentThreat.canBlockInTime &&
|
|
290
|
+
urgentThreat.ticksToStartShield <= SHIELD_BUFFER
|
|
291
|
+
)
|
|
292
|
+
{
|
|
293
|
+
return {
|
|
294
|
+
move: {x: 0, y: 0},
|
|
295
|
+
startCast: {spell: 'shield'},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// === OFFENSE: Adaptive strong-tracking homing missiles ===
|
|
301
|
+
// minTurnRate 1.0 gives better tracking than other bots (0.5) at the cost of damage/speed.
|
|
302
|
+
// Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
|
|
303
|
+
const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
|
|
304
|
+
const hasNearbyLethalMissile = state.projectiles.some(
|
|
305
|
+
(p) =>
|
|
306
|
+
!myProjectileIds.has(p.id) &&
|
|
307
|
+
p.damage >= state.health &&
|
|
308
|
+
distanceTo(state.position, p.position) / p.speed <
|
|
309
|
+
MAX_CAST_BUDGET + GCD_DURATION,
|
|
310
|
+
);
|
|
311
|
+
if (state.state === 'idle' && !hasNearbyLethalMissile)
|
|
312
|
+
{
|
|
313
|
+
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
314
|
+
|
|
315
|
+
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
316
|
+
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
317
|
+
{
|
|
318
|
+
const remainingEnemyCast =
|
|
319
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
320
|
+
const estimatedFlightTime = distance / flightTimeDivisor;
|
|
321
|
+
nextThreatTime = Math.min(
|
|
322
|
+
nextThreatTime,
|
|
323
|
+
remainingEnemyCast + estimatedFlightTime,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const safeBudget =
|
|
328
|
+
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
329
|
+
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
330
|
+
|
|
331
|
+
// Lich specializes in strong tracking (minTurnRate 1.0 vs typical 0.5)
|
|
332
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
333
|
+
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
334
|
+
const config = fitMissileToBudget(budgetTicks, distance, {
|
|
335
|
+
minTurnRate,
|
|
336
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (config && config.damage >= minDmg)
|
|
340
|
+
{
|
|
341
|
+
return {
|
|
342
|
+
move,
|
|
343
|
+
startCast: {
|
|
344
|
+
spell: 'missile',
|
|
345
|
+
config,
|
|
346
|
+
missileAI: HomingAI,
|
|
347
|
+
direction: angleTo(state.position, enemy.position),
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Budget too small for useful missile — wait for threat to pass, then fire
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {move};
|
|
356
|
+
};
|