@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
@@ -34,7 +34,7 @@ export interface SandboxOptions
34
34
  {
35
35
  /** Memory limit in MB for the isolate (default: 512). */
36
36
  memoryLimitMB?: number;
37
- /** Timeout in ms for fight/simulate calls (default: 30000). */
37
+ /** Timeout in ms for fight/simulate calls (default: 60000). */
38
38
  timeoutMs?: number;
39
39
  /** Options passed to esbuild compilation (aliases, externals). */
40
40
  compileOptions?: CompileOptions;
@@ -86,7 +86,7 @@ export class MatchSandbox
86
86
  ): Promise<MatchSandbox>
87
87
  {
88
88
  const memoryLimitMB = options?.memoryLimitMB ?? 512;
89
- const timeoutMs = options?.timeoutMs ?? 30000;
89
+ const timeoutMs = options?.timeoutMs ?? 60000;
90
90
 
91
91
  // 1. Compile the match bundle
92
92
  const bundle = await compileMatchBundle(bot1, bot2, options?.compileOptions);
@@ -133,7 +133,7 @@ export class MatchSandbox
133
133
  ): Promise<MatchSandbox>
134
134
  {
135
135
  const memoryLimitMB = options?.memoryLimitMB ?? 512;
136
- const timeoutMs = options?.timeoutMs ?? 30000;
136
+ const timeoutMs = options?.timeoutMs ?? 60000;
137
137
 
138
138
  const isolate = new ivm.Isolate({memoryLimit: memoryLimitMB});
139
139
 
@@ -2,28 +2,30 @@ import {
2
2
  GameState,
3
3
  WizardState,
4
4
  ProjectileState,
5
- WizardFunction,
6
5
  WizardActions,
7
- MissileAIFunction,
8
6
  MissileActions,
9
7
  GameConfig,
10
8
  MissileConfig,
11
9
  } from '../types.js';
10
+ import type {SimEvent} from '../types.js';
11
+ import type {WizardFunction, WizardContext, MissileContext, MissileFunction} from '../hooks/types.js';
12
+ import {withWizardContext, withMissileContext} from './hooks-runtime.js';
13
+ import {extractAction, extractMissileAction} from '../hooks/action-builders.js';
12
14
  import {
13
- ARENA_WIDTH,
14
- ARENA_HEIGHT,
15
+ RULES,
16
+ ARENA_SIZE,
17
+ ARENA_MIN,
18
+ ARENA_MAX,
15
19
  SPAWN_DISTANCE,
16
- WIZARD_HEALTH,
17
20
  TICKS_PER_SECOND,
18
21
  calculateBlinkCooldown,
19
- BLINK_RANGE,
20
22
  WIZARD_RADIUS,
21
23
  MATCH_DURATION,
22
24
  validateMissileConfig,
23
25
  calculateMissileRadius,
24
26
  ARENA_WATER_BUFFER,
25
27
  } from '../rules.js';
26
- import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveWizardCollision} from './physics.js';
28
+ import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveWizardCollision, isInLava} from './physics.js';
27
29
  import {applyDamage, updateShield, startCast, completeCast} from './spells.js';
28
30
  import {runWithHooks, resetAllHooks, clearHooks} from './hooks-runtime.js';
29
31
  import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
@@ -32,28 +34,36 @@ import {createRandom, createEntitySeed} from '../utils/random.js';
32
34
  export interface InternalWizardState extends WizardState
33
35
  {
34
36
  missileConfig?: MissileConfig;
35
- missileAI?: MissileAIFunction;
37
+ missileAI?: MissileFunction;
36
38
  blinkTarget?: {x: number; y: number};
37
39
  // Combat tracking
38
40
  damageDealt: number;
39
41
  damageTaken: number;
40
42
  lastHitTick: number;
43
+ // Knockback velocity (decays over ticks)
44
+ knockbackVx?: number;
45
+ knockbackVy?: number;
46
+ // Delayed knockback (waits for explosion to expand)
47
+ knockbackDelay?: number;
48
+ knockbackPendingVx?: number;
49
+ knockbackPendingVy?: number;
41
50
  }
42
51
 
52
+
43
53
  /**
44
54
  * Initialize a new match state.
45
55
  */
