@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.
Files changed (78) hide show
  1. package/README.md +28 -0
  2. package/dist/chunk-L7Z7OFXD.js +9140 -0
  3. package/dist/chunk-L7Z7OFXD.js.map +1 -0
  4. package/dist/index-browser.d.ts +2602 -0
  5. package/dist/index-browser.js +407 -0
  6. package/dist/index-browser.js.map +1 -0
  7. package/dist/index.d.ts +150 -0
  8. package/dist/index.js +750 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +79 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +457 -0
  12. package/src/bots/berserker/02_Stormcaller.ts +417 -0
  13. package/src/bots/berserker/03_Stormforger.ts +481 -0
  14. package/src/bots/caster/01_Flamecaller.ts +286 -0
  15. package/src/bots/caster/02_Pyromancer.ts +350 -0
  16. package/src/bots/caster/03_Infernalist.ts +492 -0
  17. package/src/bots/defensive/01_Turtle.ts +151 -0
  18. package/src/bots/defensive/02_Sentinel.ts +134 -0
  19. package/src/bots/defensive/03_Golem.ts +357 -0
  20. package/src/bots/duelist/01_Battlemage.ts +433 -0
  21. package/src/bots/duelist/02_Warmage.ts +438 -0
  22. package/src/bots/duelist/03_Archmage.ts +588 -0
  23. package/src/bots/homing/01_Bonemancer.ts +67 -0
  24. package/src/bots/homing/02_Lich.ts +356 -0
  25. package/src/bots/homing/03_Archlich.ts +220 -0
  26. package/src/bots/index.ts +30 -0
  27. package/src/bots/kiter/01_Spellspinner.ts +398 -0
  28. package/src/bots/kiter/02_Spellweaver.ts +378 -0
  29. package/src/bots/kiter/03_Spellbinder.ts +448 -0
  30. package/src/bots/melee/01_Shadowblade.ts +270 -0
  31. package/src/bots/melee/02_Nightblade.ts +437 -0
  32. package/src/bots/melee/03_Voidblade.ts +582 -0
  33. package/src/bots/registry.ts +207 -0
  34. package/src/bots/shared.ts +472 -0
  35. package/src/bots/sniper/01_Spellshot.ts +385 -0
  36. package/src/bots/sniper/02_Spelltracer.ts +441 -0
  37. package/src/bots/sniper/03_Spellseeker.ts +546 -0
  38. package/src/bots/standalone/Critter.ts +89 -0
  39. package/src/bots/standalone/Doombringer.ts +91 -0
  40. package/src/bots/standalone/Hogger.ts +228 -0
  41. package/src/bots/standalone/Rookie.ts +50 -0
  42. package/src/bots/standalone/TargetDummy.ts +21 -0
  43. package/src/bots/test/cheater.ts +405 -0
  44. package/src/bots/test/crasher.ts +81 -0
  45. package/src/engine/hooks-runtime.ts +394 -0
  46. package/src/engine/manual-match.ts +289 -0
  47. package/src/engine/missile-templates.ts +155 -0
  48. package/src/engine/optimizer.ts +220 -0
  49. package/src/engine/params-runtime.ts +189 -0
  50. package/src/engine/physics.ts +143 -0
  51. package/src/engine/sandbox-browser.ts +671 -0
  52. package/src/engine/sandbox-compile.ts +197 -0
  53. package/src/engine/sandbox-harness.ts +367 -0
  54. package/src/engine/sandbox.ts +332 -0
  55. package/src/engine/simulation.ts +828 -0
  56. package/src/engine/spells.ts +128 -0
  57. package/src/engine-version.ts +11 -0
  58. package/src/hooks/action-builders.ts +210 -0
  59. package/src/hooks/bot-wrapper.ts +84 -0
  60. package/src/hooks/index.ts +75 -0
  61. package/src/hooks/state-hooks.ts +354 -0
  62. package/src/hooks/threat-analysis.ts +365 -0
  63. package/src/hooks/types.ts +142 -0
  64. package/src/index-browser.ts +30 -0
  65. package/src/index.ts +24 -0
  66. package/src/rules.ts +254 -0
  67. package/src/stats.ts +262 -0
  68. package/src/testing.ts +207 -0
  69. package/src/trace.ts +430 -0
  70. package/src/types.ts +193 -0
  71. package/src/utils/angles.ts +47 -0
  72. package/src/utils/combat.ts +279 -0
  73. package/src/utils/distance.ts +21 -0
  74. package/src/utils/index.ts +7 -0
  75. package/src/utils/movement.ts +108 -0
  76. package/src/utils/random.ts +65 -0
  77. package/src/utils/spatial.ts +63 -0
  78. 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
+ }