@vibemancer/core 0.1.3 → 0.1.5

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 (74) hide show
  1. package/README.md +2 -2
  2. package/dist/chunk-W7HWJFQ4.js +10599 -0
  3. package/dist/chunk-W7HWJFQ4.js.map +1 -0
  4. package/dist/index-browser.d.ts +1300 -1051
  5. package/dist/index-browser.js +55 -17
  6. package/dist/index.d.ts +53 -3
  7. package/dist/index.js +200 -54
  8. package/dist/index.js.map +1 -1
  9. package/package.json +21 -15
  10. package/src/bots/Hero.ts +867 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +162 -120
  12. package/src/bots/berserker/02_Stormcaller.ts +160 -116
  13. package/src/bots/berserker/03_Stormforger.ts +162 -129
  14. package/src/bots/caster/01_Flamecaller.ts +126 -84
  15. package/src/bots/caster/02_Pyromancer.ts +148 -101
  16. package/src/bots/caster/03_Infernalist.ts +180 -160
  17. package/src/bots/defensive/01_Turtle.ts +48 -44
  18. package/src/bots/defensive/02_Sentinel.ts +57 -53
  19. package/src/bots/defensive/03_Golem.ts +85 -97
  20. package/src/bots/duelist/01_Battlemage.ts +157 -122
  21. package/src/bots/duelist/02_Warmage.ts +166 -121
  22. package/src/bots/duelist/03_Archmage.ts +198 -178
  23. package/src/bots/homing/01_Bonemancer.ts +20 -32
  24. package/src/bots/homing/02_Lich.ts +145 -93
  25. package/src/bots/homing/03_Archlich.ts +129 -60
  26. package/src/bots/kiter/01_Spellspinner.ts +145 -107
  27. package/src/bots/kiter/02_Spellweaver.ts +153 -105
  28. package/src/bots/kiter/03_Spellbinder.ts +168 -123
  29. package/src/bots/melee/01_Shadowblade.ts +131 -75
  30. package/src/bots/melee/02_Nightblade.ts +174 -130
  31. package/src/bots/melee/03_Voidblade.ts +266 -168
  32. package/src/bots/registry.ts +44 -37
  33. package/src/bots/shared.ts +185 -25
  34. package/src/bots/sniper/01_Spellshot.ts +151 -98
  35. package/src/bots/sniper/02_Spelltracer.ts +173 -128
  36. package/src/bots/sniper/03_Spellseeker.ts +177 -152
  37. package/src/bots/standalone/Critter.ts +46 -52
  38. package/src/bots/standalone/Doombringer.ts +36 -40
  39. package/src/bots/standalone/Hogger.ts +120 -89
  40. package/src/bots/standalone/Rookie.ts +21 -23
  41. package/src/bots/standalone/TargetDummy.ts +5 -6
  42. package/src/bots/test/cheater.ts +91 -85
  43. package/src/bots/test/crasher.ts +26 -28
  44. package/src/engine/bundle-fight.ts +242 -0
  45. package/src/engine/hooks-runtime.ts +58 -18
  46. package/src/engine/manual-match.ts +33 -25
  47. package/src/engine/missile-templates.ts +25 -43
  48. package/src/engine/optimizer.ts +7 -7
  49. package/src/engine/params-runtime.ts +3 -3
  50. package/src/engine/physics.ts +33 -23
  51. package/src/engine/sandbox-browser.ts +8 -7
  52. package/src/engine/sandbox-compile.ts +1 -1
  53. package/src/engine/sandbox-harness.ts +299 -367
  54. package/src/engine/sandbox.ts +3 -3
  55. package/src/engine/simulation.ts +291 -76
  56. package/src/engine/spells.ts +9 -12
  57. package/src/engine-version.ts +1 -1
  58. package/src/hooks/action-builders.ts +76 -21
  59. package/src/hooks/index.ts +17 -9
  60. package/src/hooks/state-hooks.ts +61 -25
  61. package/src/hooks/threat-analysis.ts +44 -20
  62. package/src/hooks/types.ts +62 -5
  63. package/src/index.ts +2 -0
  64. package/src/rules.ts +209 -63
  65. package/src/stats.ts +2 -2
  66. package/src/testing.ts +6 -30
  67. package/src/trace.ts +63 -3
  68. package/src/types.ts +127 -14
  69. package/src/utils/angles.ts +1 -0
  70. package/src/utils/combat.ts +98 -6
  71. package/src/utils/spatial.ts +3 -3
  72. package/dist/chunk-B7YYYBEC.js +0 -9140
  73. package/dist/chunk-B7YYYBEC.js.map +0 -1
  74. package/src/hooks/bot-wrapper.ts +0 -84
package/src/rules.ts CHANGED
@@ -3,49 +3,120 @@
3
3
  *
4
4
  * This is the single source of truth for all game constants.
5
5
  * All game logic imports from here. Read this to understand the game.
6
+ *
7
+ * SWEEPABLE CONSTANTS — the balance-search ruleset:
8
+ * Every combat-balance constant lives on the mutable `RULES` object. The engine
9
+ * and bots read `RULES.X` so the balance-search optimizer can override any of
10
+ * them at runtime via applyRulesetOverrides() — object property reads are live
11
+ * across every module and survive bundling (unlike a reassigned `let`, which
12
+ * esbuild/Vite snapshot at the import site). Structural constants (arena size,
13
+ * tick rate, hitbox radius, missile floors) stay plain `const`.
14
+ *
15
+ * The UPPER_CASE named exports below (WIZARD_HEALTH, SHIELD_CAST_TIME, …) are
16
+ * default SNAPSHOTS for external/UI/MCP consumers that only need the factory
17
+ * value. They do NOT track overrides — anything that must respond to a sweep
18
+ * reads `RULES.X`.
6
19
  */