46
56
  export function createInitialState(_seed: number, spawnDist: number = SPAWN_DISTANCE): GameState
47
57
  {
48
- const center = {x: ARENA_WIDTH / 2, y: ARENA_HEIGHT / 2};
58
+ const center = {x: ARENA_SIZE / 2, y: ARENA_SIZE / 2};
49
59
  const offset = spawnDist / 2;
50
60
 
51
61
  const wizard1: WizardState = {
52
62
  id: 'wizard-1',
53
63
  position: {x: center.x - offset, y: center.y},
54
64
  rotation: 0,
55
- health: WIZARD_HEALTH,
56
- maxHealth: WIZARD_HEALTH,
65
+ health: RULES.WIZARD_HEALTH,
66
+ maxHealth: RULES.WIZARD_HEALTH,
57
67
  state: 'idle',
58
68
  blinkCooldown: 0,
59
69
  velocity: {x: 0, y: 0},
@@ -63,8 +73,8 @@ export function createInitialState(_seed: number, spawnDist: number = SPAWN_DIST
63
73
  id: 'wizard-2',
64
74
  position: {x: center.x + offset, y: center.y},
65
75
  rotation: 180,
66
- health: WIZARD_HEALTH,
67
- maxHealth: WIZARD_HEALTH,
76
+ health: RULES.WIZARD_HEALTH,
77
+ maxHealth: RULES.WIZARD_HEALTH,
68
78
  state: 'idle',
69
79
  blinkCooldown: 0,
70
80
  velocity: {x: 0, y: 0},
@@ -85,6 +95,53 @@ export function createInitialState(_seed: number, spawnDist: number = SPAWN_DIST
85
95
  damageDealt: 0,
86
96
  damageTaken: 0,
87
97
  lastHitTick: 0,
98
+ events: [],
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Build a WizardContext from the simulation's GameState + GameConfig.
104
+ * This bridges the internal simulation state to the hooks API context.
105
+ */
106
+ function buildWizardContext(state: GameState, config: GameConfig, random: () => number): WizardContext
107
+ {
108
+ return {
109
+ entityId: 'unused', // overridden by runWithHooks
110
+ tick: state.tick,
111
+ random,
112
+ position: state.position,
113
+ velocity: state.velocity,
114
+ health: state.health,
115
+ maxHealth: state.maxHealth,
116
+ state: state.state,
117
+ castingSpell: state.castingSpell,
118
+ castProgress: state.castProgress,
119
+ castDuration: state.castDuration,
120
+ channelingSpell: state.channelingSpell,
121
+ channelDuration: state.channelDuration,
122
+ gcdRemaining: state.gcdRemaining,
123
+ blinkCooldown: state.blinkCooldown,
124
+ lastMissileConfig: state.lastMissileConfig,
125
+ enemies: state.enemies.map((e) => ({
126
+ id: e.id,
127
+ position: e.position,
128
+ velocity: e.velocity,
129
+ health: e.health,
130
+ state: e.state,
131
+ castingSpell: e.castingSpell,
132
+ castProgress: e.castProgress,
133
+ castDuration: e.castDuration,
134
+ gcdRemaining: e.gcdRemaining,
135
+ channelingSpell: e.channelingSpell,
136
+ channelDuration: e.channelDuration,
137
+ })),
138
+ projectiles: state.projectiles,
139
+ myProjectiles: state.myProjectiles,
140
+ arenaWidth: config.arenaSize.width,
141
+ arenaHeight: config.arenaSize.height,
142
+ damageDealt: state.damageDealt,
143
+ damageTaken: state.damageTaken,
144
+ lastHitTick: state.lastHitTick,
88
145
  };
89
146
  }
90
147
 
@@ -98,17 +155,19 @@ export function tick(
98
155
  config: GameConfig,
99
156
  wizards: InternalWizardState[],
100
157
  projectiles: ProjectileState[],
101
- missileAIs: Map<string, MissileAIFunction>,
158
+ missileAIs: Map<string, MissileFunction>,
102
159
  matchSeed: number,
103
160
  ): {
104
161
  nextTick: number;
105
162
  wizards: InternalWizardState[];
106
163
  projectiles: ProjectileState[];
164
+ events: SimEvent[];
107
165
  errors: BotError[];
108
166
  }
109
167
  {
110
168
  const nextTick = currentTick + 1;
111
169
  const errors: BotError[] = [];
170
+ const events: SimEvent[] = [];
112
171
 
113
172
  // 1. Run wizard AIs with isolated random generators
114
173
  // Each wizard gets a fresh random generator seeded by (matchSeed, wizardId, tick)
@@ -119,11 +178,9 @@ export function tick(
119
178
  let actions1: WizardActions = {move: {x: 0, y: 0}};
120
179
  try
121
180
  {
122
- actions1 = runWithHooks(wizards[0]!.id, () => wizard1AI({
123
- state: getPlayerStateView(0, wizards, projectiles, nextTick),
124
- config,
125
- random: random1,
126
- })) ?? {move: {x: 0, y: 0}};
181
+ const stateView1 = getPlayerStateView(0, wizards, projectiles, nextTick);
182
+ const ctx1: WizardContext = buildWizardContext(stateView1, config, random1);
183
+ actions1 = runWithHooks(wizards[0]!.id, () => extractAction(withWizardContext(ctx1, wizard1AI))) ?? {move: {x: 0, y: 0}};
127
184
  }
128
185
  catch(e)
129
186
  {
@@ -133,11 +190,9 @@ export function tick(
133
190
  let actions2: WizardActions = {move: {x: 0, y: 0}};
134
191
  try
135
192
  {
136
- actions2 = runWithHooks(wizards[1]!.id, () => wizard2AI({
137
- state: getPlayerStateView(1, wizards, projectiles, nextTick),
138
- config,
139
- random: random2,
140
- })) ?? {move: {x: 0, y: 0}};
193
+ const stateView2 = getPlayerStateView(1, wizards, projectiles, nextTick);
194
+ const ctx2: WizardContext = buildWizardContext(stateView2, config, random2);
195
+ actions2 = runWithHooks(wizards[1]!.id, () => extractAction(withWizardContext(ctx2, wizard2AI))) ?? {move: {x: 0, y: 0}};
141
196
  }
142
197
  catch(e)
143
198
  {
@@ -146,11 +201,39 @@ export function tick(
146
201
 
147
202
  const actions = [actions1, actions2];
148
203
 
204
+ // 1b. Missile guide — freeze wizard, grant invulnerability, override missile steering
205
+ const guideOverrides = new Map<string, {x: number; y: number} | null>();
206
+ const guideInvincibleApplied: number[] = []; // wizard indices where we forced invincible
207
+ actions.forEach((action, i) =>
208
+ {
209
+ if (action.missileGuide)
210
+ {
211
+ const {missileId, direction} = action.missileGuide;
212
+ // Only apply if the missile actually exists and belongs to this wizard
213
+ const missile = projectiles.find((p) => p.id === missileId && p.ownerId === wizards[i]!.id);
214
+ if (missile)
215
+ {
216
+ // Freeze and protect the wizard (only set if not already invincible)
217
+ if (!wizards[i]!.invincible)
218
+ {
219
+ wizards[i]!.invincible = true;
220
+ guideInvincibleApplied.push(i);
221
+ }
222
+ action.move = {x: 0, y: 0};
223
+ // Store the direction override for the missile tick phase
224
+ guideOverrides.set(missileId, direction);
225
+ }
226
+ }
227
+ });
228
+
149
229
  // 2. Update wizards
150
230
  wizards.forEach((wizard, i) =>
151
231
  {
152
232
  const action = actions[i]!;
153
233
 
234
+ // Dead wizards do nothing during grace period
235
+ if (wizard.health <= 0) return;
236
+
154
237
  // Handle cooldowns
155
238
  if (wizard.blinkCooldown > 0)
156
239
  {
@@ -203,6 +286,7 @@ export function tick(
203
286
  };
204
287
  projectiles.push(projectile);
205
288
  missileAIs.set(id, wizard.missileAI);
289
+ events.push({type: 'missile-launch', position: {...wizard.position}, damage: validConfig.damage, speed: validConfig.speed, ownerId: wizard.id, rotation: wizard.rotation});
206
290
  // Track last missile for warmup system
207
291
  wizard.lastMissileConfig = {...validConfig};
208
292
  delete wizard.missileConfig;
@@ -210,16 +294,16 @@ export function tick(
210
294
  }
211
295
  else if (spell === 'blink' && wizard.blinkTarget)
212
296
  {
213
- // Clamp blink target to BLINK_RANGE from current position
297
+ // Clamp blink target to RULES.BLINK_RANGE from current position
214
298
  const dx = wizard.blinkTarget.x - wizard.position.x;
215
299
  const dy = wizard.blinkTarget.y - wizard.position.y;
216
300
  const distance = Math.sqrt(dx * dx + dy * dy);
217
301
 
218
302
  let targetPos = wizard.blinkTarget;
219
- if (distance > BLINK_RANGE)
303
+ if (distance > RULES.BLINK_RANGE)
220
304
  {
221
305
  // Clamp to max range in the same direction
222
- const scale = BLINK_RANGE / distance;
306
+ const scale = RULES.BLINK_RANGE / distance;
223
307
  targetPos = {
224
308
  x: wizard.position.x + dx * scale,
225
309
  y: wizard.position.y + dy * scale,
@@ -227,16 +311,25 @@ export function tick(
227
311
  }
228
312
 
229
313
  // Clamp to arena bounds and calculate actual distance traveled
230
- const oldPos = wizard.position;
314
+ const blinkFrom = {...wizard.position};
231
315
  wizard.position = clampToArena(targetPos, WIZARD_RADIUS);
232
- const actualDx = wizard.position.x - oldPos.x;
233
- const actualDy = wizard.position.y - oldPos.y;
316
+ const actualDx = wizard.position.x - blinkFrom.x;
317
+ const actualDy = wizard.position.y - blinkFrom.y;
234
318
  const actualDistance = Math.sqrt(actualDx * actualDx + actualDy * actualDy);
235
319
  wizard.blinkCooldown = calculateBlinkCooldown(actualDistance);
236
320
  delete wizard.blinkTarget;
321
+
322
+ events.push({type: 'blink', wizardId: wizard.id, from: blinkFrom, to: {...wizard.position}});
237
323
  }
238
324
 
325
+ const completedSpell = spell;
239
326
  completeCast(wizard);
327
+
328
+ // Emit shield-start when wizard transitions to channeling
329
+ if (completedSpell === 'shield')
330
+ {
331
+ events.push({type: 'shield-start', wizardId: wizard.id, position: {...wizard.position}});
332
+ }
240
333
  }
241
334
  }
242
335
 
@@ -244,6 +337,44 @@ export function tick(
244
337
  const oldPos = wizard.position;
245
338
  const move = action.move ?? {x: 0, y: 0};
246
339
  wizard.position = moveWizard(wizard, move, 1);
340
+
341
+ // Process delayed knockback (apply impulse after delay expires)
342
+ if (wizard.knockbackDelay !== undefined && wizard.knockbackDelay > 0)
343
+ {
344
+ wizard.knockbackDelay--;
345
+ if (wizard.knockbackDelay <= 0)
346
+ {
347
+ wizard.knockbackVx = (wizard.knockbackVx ?? 0) + (wizard.knockbackPendingVx ?? 0);
348
+ wizard.knockbackVy = (wizard.knockbackVy ?? 0) + (wizard.knockbackPendingVy ?? 0);
349
+ wizard.knockbackDelay = undefined;
350
+ wizard.knockbackPendingVx = undefined;
351
+ wizard.knockbackPendingVy = undefined;
352
+ }
353
+ }
354
+
355
+ // Apply knockback velocity (decays each tick)
356
+ // Clamped to full arena (0-860), NOT playfield — can push into lava zones
357
+ if (wizard.knockbackVx || wizard.knockbackVy)
358
+ {
359
+ const kvx = wizard.knockbackVx ?? 0;
360
+ const kvy = wizard.knockbackVy ?? 0;
361
+ wizard.position = {
362
+ x: Math.max(0, Math.min(ARENA_SIZE, wizard.position.x + kvx)),
363
+ y: Math.max(0, Math.min(ARENA_SIZE, wizard.position.y + kvy)),
364
+ };
365
+ wizard.knockbackVx = kvx * RULES.KNOCKBACK_DECAY;
366
+ wizard.knockbackVy = kvy * RULES.KNOCKBACK_DECAY;
367
+ if (Math.abs(wizard.knockbackVx) < 0.1) wizard.knockbackVx = 0;
368
+ if (Math.abs(wizard.knockbackVy) < 0.1) wizard.knockbackVy = 0;
369
+ }
370
+
371
+ // Lava border: instant death if wizard is pushed or walks into lava
372
+ if (wizard.health > 0 && !wizard.invincible && isInLava(wizard.position, WIZARD_RADIUS))
373
+ {
374
+ wizard.health = 0;
375
+ events.push({type: 'wizard-lava-death', wizardId: wizard.id, position: {...wizard.position}});
376
+ }
377
+
247
378
  wizard.velocity = {
248
379
  x: wizard.position.x - oldPos.x,
249
380
  y: wizard.position.y - oldPos.y,
@@ -255,6 +386,7 @@ export function tick(
255
386
  updateShield(wizard, 1);
256
387
  if (action.cancel)
257
388
  {
389
+ events.push({type: 'cast-cancel', wizardId: wizard.id, position: {...wizard.position}, spell: 'shield'});
258
390
  wizard.state = 'idle';
259
391
  delete wizard.channelingSpell;
260
392
  delete wizard.channelDuration;
@@ -270,8 +402,9 @@ export function tick(
270
402
  }
271
403
  else
272
404
  {
273
- startCast(wizard, action.startCast.spell, action.startCast.spell === 'missile' ? action.startCast.config : undefined);
274
- if (action.startCast.spell === 'missile')
405
+ const castingSpell = action.startCast.spell;
406
+ startCast(wizard, castingSpell, castingSpell === 'missile' ? action.startCast.config : undefined);
407
+ if (castingSpell === 'missile')
275
408
  {
276
409
  wizard.missileConfig = action.startCast.config;
277
410
  wizard.missileAI = action.startCast.missileAI;
@@ -280,16 +413,19 @@ export function tick(
280
413
  wizard.rotation = action.startCast.direction;
281
414
  }
282
415
  }
283
- else if (action.startCast.spell === 'blink')
416
+ else if (castingSpell === 'blink')
284
417
  {
285
418
  wizard.blinkTarget = action.startCast.target;
286
419
  }
420
+
421
+ events.push({type: 'cast-start', wizardId: wizard.id, position: {...wizard.position}, spell: castingSpell});
287
422
  }
288
423
  }
289
424
 
290
425
  // Handle cancel
291
426
  if (wizard.state === 'casting' && action.cancel)
292
427
  {
428
+ events.push({type: 'cast-cancel', wizardId: wizard.id, position: {...wizard.position}, spell: wizard.castingSpell ?? 'missile'});
293
429
  wizard.state = 'idle';
294
430
  delete wizard.castingSpell;
295
431
  delete wizard.castProgress;
@@ -307,38 +443,70 @@ export function tick(
307
443
  const remainingProjectiles: ProjectileState[] = [];
308
444
  projectiles.forEach((projectile) =>
309
445
  {
310
- const ai = missileAIs.get(projectile.id);
311
- if (ai)
312
- {
313
- const ownerIndex = wizards.findIndex((w) => w.id === projectile.ownerId);
314
- // Each missile gets its own isolated random generator
315
- const missileRandom = createRandom(createEntitySeed(matchSeed, projectile.id, nextTick));
446
+ // Check for missile guide override — skip normal AI and use player direction
447
+ const guideDir = guideOverrides.get(projectile.id);
448
+ const isGuided = guideOverrides.has(projectile.id);
449
+
450
+ let targetAngle: number | null = null;
316
451
 
317
- // Wrap missile AI in try-catch - if it throws, missile continues straight (Lesson #21)
318
- let missileActions: MissileActions = {};
319
- try
452
+ if (isGuided)
453
+ {
454
+ // Guided missile: convert direction to target angle, or fly straight
455
+ if (guideDir && (guideDir.x !== 0 || guideDir.y !== 0))
320
456
  {
321
- missileActions = runWithHooks(projectile.id, () => ai({
322
- missileState: projectileView(projectile),
323
- worldState: getPlayerStateView(ownerIndex, wizards, projectiles, nextTick),
324
- random: missileRandom,
325
- })) ?? {};
457
+ targetAngle = (Math.atan2(guideDir.y, guideDir.x) * 180) / Math.PI;
326
458
  }
327
- catch(e)
459
+ // else: direction is null or zero → fly straight (targetAngle stays null)
460
+ }
461
+ else
462
+ {
463
+ const ai = missileAIs.get(projectile.id);
464
+ if (ai)
328
465
  {
329
- errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
330
- }
466
+ const ownerIndex = wizards.findIndex((w) => w.id === projectile.ownerId);
467
+ // Each missile gets its own isolated random generator
468
+ const missileRandom = createRandom(createEntitySeed(matchSeed, projectile.id, nextTick));
469
+
470
+ // Build missile context for hooks-style missile AIs
471
+ const pView = projectileView(projectile);
472
+ const missileCtx: MissileContext = {
473
+ position: pView.position,
474
+ rotation: pView.rotation,
475
+ speed: pView.speed,
476
+ turnRate: pView.turnRate,
477
+ damage: pView.damage,
478
+ remainingTicks: pView.remainingTicks,
479
+ ownerId: pView.ownerId,
480
+ worldState: getPlayerStateView(ownerIndex, wizards, projectiles, nextTick),
481
+ random: missileRandom,
482
+ };
331
483
 
332
- if (missileActions.turnToward)
333
- {
334
- const targetAngle = angleTo(projectile.position, missileActions.turnToward);
335
- const diff = angleDiff(projectile.rotation, targetAngle);
336
- // Negative turnRate = anti-homing (turns away from target)
337
- const absTurnRate = Math.abs(projectile.turnRate);
338
- const effectiveDiff = projectile.turnRate >= 0 ? diff : -diff;
339
- const turn = Math.max(-absTurnRate, Math.min(absTurnRate, effectiveDiff));
340
- projectile.rotation = normalizeAngle(projectile.rotation + turn);
484
+ // Wrap missile AI in try-catch - if it throws, missile continues straight (Lesson #21)
485
+ let missileActions: MissileActions = {};
486
+ try
487
+ {
488
+ missileActions = runWithHooks(projectile.id, () =>
489
+ extractMissileAction(withMissileContext(missileCtx, ai)),
490
+ ) ?? {};
491
+ }
492
+ catch(e)
493
+ {
494
+ errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
495
+ }
496
+
497
+ targetAngle = missileActions.turnToward
498
+ ? angleTo(projectile.position, missileActions.turnToward)
499
+ : missileActions.turnToAngle !== undefined
500
+ ? missileActions.turnToAngle
501
+ : null;
341
502
  }
503
+ } // end else (not guided)
504
+
505
+ if (targetAngle !== null)
506
+ {
507
+ const diff = angleDiff(projectile.rotation, targetAngle);
508
+ const turn = Math.max(-projectile.turnRate, Math.min(projectile.turnRate, diff));
509
+ projectile.rotation = normalizeAngle(projectile.rotation + turn);
342
510
  }
343
511
 
344
512
  const oldPos = projectile.position;
@@ -350,7 +518,7 @@ export function tick(
350
518
  const missileRadius = calculateMissileRadius(projectile.damage);
351
519
  wizards.forEach((wizard) =>
352
520
  {
353
- if (wizard.id !== projectile.ownerId && !hit)
521
+ if (wizard.id !== projectile.ownerId && !hit && wizard.health > 0)
354
522
  {
355
523
  if (sweptCircleCollision(oldPos, projectile.position, missileRadius, wizard.position, WIZARD_RADIUS))
356
524
  {
@@ -367,6 +535,35 @@ export function tick(
367
535
  {
368
536
  owner.damageDealt += actualDamage;
369
537
  }
538
+
539
+ events.push({type: 'missile-hit', position: {...projectile.position}, damage: projectile.damage, actualDamage, speed: projectile.speed, ownerId: projectile.ownerId, targetId: wizard.id});
540
+
541
+ // Knockback: delayed impulse (waits for explosion to expand first)
542
+ const knockbackSpeed = Math.max(0, (projectile.damage - RULES.KNOCKBACK_DAMAGE_THRESHOLD) * RULES.KNOCKBACK_SPEED_PER_DAMAGE);
543
+ if (knockbackSpeed > 0)
544
+ {
545
+ const kdx = wizard.position.x - projectile.position.x;
546
+ const kdy = wizard.position.y - projectile.position.y;
547
+ const kdist = Math.sqrt(kdx * kdx + kdy * kdy);
548
+ if (kdist > 0)
549
+ {
550
+ wizard.knockbackDelay = RULES.KNOCKBACK_DELAY;
551
+ wizard.knockbackPendingVx = (kdx / kdist) * knockbackSpeed;
552
+ wizard.knockbackPendingVy = (kdy / kdist) * knockbackSpeed;
553
+ }
554
+ }
555
+
556
+ // Shield block event (actual damage < projectile damage means shield absorbed some)
557
+ if (actualDamage < projectile.damage && wizard.state === 'channeling')
558
+ {
559
+ events.push({type: 'shield-block', wizardId: wizard.id, position: {...wizard.position}, damageBlocked: projectile.damage - actualDamage, damageThrough: actualDamage});
560
+ }
561
+
562
+ // Emit wizard-death if health reached 0
563
+ if (wizard.health <= 0)
564
+ {
565
+ events.push({type: 'wizard-death', wizardId: wizard.id, position: {...wizard.position}});
566
+ }
370
567
  }
371
568
  }
372
569
  });
@@ -374,9 +571,9 @@ export function tick(
374
571
  // Check if projectile is beyond the water buffer
375
572
  const isOutOfBounds =
376
573
  projectile.position.x < -ARENA_WATER_BUFFER ||
377
- projectile.position.x > ARENA_WIDTH + ARENA_WATER_BUFFER ||
574
+ projectile.position.x > ARENA_SIZE + ARENA_WATER_BUFFER ||
378
575
  projectile.position.y < -ARENA_WATER_BUFFER ||
379
- projectile.position.y > ARENA_HEIGHT + ARENA_WATER_BUFFER;
576
+ projectile.position.y > ARENA_SIZE + ARENA_WATER_BUFFER;
380
577
 
381
578
  if (!hit && projectile.remainingTicks > 0 && !isOutOfBounds)
382
579
  {
@@ -384,15 +581,25 @@ export function tick(
384
581
  }
385
582
  else
386
583
  {
584
+ // Emit missile removal events (hit event already emitted above)
585
+ if (isOutOfBounds) events.push({type: 'missile-oob', position: {...projectile.position}});
586
+ else if (projectile.remainingTicks <= 0 && !hit) events.push({type: 'missile-expired', position: {...projectile.position}});
387
587
  missileAIs.delete(projectile.id);
388
588
  clearHooks(projectile.id);
389
589
  }
390
590
  });
391
591
 
592
+ // 4. Clear guide-applied invincibility so it doesn't persist beyond this tick
593
+ for (const idx of guideInvincibleApplied)
594
+ {
595
+ wizards[idx]!.invincible = false;
596
+ }
597
+
392
598
  return {
393
599
  nextTick,
394
600
  wizards,
395
601
  projectiles: remainingProjectiles,
602
+ events,
396
603
  errors,
397
604
  };
398
605
  }
@@ -443,7 +650,7 @@ function cloneProjectile(projectile: ProjectileState): ProjectileState
443
650
  * Returns a deep clone to prevent mutation of history entries.
444
651
  * Used for history recording where independent snapshots are needed.
445
652
  */
446
- export function getPlayerState(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
653
+ export function getPlayerState(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number, events: SimEvent[] = []): GameState
447
654
  {
448
655
  const me = wizards[playerIndex]!;
449
656
  const enemies = wizards.filter((_, i) => i !== playerIndex).map(cloneWizard);
@@ -473,6 +680,7 @@ export function getPlayerState(playerIndex: number, wizards: InternalWizardState
473
680
  damageDealt: me.damageDealt,
474
681
  damageTaken: me.damageTaken,
475
682
  lastHitTick: me.lastHitTick,
683
+ events,
476
684
  };
477
685
  }
478
686
 
@@ -561,6 +769,7 @@ function getPlayerStateView(playerIndex: number, wizards: InternalWizardState[],
561
769
  damageDealt: me.damageDealt,
562
770
  damageTaken: me.damageTaken,
563
771
  lastHitTick: me.lastHitTick,
772
+ events: [], // AI views don't need events
564
773
  };
565
774
  }
566
775
 
@@ -712,7 +921,6 @@ export function simulate(
712
921
  } = {},
713
922
  ): SimulateResult
714
923
  {
715
- // Validate AI functions (Lesson #1)
716
924
  if (typeof wizard1AI !== 'function')
717
925
  {
718
926
  throw new Error('wizard1AI must be a function');
@@ -735,11 +943,11 @@ export function simulate(
735
943
 
736
944
  // Sanitize spawnDistance - must be positive and fit in arena
737
945
  const spawnDistance = Number.isFinite(rawSpawnDistance) && rawSpawnDistance! > 0
738
- ? Math.min(rawSpawnDistance!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100) // Leave room for wizards
946
+ ? Math.min(rawSpawnDistance!, (ARENA_MAX - ARENA_MIN) - 100) // Leave room for wizards within playfield
739
947
  : SPAWN_DISTANCE;
740
948
 
741
949
  const config: GameConfig = Object.freeze({
742
- arenaSize: Object.freeze({width: ARENA_WIDTH, height: ARENA_HEIGHT}),
950
+ arenaSize: Object.freeze({width: ARENA_SIZE, height: ARENA_SIZE}),
743
951
  tickRate: TICKS_PER_SECOND,
744
952
  maxTicks,
745
953
  });
@@ -752,10 +960,10 @@ export function simulate(
752
960
  let wizards: InternalWizardState[] = [
753
961
  {
754
962
  id: 'wizard-1',
755
- position: {x: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
963
+ position: {x: ARENA_SIZE / 2 - spawnDistance / 2, y: ARENA_SIZE / 2},
756
964
  rotation: 0,
757
- health: WIZARD_HEALTH,
758
- maxHealth: WIZARD_HEALTH,
965
+ health: RULES.WIZARD_HEALTH,
966
+ maxHealth: RULES.WIZARD_HEALTH,
759
967
  state: 'idle',
760
968
  blinkCooldown: 0,
761
969
  velocity: {x: 0, y: 0},
@@ -765,10 +973,10 @@ export function simulate(
765
973
  },
766
974
  {
767
975
  id: 'wizard-2',
768
- position: {x: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
976
+ position: {x: ARENA_SIZE / 2 + spawnDistance / 2, y: ARENA_SIZE / 2},
769
977
  rotation: 180,
770
- health: WIZARD_HEALTH,
771
- maxHealth: WIZARD_HEALTH,
978
+ health: RULES.WIZARD_HEALTH,
979
+ maxHealth: RULES.WIZARD_HEALTH,
772
980
  state: 'idle',
773
981
  blinkCooldown: 0,
774
982
  velocity: {x: 0, y: 0},
@@ -778,9 +986,11 @@ export function simulate(
778
986
  },
779
987
  ];
780
988
  let projectiles: ProjectileState[] = [];
781
- const missileAIs = new Map<string, MissileAIFunction>();
989
+ const missileAIs = new Map<string, MissileFunction>();
782
990
  const allErrors: BotError[] = [];
783
991
 
992
+ let deathTick: number | null = null;
993
+
784
994
  while (currentTick < maxTicks)
785
995
  {
786
996
  const result = tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, seed);
@@ -791,11 +1001,16 @@ export function simulate(
791
1001
 
792
1002
  if (!skipHistory)
793
1003
  {
794
- // Deep-clone snapshot for history (needed for replay)
795
- history.push(getPlayerState(0, wizards, projectiles, currentTick));
1004
+ // Deep-clone snapshot for history (needed for replay), including tick events
1005
+ history.push(getPlayerState(0, wizards, projectiles, currentTick, result.events));
796
1006
  }
797
1007
 
798
- if (wizards[0]!.health <= 0 || wizards[1]!.health <= 0)
1008
+ // Track death and add grace period (0.5s = 50 ticks at 100 tps)
1009
+ if (!deathTick && (wizards[0]!.health <= 0 || wizards[1]!.health <= 0))
1010
+ {
1011
+ deathTick = currentTick;
1012
+ }
1013
+ if (deathTick && currentTick - deathTick >= 200)
799
1014
  {
800
1015
  break;
801
1016
  }