@vibemancer/core 0.1.4 → 0.1.6

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