7
20
 
8
21
  import {MissileConfig} from './types.js';
9
22
 
10
- // === TIMING ===
23
+ // === TIMING (structural) ===
11
24
  export const TICKS_PER_SECOND = 100;
12
25
  export const TICK_DURATION_MS = 10;
13
26
 
14
- // === WIZARD ===
15
- export const WIZARD_HEALTH = 60;
16
- export const WIZARD_RADIUS = 5; // units
17
- export const MOVEMENT_SPEED = 1; // units per tick (100 units/sec)
18
- export const CASTING_MOVEMENT_MULT = 0.5; // 50% speed while casting
27
+ /* eslint-disable @typescript-eslint/naming-convention -- keys mirror constant names */
28
+ /**
29
+ * The mutable ruleset: the single source of truth for every sweepable combat
30
+ * constant. Engine + bots read these via `RULES.X`. Override with
31
+ * applyRulesetOverrides(); restore with resetRuleset().
32
+ */
33
+ export const RULES = {
34
+ // Wizard
35
+ WIZARD_HEALTH: 60,
36
+ MOVEMENT_SPEED: 1.5, // units per tick (150 units/sec)
37
+ CASTING_MOVEMENT_MULT: 0.33, // 33% speed while casting
38
+
39
+ // GCD
40
+ GCD_DURATION: 100, // ticks (1 second)
41
+
42
+ // Shield
43
+ SHIELD_CAST_TIME: 20, // ticks (0.2s)
44
+ SHIELD_MAX_BLOCK: 0.9, // 90%
45
+ SHIELD_DECAY_PER_SECOND: 0.2, // loses 20% per second
46
+ SHIELD_MIN_BLOCK: 0.3, // 30% minimum
47
+
48
+ // Blink
49
+ BLINK_CAST_TIME: 10, // ticks (0.1s)
50
+ BLINK_RANGE: 300, // units
51
+ BLINK_MAX_COOLDOWN: 2000, // ticks (20 seconds) — full-range blink
52
+ BLINK_MIN_COOLDOWN: 100, // ticks (1 second) — micro-blink floor
53
+
54
+ // Knockback
55
+ KNOCKBACK_DAMAGE_THRESHOLD: 10, // damage above this causes knockback
56
+ KNOCKBACK_SPEED_PER_DAMAGE: 2, // knockback speed per damage over threshold
57
+ KNOCKBACK_DELAY: 5, // ticks before the impulse applies (explosion expand)
58
+ KNOCKBACK_DECAY: 0.7, // per-tick knockback velocity decay
59
+
60
+ // Missile cast-time formula
61
+ MISSILE_MIN_CAST_TIME: 0.1, // seconds
62
+ MISSILE_BASE_RADIUS: 2, // base hitbox radius in units
63
+ MISSILE_DAMAGE_RADIUS_SCALE: 0.1, // additional radius per damage point
64
+ MISSILE_BASE_CAST: 0.1,
65
+ MISSILE_DAMAGE_SCALE: 0.226,
66
+ MISSILE_DAMAGE_POWER: 2 / 3, // sublinear: DPS always increases with damage
67
+ MISSILE_HOMING_COEFF: 0.12,
68
+ MISSILE_TURN_DURATION_COEFF: 0.10,
69
+ MISSILE_SPEED_DURATION_BASELINE: 1.5,
70
+ MISSILE_SPEED_DURATION_COEFF: 0.025,
71
+
72
+ // Cast warmup
73
+ WARMUP_MAX_BONUS: 0.20, // 20% cast time reduction at full warmup
74
+ WARMUP_MAX_PENALTY: 0.20, // 20% cast time increase when switching
75
+ };
76
+ /* eslint-enable @typescript-eslint/naming-convention */
77
+
78
+ // === WIZARD (default snapshots — engine/bots read RULES.X) ===
79
+ export const WIZARD_HEALTH = RULES.WIZARD_HEALTH;
80
+ export const WIZARD_RADIUS = 5; // units (structural — hitbox)
81
+ export const MOVEMENT_SPEED = RULES.MOVEMENT_SPEED;
82
+ export const CASTING_MOVEMENT_MULT = RULES.CASTING_MOVEMENT_MULT;
19
83
 
20
84
  // === GCD ===
21
- export const GCD_DURATION = 100; // ticks (1 second)
85
+ export const GCD_DURATION = RULES.GCD_DURATION;
22
86
 
23
87
  // === SHIELD ===
24
- export const SHIELD_CAST_TIME = 20; // ticks (0.2s)
25
- export const SHIELD_MAX_BLOCK = 0.9; // 90%
26
- export const SHIELD_DECAY_PER_SECOND = 0.2; // loses 20% per second
27
- export const SHIELD_MIN_BLOCK = 0.3; // 30% minimum
88
+ export const SHIELD_CAST_TIME = RULES.SHIELD_CAST_TIME;
89
+ export const SHIELD_MAX_BLOCK = RULES.SHIELD_MAX_BLOCK;
90
+ export const SHIELD_DECAY_PER_SECOND = RULES.SHIELD_DECAY_PER_SECOND;
91
+ export const SHIELD_MIN_BLOCK = RULES.SHIELD_MIN_BLOCK;
28
92
  // Note: Shield block % is applied directly to damage (no additional scaling)
29
93
 
30
94
  // === BLINK ===
31
- export const BLINK_CAST_TIME = 10; // ticks (0.1s)
32
- export const BLINK_RANGE = 300; // units
33
- export const BLINK_MAX_COOLDOWN = 2000; // ticks (20 seconds) — full-range blink
34
- export const BLINK_MIN_COOLDOWN = 100; // ticks (1 second) — micro-blink floor
95
+ export const BLINK_CAST_TIME = RULES.BLINK_CAST_TIME;
96
+ export const BLINK_RANGE = RULES.BLINK_RANGE;
97
+ export const BLINK_MAX_COOLDOWN = RULES.BLINK_MAX_COOLDOWN; // full-range blink
98
+ export const BLINK_MIN_COOLDOWN = RULES.BLINK_MIN_COOLDOWN; // micro-blink floor
35
99
  /** @deprecated Use BLINK_MAX_COOLDOWN */
36
- export const BLINK_COOLDOWN = BLINK_MAX_COOLDOWN;
37
-
38
- // === ARENA ===
39
- export const ARENA_WIDTH = 800;
40
- export const ARENA_HEIGHT = 800;
41
- export const ARENA_SIZE = ARENA_WIDTH; // Arena is square; alias for bot convenience
100
+ export const BLINK_COOLDOWN = RULES.BLINK_MAX_COOLDOWN;
101
+
102
+ // === KNOCKBACK ===
103
+ export const KNOCKBACK_DAMAGE_THRESHOLD = RULES.KNOCKBACK_DAMAGE_THRESHOLD;
104
+ export const KNOCKBACK_SPEED_PER_DAMAGE = RULES.KNOCKBACK_SPEED_PER_DAMAGE;
105
+ export const KNOCKBACK_DELAY = RULES.KNOCKBACK_DELAY;
106
+ export const KNOCKBACK_DECAY = RULES.KNOCKBACK_DECAY;
107
+
108
+ // === ARENA (structural) ===
109
+ export const ARENA_SIZE = 860;
110
+ export const LAVA_BORDER_WIDTH = 30;
111
+ export const ARENA_MIN = LAVA_BORDER_WIDTH; // playfield starts here (30)
112
+ export const ARENA_MAX = ARENA_SIZE - LAVA_BORDER_WIDTH; // playfield ends here (830)
42
113
  export const SPAWN_DISTANCE = 600;
43
114
 
44
- // === MISSILE CONSTRAINTS ===
115
+ // === MISSILE CONSTRAINTS (structural) ===
45
116
  export const MISSILE_MIN_DAMAGE = 1;
46
117
  export const MISSILE_MIN_SPEED = 1.5; // must be faster than player
47
118
  export const MISSILE_MIN_DURATION = 10; // ticks (0.1 seconds)
48
- export const MISSILE_MIN_CAST_TIME = 0.1; // seconds
119
+ export const MISSILE_MIN_CAST_TIME = RULES.MISSILE_MIN_CAST_TIME;
49
120
 
50
121
  // === MISSILE HITBOX ===
51
122
  // Missile hitbox scales with damage (design doc line 1122)
@@ -54,15 +125,15 @@ export const MISSILE_MIN_CAST_TIME = 0.1; // seconds
54
125
  // - 6-15 damage: Small-Medium
55
126
  // - 16-30 damage: Large
56
127
  // - 31+ damage: Huge
57
- export const MISSILE_BASE_RADIUS = 2; // base radius in units
58
- export const MISSILE_DAMAGE_RADIUS_SCALE = 0.1; // additional radius per damage point
128
+ export const MISSILE_BASE_RADIUS = RULES.MISSILE_BASE_RADIUS;
129
+ export const MISSILE_DAMAGE_RADIUS_SCALE = RULES.MISSILE_DAMAGE_RADIUS_SCALE;
59
130
 
60
131
  /**
61
132
  * Calculate missile hitbox radius based on damage.
62
133
  */
63
134
  export function calculateMissileRadius(damage: number): number
64
135
  {
65
- return MISSILE_BASE_RADIUS + damage * MISSILE_DAMAGE_RADIUS_SCALE;
136
+ return (RULES.MISSILE_BASE_RADIUS + damage * RULES.MISSILE_DAMAGE_RADIUS_SCALE) * 3;
66
137
  }
67
138
 
68
139
  // === MISSILE CAST TIME FORMULA ===
@@ -77,27 +148,21 @@ export function calculateMissileRadius(damage: number): number
77
148
  // d=20: ~1.77s cast → DPS 7.23, TTK 7.3s (medium, borderline shieldable)
78
149
  // d=30: ~2.28s cast → DPS 9.14, TTK 5.6s (slow, shieldable)
79
150
  // d=60: ~3.57s cast → DPS 13.1, TTK 3.6s (very slow, easily shielded)
80
- export const MISSILE_BASE_CAST = 0.1;
81
- export const MISSILE_DAMAGE_SCALE = 0.226;
82
- export const MISSILE_DAMAGE_POWER = 2 / 3; // sublinear: DPS always increases with damage
83
- export const MISSILE_HOMING_COEFF = 0.12;
84
- export const MISSILE_TURN_DURATION_COEFF = 0.10;
85
- export const MISSILE_SPEED_DURATION_BASELINE = 1.5;
86
- export const MISSILE_SPEED_DURATION_COEFF = 0.025;
151
+ export const MISSILE_BASE_CAST = RULES.MISSILE_BASE_CAST;
152
+ export const MISSILE_DAMAGE_SCALE = RULES.MISSILE_DAMAGE_SCALE;
153
+ export const MISSILE_DAMAGE_POWER = RULES.MISSILE_DAMAGE_POWER; // sublinear: DPS always increases with damage
154
+ export const MISSILE_HOMING_COEFF = RULES.MISSILE_HOMING_COEFF;
155
+ export const MISSILE_TURN_DURATION_COEFF = RULES.MISSILE_TURN_DURATION_COEFF;
156
+ export const MISSILE_SPEED_DURATION_BASELINE = RULES.MISSILE_SPEED_DURATION_BASELINE;
157
+ export const MISSILE_SPEED_DURATION_COEFF = RULES.MISSILE_SPEED_DURATION_COEFF;
87
158
 
88
159
  /**
89
160
  * Maps turnRate to effective cost for the cast time formula.
90
- * - Positive: linear (unchanged behavior)
91
- * - Negative: diminishing returns via -|t|/(1+|t|), saturating at -1.
92
- * turnRate -0.5 → -0.33, -1 → -0.5, -5 → -0.83, -10 → -0.91
93
- * Big cast speed gains from 0 to -1, worthwhile to -5, negligible after.
94
- * No hard floor — the curve naturally caps the benefit.
161
+ * Higher turn rate = more expensive cast. Negative values clamped to 0.
95
162
  */
96
163
  export function effectiveTurnRateCost(turnRate: number): number
97
164
  {
98
- if (turnRate >= 0) return turnRate;
99
- const abs = Math.abs(turnRate);
100
- return -(abs / (1 + abs));
165
+ return Math.max(0, turnRate);
101
166
  }
102
167
 
103
168
  /**
@@ -109,7 +174,7 @@ export function validateMissileConfig(config: MissileConfig): MissileConfig
109
174
  return {
110
175
  damage: Math.max(MISSILE_MIN_DAMAGE, Number.isFinite(config.damage) ? config.damage : MISSILE_MIN_DAMAGE),
111
176
  speed: Math.max(MISSILE_MIN_SPEED, Number.isFinite(config.speed) ? config.speed : MISSILE_MIN_SPEED),
112
- turnRate: Number.isFinite(config.turnRate) ? config.turnRate : 0, // no floor — diminishing returns via effectiveTurnRateCost
177
+ turnRate: Math.max(0, Number.isFinite(config.turnRate) ? config.turnRate : 0),
113
178
  duration: Math.max(MISSILE_MIN_DURATION, Number.isFinite(config.duration) ? Math.floor(config.duration) : MISSILE_MIN_DURATION),
114
179
  };
115
180
  }
@@ -124,7 +189,7 @@ export function validateMissileConfig(config: MissileConfig): MissileConfig
124
189
  * + 0.10 × (effectiveTurnRateCost(turnRate) × durationSeconds)
125
190
  * + 0.025 × (speed × durationSeconds - 1.5)
126
191
  *
127
- * effectiveTurnRateCost: linear for positive, -ln(1+|t|) for negative (diminishing returns).
192
+ * turnRate is clamped to >= 0. Cost is linear.
128
193
  *
129
194
  * If lastMissileConfig is a MissileConfig, applies warmup multiplier:
130
195
  * - Similar to previous: up to 20% faster
@@ -136,13 +201,13 @@ export function calculateMissileCastTime(config: MissileConfig, lastMissileConfi
136
201
  const durationSeconds = duration / TICKS_PER_SECOND;
137
202
  const turnCost = effectiveTurnRateCost(turnRate);
138
203
 
139
- const raw = MISSILE_BASE_CAST
140
- + MISSILE_DAMAGE_SCALE * Math.pow(damage, MISSILE_DAMAGE_POWER)
141
- + MISSILE_HOMING_COEFF * turnCost
142
- + MISSILE_TURN_DURATION_COEFF * (turnCost * durationSeconds)
143
- + MISSILE_SPEED_DURATION_COEFF * (speed * durationSeconds - MISSILE_SPEED_DURATION_BASELINE);
204
+ const raw = RULES.MISSILE_BASE_CAST
205
+ + RULES.MISSILE_DAMAGE_SCALE * Math.pow(damage, RULES.MISSILE_DAMAGE_POWER)
206
+ + RULES.MISSILE_HOMING_COEFF * turnCost
207
+ + RULES.MISSILE_TURN_DURATION_COEFF * (turnCost * durationSeconds)
208
+ + RULES.MISSILE_SPEED_DURATION_COEFF * (speed * durationSeconds - RULES.MISSILE_SPEED_DURATION_BASELINE);
144
209
 
145
- const baseCast = Math.max(MISSILE_MIN_CAST_TIME, raw);
210
+ const baseCast = Math.max(RULES.MISSILE_MIN_CAST_TIME, raw);
146
211
 
147
212
  // Warmup is applied when lastMissileConfig is an actual MissileConfig.
148
213
  // Note: passing undefined triggers the default (null) due to JS semantics,
@@ -152,7 +217,7 @@ export function calculateMissileCastTime(config: MissileConfig, lastMissileConfi
152
217
  if (lastMissileConfig !== null)
153
218
  {
154
219
  const multiplier = calculateWarmupMultiplier(lastMissileConfig, config);
155
- return Math.max(MISSILE_MIN_CAST_TIME, baseCast * multiplier);
220
+ return Math.max(RULES.MISSILE_MIN_CAST_TIME, baseCast * multiplier);
156
221
  }
157
222
 
158
223
  return baseCast;
@@ -168,8 +233,8 @@ export function calculateMissileCastTime(config: MissileConfig, lastMissileConfi
168
233
  // Similar to previous: cast × 0.80 (20% faster)
169
234
  // First cast (no previous): cast × 0.80 (full warmup, no conflict)
170
235
  // Very different from previous: cast × 1.20 (20% slower, switching penalty)
171
- export const WARMUP_MAX_BONUS = 0.20; // 20% cast time reduction at full warmup
172
- export const WARMUP_MAX_PENALTY = 0.20; // 20% cast time increase when switching
236
+ export const WARMUP_MAX_BONUS = RULES.WARMUP_MAX_BONUS;
237
+ export const WARMUP_MAX_PENALTY = RULES.WARMUP_MAX_PENALTY;
173
238
  // Tolerances for similarity calculation (differences within tolerance are "similar"):
174
239
  // Note: damage is intentionally excluded — it doesn't define playstyle.
175
240
  // A melee bot stabbing at d=10 and d=25 is the same style, just different power.
@@ -190,9 +255,11 @@ export function calculateMissileSimilarity(prev: MissileConfig | undefined, curr
190
255
  {
191
256
  if (!prev) return 1; // No previous missile = no conflicting muscle memory = full warmup
192
257
 
258
+ // Stryker disable all: /WARMUP_TURN_TOLERANCE equivalent when tolerance=1 (dividing by 1 = multiplying by 1)
193
259
  const dist = Math.abs(prev.speed - current.speed) / WARMUP_SPEED_TOLERANCE
194
260
  + Math.abs(prev.turnRate - current.turnRate) / WARMUP_TURN_TOLERANCE
195
261
  + Math.abs(prev.duration - current.duration) / WARMUP_DURATION_TOLERANCE;
262
+ // Stryker restore all
196
263
 
197
264
  return Math.max(0, 1 - dist / 3);
198
265
  }
@@ -211,10 +278,10 @@ export function calculateWarmupMultiplier(prev: MissileConfig | undefined, curre
211
278
  // similarity=1 → bonus (faster): 1 - MAX_BONUS
212
279
  // similarity=0 → penalty (slower): 1 + MAX_PENALTY
213
280
  // Linear interpolation between penalty and bonus
214
- return (1 + WARMUP_MAX_PENALTY) - similarity * (WARMUP_MAX_BONUS + WARMUP_MAX_PENALTY);
281
+ return (1 + RULES.WARMUP_MAX_PENALTY) - similarity * (RULES.WARMUP_MAX_BONUS + RULES.WARMUP_MAX_PENALTY);
215
282
  }
216
283
 
217
- // === MATCH ===
284
+ // === MATCH (structural) ===
218
285
  export const MATCH_DURATION = 30000; // ticks (5 minutes)
219
286
 
220
287
  // ============================================================
@@ -223,22 +290,22 @@ export const MATCH_DURATION = 30000; // ticks (5 minutes)
223
290
  // ============================================================
224
291
 
225
292
  // Health & Combat
226
- export const MAX_HEALTH = WIZARD_HEALTH;
293
+ export const MAX_HEALTH = RULES.WIZARD_HEALTH;
227
294
  export const COLLISION_RADIUS = WIZARD_RADIUS;
228
295
 
229
296
  // Movement
230
- export const MOVE_SPEED = MOVEMENT_SPEED;
297
+ export const MOVE_SPEED = RULES.MOVEMENT_SPEED;
231
298
 
232
299
  // Missiles
233
- export const MISSILE_RADIUS_PER_DAMAGE = MISSILE_DAMAGE_RADIUS_SCALE;
300
+ export const MISSILE_RADIUS_PER_DAMAGE = RULES.MISSILE_DAMAGE_RADIUS_SCALE;
234
301
 
235
302
  // Shield (aliases for consistency)
236
- export const SHIELD_MAX_STRENGTH = SHIELD_MAX_BLOCK;
237
- export const SHIELD_MIN_STRENGTH = SHIELD_MIN_BLOCK;
238
- export const SHIELD_DECAY_RATE = SHIELD_DECAY_PER_SECOND;
303
+ export const SHIELD_MAX_STRENGTH = RULES.SHIELD_MAX_BLOCK;
304
+ export const SHIELD_MIN_STRENGTH = RULES.SHIELD_MIN_BLOCK;
305
+ export const SHIELD_DECAY_RATE = RULES.SHIELD_DECAY_PER_SECOND;
239
306
 
240
307
  // Blink (aliases for consistency)
241
- export const BLINK_MAX_RANGE = BLINK_RANGE;
308
+ export const BLINK_MAX_RANGE = RULES.BLINK_RANGE;
242
309
 
243
310
  /**
244
311
  * Calculate blink cooldown based on distance traveled.
@@ -246,9 +313,88 @@ export const BLINK_MAX_RANGE = BLINK_RANGE;
246
313
  */
247
314
  export function calculateBlinkCooldown(distance: number): number
248
315
  {
249
- const ratio = Math.min(1, distance / BLINK_MAX_RANGE);
250
- return Math.max(BLINK_MIN_COOLDOWN, Math.round(ratio * BLINK_MAX_COOLDOWN));
316
+ const ratio = Math.min(1, distance / RULES.BLINK_RANGE);
317
+ return Math.max(RULES.BLINK_MIN_COOLDOWN, Math.round(ratio * RULES.BLINK_MAX_COOLDOWN));
251
318
  }
252
319
 
253
320
  // Arena
254
321
  export const ARENA_WATER_BUFFER = 200; // units - missiles can fly this far past arena bounds
322
+
323
+ // ============================================================
324
+ // RULESET OVERRIDES (for the balance-search optimizer)
325
+ // ============================================================
326
+ //
327
+ // applyRulesetOverrides() mutates the RULES object in place; every engine/bot
328
+ // read of RULES.X sees the new value immediately. Each optimizer worker calls
329
+ // applyRulesetOverrides() before a batch of sims and resetRuleset() after, so a
330
+ // sweep never leaks across evaluations.
331
+
332
+ /** Factory defaults, snapshotted from the live values at module load (no drift). */
333
+ const RULES_DEFAULTS: Record<string, number> = {...RULES};
334
+
335
+ /* eslint-disable @typescript-eslint/naming-convention -- keys mirror constant names */
336
+ /**
337
+ * Search bounds for each sweepable constant. The current value is the start.
338
+ * Every key here is read by the engine/bots through `RULES.X`, so overrides
339
+ * take effect across the whole simulation.
340
+ */
341
+ export const RULESET_RANGES: Record<string, {min: number; max: number}> = {
342
+ // Wizard
343
+ WIZARD_HEALTH: {min: 30, max: 120},
344
+ MOVEMENT_SPEED: {min: 0.75, max: 3},
345
+ CASTING_MOVEMENT_MULT: {min: 0, max: 1},
346
+ // GCD
347
+ GCD_DURATION: {min: 50, max: 200},
348
+ // Shield
349
+ SHIELD_CAST_TIME: {min: 5, max: 60},
350
+ SHIELD_MAX_BLOCK: {min: 0.5, max: 0.95},
351
+ SHIELD_DECAY_PER_SECOND: {min: 0, max: 0.6},
352
+ SHIELD_MIN_BLOCK: {min: 0, max: 0.6},
353
+ // Blink
354
+ BLINK_CAST_TIME: {min: 2, max: 40},
355
+ BLINK_RANGE: {min: 150, max: 500},
356
+ BLINK_MAX_COOLDOWN: {min: 500, max: 4000},
357
+ BLINK_MIN_COOLDOWN: {min: 20, max: 400},
358
+ // Knockback (anti-melee lever)
359
+ KNOCKBACK_DAMAGE_THRESHOLD: {min: 0, max: 30},
360
+ KNOCKBACK_SPEED_PER_DAMAGE: {min: 0, max: 8},
361
+ KNOCKBACK_DELAY: {min: 0, max: 15},
362
+ KNOCKBACK_DECAY: {min: 0.3, max: 0.95},
363
+ // Missile cast-time formula (the core "cast speed" balance lever)
364
+ MISSILE_BASE_CAST: {min: 0, max: 0.5},
365
+ MISSILE_DAMAGE_SCALE: {min: 0.1, max: 0.5},
366
+ MISSILE_DAMAGE_POWER: {min: 0.4, max: 1},
367
+ MISSILE_HOMING_COEFF: {min: 0, max: 0.4},
368
+ MISSILE_TURN_DURATION_COEFF: {min: 0, max: 0.3},
369
+ MISSILE_SPEED_DURATION_BASELINE: {min: 0.5, max: 3},
370
+ MISSILE_SPEED_DURATION_COEFF: {min: 0, max: 0.1},
371
+ MISSILE_MIN_CAST_TIME: {min: 0.05, max: 0.5},
372
+ // Missile hitbox
373
+ MISSILE_BASE_RADIUS: {min: 0.5, max: 5},
374
+ MISSILE_DAMAGE_RADIUS_SCALE: {min: 0, max: 0.3},
375
+ // Warmup
376
+ WARMUP_MAX_BONUS: {min: 0, max: 0.5},
377
+ WARMUP_MAX_PENALTY: {min: 0, max: 0.5},
378
+ };
379
+ /* eslint-enable @typescript-eslint/naming-convention */
380
+
381
+ /** Merge overrides onto the RULES object and apply them globally. */
382
+ export function applyRulesetOverrides(overrides: Record<string, number>): void
383
+ {
384
+ for (const [key, value] of Object.entries(overrides))
385
+ {
386
+ if (key in RULES) (RULES as Record<string, number>)[key] = value;
387
+ }
388
+ }
389
+
390
+ /** Restore all swept constants to their factory defaults. */
391
+ export function resetRuleset(): void
392
+ {
393
+ Object.assign(RULES, RULES_DEFAULTS);
394
+ }
395
+
396
+ /** Current value of every sweepable constant (the optimizer's starting point). */
397
+ export function currentRuleset(): Record<string, number>
398
+ {
399
+ return {...RULES};
400
+ }
package/src/stats.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type {GameState} from './types.js';
9
9
  import type {SimulateResult} from './engine/simulation.js';
10
- import {TICKS_PER_SECOND, SHIELD_MAX_BLOCK, SHIELD_DECAY_PER_SECOND, SHIELD_MIN_BLOCK} from './rules.js';
10
+ import {RULES, TICKS_PER_SECOND} from './rules.js';
11
11
 
12
12
  /** Detailed statistics for one bot in a single match. */
13
13
  export interface FightStats
@@ -67,7 +67,7 @@ export interface FightStats
67
67
  function shieldBlockPct(channelDurationTicks: number): number
68
68
  {
69
69
  const channelSec = channelDurationTicks / TICKS_PER_SECOND;
70
- return Math.max(SHIELD_MIN_BLOCK, SHIELD_MAX_BLOCK - SHIELD_DECAY_PER_SECOND * channelSec);
70
+ return Math.max(RULES.SHIELD_MIN_BLOCK, RULES.SHIELD_MAX_BLOCK - RULES.SHIELD_DECAY_PER_SECOND * channelSec);
71
71
  }
72
72
 
73
73
  /**
package/src/testing.ts CHANGED
@@ -14,11 +14,9 @@
14
14
  * });
15
15
  */
16
16
 
17
- import type {BotFunction} from './hooks/types.js';
18
- import type {WizardFunction} from './types.js';
17
+ import type {WizardFunction} from './hooks/types.js';
19
18
  import type {FightResult, SimulateResult, MatchWinner, FightWinner} from './engine/simulation.js';
20
19
  import {fight, simulate, FIGHT_SPAWN_DISTANCES} from './engine/simulation.js';
21
- import {isNewStyleBot, wrapNewBot} from './hooks/bot-wrapper.js';
22
20
  import {extractStats} from './stats.js';
23
21
  import type {FightStats} from './stats.js';
24
22
 
@@ -97,34 +95,14 @@ export interface TestBotBuilder
97
95
  function resolveBuiltinBot(name: string): WizardFunction
98
96
  {
99
97
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- dynamic bot lookup
100
- const botMap = allBots as unknown as Record<string, WizardFunction | BotFunction>;
98
+ const botMap = allBots as unknown as Record<string, WizardFunction>;
101
99
  const bot = botMap[name];
102
100
  if (!bot)
103
101
  {
104
102
  const available = Object.keys(botMap).filter((k) => typeof botMap[k] === 'function').join(', ');
105
103
  throw new Error(`Unknown opponent "${name}". Available bots: ${available}`);
106
104
  }
107
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- branching on length+shape detection
108
- return typeof bot === 'function' && bot.length === 0 && isNewStyleBot(bot as BotFunction)
109
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by isNewStyleBot above
110
- ? wrapNewBot(bot as BotFunction)
111
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- legacy style bot after guard
112
- : bot as WizardFunction;
113
- }
114
-
115
- /**
116
- * Wrap a bot function for use with the simulation engine.
117
- */
118
- function wrapBot(bot: WizardFunction | BotFunction): WizardFunction
119
- {
120
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- runtime shape detection
121
- if (isNewStyleBot(bot as BotFunction))
122
- {
123
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by isNewStyleBot above
124
- return wrapNewBot(bot as BotFunction);
125
- }
126
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- legacy style bot after guard
127
- return bot as WizardFunction;
105
+ return bot;
128
106
  }
129
107
 
130
108
  /**
@@ -155,15 +133,13 @@ function wrapBot(bot: WizardFunction | BotFunction): WizardFunction
155
133
  * });
156
134
  * ```
157
135
  */
158
- export function testBot(bot: WizardFunction | BotFunction): TestBotBuilder
136
+ export function testBot(bot: WizardFunction): TestBotBuilder
159
137
  {
160
- const wrappedBot = wrapBot(bot);
161
-
162
138
  return {
163
139
  fight(opponent: string, options?: {seed?: number}): TestFightResult
164
140
  {
165
141
  const opponentBot = resolveBuiltinBot(opponent);
166
- const raw = fight(wrappedBot, opponentBot, {seed: options?.seed});
142
+ const raw = fight(bot, opponentBot, {seed: options?.seed});
167
143
 
168
144
  return {
169
145
  raw,
@@ -184,7 +160,7 @@ export function testBot(bot: WizardFunction | BotFunction): TestBotBuilder
184
160
  }): TestSimulateResult
185
161
  {
186
162
  const opponentBot = resolveBuiltinBot(opponent);
187
- const raw = simulate(wrappedBot, opponentBot, {
163
+ const raw = simulate(bot, opponentBot, {
188
164
  seed: options?.seed,
189
165
  spawnDistance: options?.spawnDistance ?? FIGHT_SPAWN_DISTANCES[2], // 600 (middle distance)
190
166
  maxTicks: options?.maxTicks,
package/src/trace.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * - User tests that want event-level analysis
12
12
  */
13
13
 
14
- import type {GameState, ProjectileState} from './types.js';
14
+ import type {GameState, ProjectileState, SimEvent} from './types.js';
15
15
  import type {SimulateResult, BotError} from './engine/simulation.js';
16
16
  import {distanceTo} from './utils/distance.js';
17
17
 
@@ -31,6 +31,11 @@ export type TraceEventType =
31
31
  | 'FIRE' // Missile launched (includes config + range)
32
32
  | 'HIT' // Damage dealt to opponent
33
33
  | 'HURT' // Damage taken from opponent
34
+ | 'DEATH' // Wizard killed (missile hit)
35
+ | 'LAVA_DEATH' // Wizard killed by lava (knockback/walk into border)
36
+ | 'SHIELD_BLOCK' // Shield absorbed damage (shows blocked + through amounts)
37
+ | 'KNOCKBACK' // Knocked back by missile impact
38
+ | 'BLINK' // Blink teleport (from → to positions)
34
39
  | 'DODGE_START' // Enemy missile enters 150u proximity
35
40
  | 'DODGE_CLOSE' // Enemy missile enters 50u proximity
36
41
  | 'MOVE' // Movement summary (every 100 ticks)
@@ -58,6 +63,7 @@ export interface TraceBotSummary
58
63
  shields: number;
59
64
  blinks: number;
60
65
  dodgeEncounters: number;
66
+ causeOfDeath: 'missile' | 'lava' | 'alive' | 'timeout';
61
67
  movement: {strafe: number; approach: number; retreat: number; still: number};
62
68
  }
63
69
 
@@ -189,6 +195,41 @@ export function extractTraceEvents(history: GameState[], errors?: BotError[]): T
189
195
  }
190
196
  }
191
197
 
198
+ // --- Simulation events (death, lava, shield block, knockback, blink) ---
199
+ if (state.events)
200
+ {
201
+ for (const ev of state.events)
202
+ {
203
+ const actor = actorFromId(ev);
204
+ if (ev.type === 'wizard-death')
205
+ {
206
+ events.push({tick, actor, type: 'DEATH', detail: `killed at (${ev.position.x.toFixed(0)},${ev.position.y.toFixed(0)})`});
207
+ }
208
+ else if (ev.type === 'wizard-lava-death')
209
+ {
210
+ events.push({tick, actor, type: 'LAVA_DEATH', detail: `walked/knocked into lava at (${ev.position.x.toFixed(0)},${ev.position.y.toFixed(0)})`});
211
+ }
212
+ else if (ev.type === 'shield-block')
213
+ {
214
+ events.push({tick, actor, type: 'SHIELD_BLOCK', detail: `blocked ${ev.damageBlocked.toFixed(1)} (${ev.damageThrough.toFixed(1)} through)`});
215
+ }
216
+ else if (ev.type === 'missile-hit')
217
+ {
218
+ const target = ev.targetId === 'wizard-1' ? 'W1' : 'W2';
219
+ const owner = ev.ownerId === 'wizard-1' ? 'W1' : 'W2';
220
+ if (ev.actualDamage < ev.damage)
221
+ {
222
+ events.push({tick, actor: target, type: 'HURT', detail: `hit by ${owner} missile: ${ev.damage} raw → ${ev.actualDamage.toFixed(1)} actual (shielded) dist=${distance.toFixed(0)}`});
223
+ }
224
+ }
225
+ else if (ev.type === 'blink')
226
+ {
227
+ const blinkDist = distanceTo(ev.from, ev.to);
228
+ events.push({tick, actor, type: 'BLINK', detail: `(${ev.from.x.toFixed(0)},${ev.from.y.toFixed(0)}) → (${ev.to.x.toFixed(0)},${ev.to.y.toFixed(0)}) ${blinkDist.toFixed(0)}u`});
229
+ }
230
+ }
231
+ }
232
+
192
233
  // --- Movement summary every 100 ticks ---
193
234
  if (tick > 0 && tick % 100 === 0)
194
235
  {
@@ -265,6 +306,10 @@ export function summarizeTrace(
265
306
  shields: myEvents.filter((e) => e.type === 'STATE' && e.detail.includes('shield')).length,
266
307
  blinks: myEvents.filter((e) => e.type === 'STATE' && e.detail.includes('blink')).length,
267
308
  dodgeEncounters: myEvents.filter((e) => e.type.startsWith('DODGE')).length,
309
+ causeOfDeath: myEvents.some((e) => e.type === 'LAVA_DEATH') ? 'lava'
310
+ : myEvents.some((e) => e.type === 'DEATH') ? 'missile'
311
+ : (final && ((actor === 'W1' ? final.health : final.enemies[0]?.health ?? 0) <= 0)) ? 'missile'
312
+ : result.winner === null ? 'timeout' : 'alive',
268
313
  movement: {
269
314
  strafe: moveEvents.filter((e) => e.detail.startsWith('STRAFE')).length,
270
315
  approach: moveEvents.filter((e) => e.detail.startsWith('APPROACH')).length,
@@ -309,12 +354,20 @@ export function formatTraceEvents(events: TraceEvent[]): string
309
354
  export function formatTraceSummary(summary: TraceSummary): string
310
355
  {
311
356
  const {w1Name, w2Name, w1, w2} = summary;
357
+ function formatBot(name: string, b: TraceBotSummary): string
358
+ {
359
+ const hitPct = b.missilesLaunched > 0 ? ((b.hits / b.missilesLaunched) * 100).toFixed(0) : 'N/A';
360
+ const death = b.causeOfDeath === 'lava' ? ' [LAVA DEATH]'
361
+ : b.causeOfDeath === 'missile' ? ' [KILLED]'
362
+ : b.causeOfDeath === 'timeout' ? '' : '';
363
+ return ` ${name}: ${b.missilesLaunched}m ${b.hits}h (${hitPct}%), ${b.damageDealt.toFixed(1)} dealt, ${b.damageReceived.toFixed(1)} taken, ${b.shields}s ${b.blinks}b${death}`;
364
+ }
312
365
  const lines = [
313
366
  ` Winner: ${summary.winner} @ T${summary.ticks}`,
314
367
  ` Final HP: ${w1Name}=${summary.w1FinalHp.toFixed(1)}, ${w2Name}=${summary.w2FinalHp.toFixed(1)}`,
315
368
  '',
316
- ` ${w1Name}: ${w1.missilesLaunched} missiles, ${w1.hits} hits (${w1.missilesLaunched > 0 ? ((w1.hits / w1.missilesLaunched) * 100).toFixed(0) : 'N/A'}%), ${w1.damageDealt.toFixed(1)} dmg dealt, ${w1.damageReceived.toFixed(1)} dmg taken, ${w1.shields} shields, ${w1.blinks} blinks`,
317
- ` ${w2Name}: ${w2.missilesLaunched} missiles, ${w2.hits} hits (${w2.missilesLaunched > 0 ? ((w2.hits / w2.missilesLaunched) * 100).toFixed(0) : 'N/A'}%), ${w2.damageDealt.toFixed(1)} dmg dealt, ${w2.damageReceived.toFixed(1)} dmg taken, ${w2.shields} shields, ${w2.blinks} blinks`,
369
+ formatBot(w1Name, w1),
370
+ formatBot(w2Name, w2),
318
371
  '',
319
372
  ` ${w1Name} movement: ${w1.movement.approach} approach, ${w1.movement.strafe} strafe, ${w1.movement.retreat} retreat, ${w1.movement.still} still | ${w1.dodgeEncounters} dodge encounters`,
320
373
  ];
@@ -413,6 +466,13 @@ function dodgeAngle(wizardVelocity: {x: number; y: number}, missileRotation: num
413
466
  return Math.acos(Math.max(-1, Math.min(1, Math.abs(dot)))) * (180 / Math.PI);
414
467
  }
415
468
 
469
+ function actorFromId(ev: SimEvent): 'W1' | 'W2'
470
+ {
471
+ if ('wizardId' in ev) return ev.wizardId === 'wizard-1' ? 'W1' : 'W2';
472
+ if ('ownerId' in ev) return ev.ownerId === 'wizard-1' ? 'W1' : 'W2';
473
+ return 'W1';
474
+ }
475
+
416
476
  function classifyMovement(state: GameState, enemy: GameState['enemies'][0], speed: number): string
417
477
  {
418
478
  if (speed < 0.1) return 'STILL';