@vibemancer/core 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +28 -28
  2. package/dist/{chunk-L7Z7OFXD.js → chunk-OL7ETV6V.js} +3 -3
  3. package/dist/chunk-OL7ETV6V.js.map +1 -0
  4. package/dist/index-browser.d.ts +1 -1
  5. package/dist/index-browser.js +1 -1
  6. package/dist/index.js +1 -1
  7. package/package.json +79 -78
  8. package/src/bots/berserker/01_Stormchaser.ts +457 -457
  9. package/src/bots/berserker/02_Stormcaller.ts +417 -417
  10. package/src/bots/berserker/03_Stormforger.ts +481 -481
  11. package/src/bots/caster/01_Flamecaller.ts +286 -286
  12. package/src/bots/caster/02_Pyromancer.ts +350 -350
  13. package/src/bots/caster/03_Infernalist.ts +492 -492
  14. package/src/bots/defensive/01_Turtle.ts +151 -151
  15. package/src/bots/defensive/02_Sentinel.ts +134 -134
  16. package/src/bots/defensive/03_Golem.ts +357 -357
  17. package/src/bots/duelist/01_Battlemage.ts +433 -433
  18. package/src/bots/duelist/02_Warmage.ts +438 -438
  19. package/src/bots/duelist/03_Archmage.ts +588 -588
  20. package/src/bots/homing/01_Bonemancer.ts +67 -67
  21. package/src/bots/homing/02_Lich.ts +356 -356
  22. package/src/bots/homing/03_Archlich.ts +220 -220
  23. package/src/bots/kiter/01_Spellspinner.ts +398 -398
  24. package/src/bots/kiter/02_Spellweaver.ts +378 -378
  25. package/src/bots/kiter/03_Spellbinder.ts +448 -448
  26. package/src/bots/melee/01_Shadowblade.ts +270 -270
  27. package/src/bots/melee/02_Nightblade.ts +437 -437
  28. package/src/bots/melee/03_Voidblade.ts +582 -582
  29. package/src/bots/sniper/01_Spellshot.ts +385 -385
  30. package/src/bots/sniper/02_Spelltracer.ts +441 -441
  31. package/src/bots/sniper/03_Spellseeker.ts +546 -546
  32. package/src/bots/standalone/Critter.ts +89 -89
  33. package/src/bots/standalone/Doombringer.ts +91 -91
  34. package/src/bots/standalone/Hogger.ts +228 -228
  35. package/src/bots/standalone/Rookie.ts +50 -50
  36. package/src/bots/standalone/TargetDummy.ts +21 -21
  37. package/src/bots/test/cheater.ts +405 -405
  38. package/src/bots/test/crasher.ts +81 -81
  39. package/src/engine/hooks-runtime.ts +394 -394
  40. package/src/engine/manual-match.ts +289 -289
  41. package/src/engine/missile-templates.ts +155 -155
  42. package/src/engine/physics.ts +143 -143
  43. package/src/engine/simulation.ts +828 -828
  44. package/src/engine/spells.ts +128 -128
  45. package/src/engine-version.ts +1 -1
  46. package/src/index.ts +23 -23
  47. package/src/rules.ts +254 -254
  48. package/src/types.ts +193 -193
  49. package/src/utils/index.ts +6 -6
  50. package/dist/chunk-L7Z7OFXD.js.map +0 -1
@@ -1,828 +1,828 @@
1
- import {
2
- GameState,
3
- WizardState,
4
- ProjectileState,
5
- WizardFunction,
6
- WizardActions,
7
- MissileAIFunction,
8
- MissileActions,
9
- GameConfig,
10
- MissileConfig,
11
- } from '../types.js';
12
- import {
13
- ARENA_WIDTH,
14
- ARENA_HEIGHT,
15
- SPAWN_DISTANCE,
16
- WIZARD_HEALTH,
17
- TICKS_PER_SECOND,
18
- calculateBlinkCooldown,
19
- BLINK_RANGE,
20
- WIZARD_RADIUS,
21
- MATCH_DURATION,
22
- validateMissileConfig,
23
- calculateMissileRadius,
24
- ARENA_WATER_BUFFER,
25
- } from '../rules.js';
26
- import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveWizardCollision} from './physics.js';
27
- import {applyDamage, updateShield, startCast, completeCast} from './spells.js';
28
- import {runWithHooks, resetAllHooks, clearHooks} from './hooks-runtime.js';
29
- import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
30
- import {createRandom, createEntitySeed} from '../utils/random.js';
31
-
32
- export interface InternalWizardState extends WizardState
33
- {
34
- missileConfig?: MissileConfig;
35
- missileAI?: MissileAIFunction;
36
- blinkTarget?: {x: number; y: number};
37
- // Combat tracking
38
- damageDealt: number;
39
- damageTaken: number;
40
- lastHitTick: number;
41
- }
42
-
43
- /**
44
- * Initialize a new match state.
45
- */
46
- export function createInitialState(_seed: number, spawnDist: number = SPAWN_DISTANCE): GameState
47
- {
48
- const center = {x: ARENA_WIDTH / 2, y: ARENA_HEIGHT / 2};
49
- const offset = spawnDist / 2;
50
-
51
- const wizard1: WizardState = {
52
- id: 'wizard-1',
53
- position: {x: center.x - offset, y: center.y},
54
- rotation: 0,
55
- health: WIZARD_HEALTH,
56
- maxHealth: WIZARD_HEALTH,
57
- state: 'idle',
58
- blinkCooldown: 0,
59
- velocity: {x: 0, y: 0},
60
- };
61
-
62
- const wizard2: WizardState = {
63
- id: 'wizard-2',
64
- position: {x: center.x + offset, y: center.y},
65
- rotation: 180,
66
- health: WIZARD_HEALTH,
67
- maxHealth: WIZARD_HEALTH,
68
- state: 'idle',
69
- blinkCooldown: 0,
70
- velocity: {x: 0, y: 0},
71
- };
72
-
73
- return {
74
- tick: 0,
75
- position: wizard1.position,
76
- rotation: wizard1.rotation,
77
- health: wizard1.health,
78
- maxHealth: wizard1.maxHealth,
79
- state: wizard1.state,
80
- blinkCooldown: wizard1.blinkCooldown,
81
- velocity: wizard1.velocity,
82
- enemies: [wizard2],
83
- projectiles: [],
84
- myProjectiles: [],
85
- damageDealt: 0,
86
- damageTaken: 0,
87
- lastHitTick: 0,
88
- };
89
- }
90
-
91
- /**
92
- * Process one game tick.
93
- */
94
- export function tick(
95
- currentTick: number,
96
- wizard1AI: WizardFunction,
97
- wizard2AI: WizardFunction,
98
- config: GameConfig,
99
- wizards: InternalWizardState[],
100
- projectiles: ProjectileState[],
101
- missileAIs: Map<string, MissileAIFunction>,
102
- matchSeed: number,
103
- ): {
104
- nextTick: number;
105
- wizards: InternalWizardState[];
106
- projectiles: ProjectileState[];
107
- errors: BotError[];
108
- }
109
- {
110
- const nextTick = currentTick + 1;
111
- const errors: BotError[] = [];
112
-
113
- // 1. Run wizard AIs with isolated random generators
114
- // Each wizard gets a fresh random generator seeded by (matchSeed, wizardId, tick)
115
- const random1 = createRandom(createEntitySeed(matchSeed, wizards[0]!.id, nextTick));
116
- const random2 = createRandom(createEntitySeed(matchSeed, wizards[1]!.id, nextTick));
117
-
118
- // Wrap AI calls in try-catch - if AI throws, wizard does nothing (Lesson #21)
119
- let actions1: WizardActions = {move: {x: 0, y: 0}};
120
- try
121
- {
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}};
127
- }
128
- catch(e)
129
- {
130
- errors.push({tick: nextTick, entityId: 'wizard-1', message: e instanceof Error ? e.message : String(e)});
131
- }
132
-
133
- let actions2: WizardActions = {move: {x: 0, y: 0}};
134
- try
135
- {
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}};
141
- }
142
- catch(e)
143
- {
144
- errors.push({tick: nextTick, entityId: 'wizard-2', message: e instanceof Error ? e.message : String(e)});
145
- }
146
-
147
- const actions = [actions1, actions2];
148
-
149
- // 2. Update wizards
150
- wizards.forEach((wizard, i) =>
151
- {
152
- const action = actions[i]!;
153
-
154
- // Handle cooldowns
155
- if (wizard.blinkCooldown > 0)
156
- {
157
- wizard.blinkCooldown--;
158
- }
159
- if (wizard.state === 'gcd_locked' && wizard.gcdRemaining !== undefined)
160
- {
161
- wizard.gcdRemaining--;
162
- if (wizard.gcdRemaining <= 0)
163
- {
164
- wizard.state = 'idle';
165
- delete wizard.gcdRemaining;
166
- }
167
- }
168
-
169
- // Auto-aim: face the enemy by default, bot can override with aimDirection
170
- const enemy = wizards[1 - i]!;
171
- if (action.aimDirection !== undefined && Number.isFinite(action.aimDirection))
172
- {
173
- wizard.rotation = action.aimDirection;
174
- }
175
- else
176
- {
177
- wizard.rotation = angleTo(wizard.position, enemy.position);
178
- }
179
-
180
- // Handle casting
181
- if (wizard.state === 'casting' && wizard.castProgress !== undefined && wizard.castDuration !== undefined)
182
- {
183
- wizard.castProgress++;
184
- if (wizard.castProgress >= wizard.castDuration)
185
- {
186
- const spell = wizard.castingSpell;
187
-
188
- if (spell === 'missile' && wizard.missileConfig && wizard.missileAI)
189
- {
190
- // Validate missile config to enforce minimums (Lesson #9)
191
- const validConfig = validateMissileConfig(wizard.missileConfig);
192
- const id = `missile-${wizard.id}-${nextTick}`;
193
- const projectile: ProjectileState = {
194
- id,
195
- type: 'missile',
196
- ownerId: wizard.id,
197
- position: {...wizard.position},
198
- rotation: wizard.rotation, // Use wizard's rotation at time of firing
199
- speed: validConfig.speed,
200
- turnRate: validConfig.turnRate,
201
- damage: validConfig.damage,
202
- remainingTicks: validConfig.duration,
203
- };
204
- projectiles.push(projectile);
205
- missileAIs.set(id, wizard.missileAI);
206
- // Track last missile for warmup system
207
- wizard.lastMissileConfig = {...validConfig};
208
- delete wizard.missileConfig;
209
- delete wizard.missileAI;
210
- }
211
- else if (spell === 'blink' && wizard.blinkTarget)
212
- {
213
- // Clamp blink target to BLINK_RANGE from current position
214
- const dx = wizard.blinkTarget.x - wizard.position.x;
215
- const dy = wizard.blinkTarget.y - wizard.position.y;
216
- const distance = Math.sqrt(dx * dx + dy * dy);
217
-
218
- let targetPos = wizard.blinkTarget;
219
- if (distance > BLINK_RANGE)
220
- {
221
- // Clamp to max range in the same direction
222
- const scale = BLINK_RANGE / distance;
223
- targetPos = {
224
- x: wizard.position.x + dx * scale,
225
- y: wizard.position.y + dy * scale,
226
- };
227
- }
228
-
229
- // Clamp to arena bounds and calculate actual distance traveled
230
- const oldPos = wizard.position;
231
- wizard.position = clampToArena(targetPos, WIZARD_RADIUS);
232
- const actualDx = wizard.position.x - oldPos.x;
233
- const actualDy = wizard.position.y - oldPos.y;
234
- const actualDistance = Math.sqrt(actualDx * actualDx + actualDy * actualDy);
235
- wizard.blinkCooldown = calculateBlinkCooldown(actualDistance);
236
- delete wizard.blinkTarget;
237
- }
238
-
239
- completeCast(wizard);
240
- }
241
- }
242
-
243
- // Handle movement (default to no movement if bot didn't provide move)
244
- const oldPos = wizard.position;
245
- const move = action.move ?? {x: 0, y: 0};
246
- wizard.position = moveWizard(wizard, move, 1);
247
- wizard.velocity = {
248
- x: wizard.position.x - oldPos.x,
249
- y: wizard.position.y - oldPos.y,
250
- };
251
-
252
- // Handle shield — cancel goes to idle (no GCD)
253
- if (wizard.state === 'channeling')
254
- {
255
- updateShield(wizard, 1);
256
- if (action.cancel)
257
- {
258
- wizard.state = 'idle';
259
- delete wizard.channelingSpell;
260
- delete wizard.channelDuration;
261
- }
262
- }
263
-
264
- // Handle start cast
265
- if (wizard.state === 'idle' && action.startCast)
266
- {
267
- if (action.startCast.spell === 'blink' && wizard.blinkCooldown > 0)
268
- {
269
- // Cannot blink yet
270
- }
271
- else
272
- {
273
- startCast(wizard, action.startCast.spell, action.startCast.spell === 'missile' ? action.startCast.config : undefined);
274
- if (action.startCast.spell === 'missile')
275
- {
276
- wizard.missileConfig = action.startCast.config;
277
- wizard.missileAI = action.startCast.missileAI;
278
- if (action.startCast.direction !== undefined)
279
- {
280
- wizard.rotation = action.startCast.direction;
281
- }
282
- }
283
- else if (action.startCast.spell === 'blink')
284
- {
285
- wizard.blinkTarget = action.startCast.target;
286
- }
287
- }
288
- }
289
-
290
- // Handle cancel
291
- if (wizard.state === 'casting' && action.cancel)
292
- {
293
- wizard.state = 'idle';
294
- delete wizard.castingSpell;
295
- delete wizard.castProgress;
296
- delete wizard.castDuration;
297
- delete wizard.missileConfig;
298
- delete wizard.missileAI;
299
- delete wizard.blinkTarget;
300
- }
301
- });
302
-
303
- // 2b. Resolve wizard body collision (push apart if overlapping)
304
- resolveWizardCollision(wizards[0]!, wizards[1]!);
305
-
306
- // 3. Update projectiles
307
- const remainingProjectiles: ProjectileState[] = [];
308
- projectiles.forEach((projectile) =>
309
- {
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));
316
-
317
- // Wrap missile AI in try-catch - if it throws, missile continues straight (Lesson #21)
318
- let missileActions: MissileActions = {};
319
- try
320
- {
321
- missileActions = runWithHooks(projectile.id, () => ai({
322
- missileState: projectileView(projectile),
323
- worldState: getPlayerStateView(ownerIndex, wizards, projectiles, nextTick),
324
- random: missileRandom,
325
- })) ?? {};
326
- }
327
- catch(e)
328
- {
329
- errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
330
- }
331
-
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);
341
- }
342
- }
343
-
344
- const oldPos = projectile.position;
345
- projectile.position = moveProjectile(projectile, 1);
346
- projectile.remainingTicks--;
347
-
348
- // Check collisions
349
- let hit = false;
350
- const missileRadius = calculateMissileRadius(projectile.damage);
351
- wizards.forEach((wizard) =>
352
- {
353
- if (wizard.id !== projectile.ownerId && !hit)
354
- {
355
- if (sweptCircleCollision(oldPos, projectile.position, missileRadius, wizard.position, WIZARD_RADIUS))
356
- {
357
- const actualDamage = applyDamage(wizard, projectile.damage);
358
- hit = true;
359
-
360
- // Track damage for combat tracking hooks
361
- wizard.damageTaken += actualDamage;
362
- wizard.lastHitTick = nextTick;
363
-
364
- // Find the owner and track their damage dealt
365
- const owner = wizards.find((w) => w.id === projectile.ownerId);
366
- if (owner)
367
- {
368
- owner.damageDealt += actualDamage;
369
- }
370
- }
371
- }
372
- });
373
-
374
- // Check if projectile is beyond the water buffer
375
- const isOutOfBounds =
376
- projectile.position.x < -ARENA_WATER_BUFFER ||
377
- projectile.position.x > ARENA_WIDTH + ARENA_WATER_BUFFER ||
378
- projectile.position.y < -ARENA_WATER_BUFFER ||
379
- projectile.position.y > ARENA_HEIGHT + ARENA_WATER_BUFFER;
380
-
381
- if (!hit && projectile.remainingTicks > 0 && !isOutOfBounds)
382
- {
383
- remainingProjectiles.push(projectile);
384
- }
385
- else
386
- {
387
- missileAIs.delete(projectile.id);
388
- clearHooks(projectile.id);
389
- }
390
- });
391
-
392
- return {
393
- nextTick,
394
- wizards,
395
- projectiles: remainingProjectiles,
396
- errors,
397
- };
398
- }
399
-
400
- /**
401
- * Deep clone a wizard state to prevent mutation.
402
- */
403
- function cloneWizard(wizard: WizardState): WizardState
404
- {
405
- return {
406
- id: wizard.id,
407
- position: {...wizard.position},
408
- rotation: wizard.rotation,
409
- health: wizard.health,
410
- maxHealth: wizard.maxHealth,
411
- state: wizard.state,
412
- castingSpell: wizard.castingSpell,
413
- castProgress: wizard.castProgress,
414
- castDuration: wizard.castDuration,
415
- channelingSpell: wizard.channelingSpell,
416
- channelDuration: wizard.channelDuration,
417
- gcdRemaining: wizard.gcdRemaining,
418
- blinkCooldown: wizard.blinkCooldown,
419
- velocity: {...wizard.velocity},
420
- };
421
- }
422
-
423
- /**
424
- * Deep clone a projectile state to prevent mutation.
425
- */
426
- function cloneProjectile(projectile: ProjectileState): ProjectileState
427
- {
428
- return {
429
- id: projectile.id,
430
- type: projectile.type,
431
- ownerId: projectile.ownerId,
432
- position: {...projectile.position},
433
- rotation: projectile.rotation,
434
- speed: projectile.speed,
435
- turnRate: projectile.turnRate,
436
- damage: projectile.damage,
437
- remainingTicks: projectile.remainingTicks,
438
- };
439
- }
440
-
441
- /**
442
- * Get the game state from a specific player's perspective.
443
- * Returns a deep clone to prevent mutation of history entries.
444
- * Used for history recording where independent snapshots are needed.
445
- */
446
- export function getPlayerState(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
447
- {
448
- const me = wizards[playerIndex]!;
449
- const enemies = wizards.filter((_, i) => i !== playerIndex).map(cloneWizard);
450
- const clonedProjectiles = projectiles.map(cloneProjectile);
451
-
452
- return {
453
- tick,
454
- position: {...me.position},
455
- rotation: me.rotation,
456
- health: me.health,
457
- maxHealth: me.maxHealth,
458
- state: me.state,
459
- castingSpell: me.castingSpell,
460
- castProgress: me.castProgress,
461
- castDuration: me.castDuration,
462
- channelingSpell: me.channelingSpell,
463
- channelDuration: me.channelDuration,
464
- gcdRemaining: me.gcdRemaining,
465
- blinkCooldown: me.blinkCooldown,
466
- velocity: {...me.velocity},
467
- lastMissileConfig: me.lastMissileConfig ? {...me.lastMissileConfig} : undefined,
468
- warmupMultiplier: me.warmupMultiplier,
469
- enemies,
470
- projectiles: clonedProjectiles,
471
- myProjectiles: clonedProjectiles.filter((p) => p.ownerId === me.id),
472
- // Combat tracking
473
- damageDealt: me.damageDealt,
474
- damageTaken: me.damageTaken,
475
- lastHitTick: me.lastHitTick,
476
- };
477
- }
478
-
479
- /**
480
- * Create a lightweight read-only view of a projectile.
481
- * Clones only the mutable sub-objects (position) to prevent
482
- * bot code from corrupting internal simulation state.
483
- * Scalar fields are safe since they're copied by value.
484
- */
485
- function projectileView(p: ProjectileState): ProjectileState
486
- {
487
- return {
488
- id: p.id,
489
- type: p.type,
490
- ownerId: p.ownerId,
491
- position: {x: p.position.x, y: p.position.y},
492
- rotation: p.rotation,
493
- speed: p.speed,
494
- turnRate: p.turnRate,
495
- damage: p.damage,
496
- remainingTicks: p.remainingTicks,
497
- };
498
- }
499
-
500
- /**
501
- * Extract public WizardState fields from an InternalWizardState.
502
- * Clones position and velocity ({x,y} spreads) to prevent bot code
503
- * from mutating internal simulation state. Scalar fields are safe.
504
- */
505
- function extractWizardView(wizard: InternalWizardState): WizardState
506
- {
507
- return {
508
- id: wizard.id,
509
- position: {x: wizard.position.x, y: wizard.position.y},
510
- rotation: wizard.rotation,
511
- health: wizard.health,
512
- maxHealth: wizard.maxHealth,
513
- state: wizard.state,
514
- castingSpell: wizard.castingSpell,
515
- castProgress: wizard.castProgress,
516
- castDuration: wizard.castDuration,
517
- channelingSpell: wizard.channelingSpell,
518
- channelDuration: wizard.channelDuration,
519
- gcdRemaining: wizard.gcdRemaining,
520
- blinkCooldown: wizard.blinkCooldown,
521
- velocity: {x: wizard.velocity.x, y: wizard.velocity.y},
522
- };
523
- }
524
-
525
- /**
526
- * Get a lightweight read-only view of game state for AI calls.
527
- * Clones only the small mutable sub-objects (position, velocity as {x,y})
528
- * to prevent bot code from corrupting internal simulation state.
529
- * Scalar fields are copied by value and safe from mutation.
530
- *
531
- * This is much faster than getPlayerState() (which deep-clones everything)
532
- * because it only spreads the tiny {x,y} objects, not full deep clones.
533
- */
534
- function getPlayerStateView(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
535
- {
536
- const me = wizards[playerIndex]!;
537
- const enemies = wizards.filter((_, i) => i !== playerIndex).map(extractWizardView);
538
- const projViews = projectiles.map(projectileView);
539
-
540
- return {
541
- tick,
542
- position: {x: me.position.x, y: me.position.y},
543
- rotation: me.rotation,
544
- health: me.health,
545
- maxHealth: me.maxHealth,
546
- state: me.state,
547
- castingSpell: me.castingSpell,
548
- castProgress: me.castProgress,
549
- castDuration: me.castDuration,
550
- channelingSpell: me.channelingSpell,
551
- channelDuration: me.channelDuration,
552
- gcdRemaining: me.gcdRemaining,
553
- blinkCooldown: me.blinkCooldown,
554
- velocity: {x: me.velocity.x, y: me.velocity.y},
555
- lastMissileConfig: me.lastMissileConfig ? {...me.lastMissileConfig} : undefined,
556
- warmupMultiplier: me.warmupMultiplier,
557
- enemies,
558
- projectiles: projViews,
559
- myProjectiles: projViews.filter((p) => p.ownerId === me.id),
560
- // Combat tracking
561
- damageDealt: me.damageDealt,
562
- damageTaken: me.damageTaken,
563
- lastHitTick: me.lastHitTick,
564
- };
565
- }
566
-
567
- /** Winner of a single match: a wizard ID, 'draw' (simultaneous kill), or null (timeout). */
568
- export type MatchWinner = 'wizard-1' | 'wizard-2' | 'draw' | null;
569
-
570
- /** Winner of a fight (aggregate): a wizard ID or 'draw'. Never null. */
571
- export type FightWinner = 'wizard-1' | 'wizard-2' | 'draw';
572
-
573
- /**
574
- * Result of a simulation.
575
- */
576
- /** A runtime error captured from a bot or missile AI function. */
577
- export interface BotError
578
- {
579
- tick: number;
580
- entityId: string;
581
- message: string;
582
- }
583
-
584
- export interface SimulateResult
585
- {
586
- /** 'wizard-1'/'wizard-2' = killed opponent, 'draw' = simultaneous kill, null = timeout */
587
- winner: MatchWinner;
588
- ticks: number;
589
- finalState: GameState;
590
- history: GameState[];
591
- /** Runtime errors thrown by bot or missile AI functions (empty if no errors). */
592
- errors: BotError[];
593
- }
594
-
595
- /**
596
- * Result of a fight (best-of-5 at different spawn distances).
597
- */
598
- export interface FightResult
599
- {
600
- wizard1Wins: number;
601
- wizard2Wins: number;
602
- draws: number;
603
- /** Winner of the fight: 'wizard-1', 'wizard-2', or 'draw' (never null) */
604
- winner: FightWinner;
605
- /**
606
- * Individual match results (one per spawn distance, non-swapped only).
607
- * Used for visual playback in the web viewer. Scoring includes both sides.
608
- */
609
- matches: SimulateResult[];
610
- }
611
-
612
- /** Spawn distances for the 5 matches in a fight (creates butterfly effect variation) */
613
- export const FIGHT_SPAWN_DISTANCES = [500, 550, 600, 650, 700];
614
-
615
- /**
616
- * Run a fight: 10 matches (5 spawn distances × 2 sides) between two bots.
617
- * Each spawn distance is played twice — once with each bot on each side —
618
- * to ensure results are independent of starting position.
619
- *
620
- * The `matches` array contains only the 5 non-swapped matches (for visual playback).
621
- * The scoring aggregates (wizard1Wins, wizard2Wins, draws) include all 10 matches.
622
- *
623
- * This is the standard way to determine who wins a matchup.
624
- * Used by both the tournament system and the visual UI.
625
- */
626
- export function fight(
627
- wizard1AI: WizardFunction,
628
- wizard2AI: WizardFunction,
629
- options: {
630
- seed?: number;
631
- maxTicks?: number;
632
- } = {},
633
- ): FightResult
634
- {
635
- const matches: SimulateResult[] = [];
636
- let wizard1Wins = 0;
637
- let wizard2Wins = 0;
638
- let draws = 0;
639
-
640
- for (const spawnDistance of FIGHT_SPAWN_DISTANCES)
641
- {
642
- // Normal side: wizard1 on left, wizard2 on right
643
- const result = simulate(wizard1AI, wizard2AI, {
644
- seed: options.seed,
645
- maxTicks: options.maxTicks,
646
- spawnDistance,
647
- });
648
-
649
- matches.push(result);
650
-
651
- if (result.winner === 'wizard-1')
652
- {
653
- wizard1Wins++;
654
- }
655
- else if (result.winner === 'wizard-2')
656
- {
657
- wizard2Wins++;
658
- }
659
- else
660
- {
661
- draws++;
662
- }
663
-
664
- // Swapped side: wizard2 on left, wizard1 on right
665
- // skipHistory: swapped matches are only for scoring, not visual playback
666
- const swapped = simulate(wizard2AI, wizard1AI, {
667
- seed: options.seed,
668
- maxTicks: options.maxTicks,
669
- spawnDistance,
670
- skipHistory: true,
671
- });
672
-
673
- // Don't push swapped match to matches array (it's only for scoring)
674
- if (swapped.winner === 'wizard-1')
675
- {
676
- // wizard-1 in swapped match = wizard2 from original perspective
677
- wizard2Wins++;
678
- }
679
- else if (swapped.winner === 'wizard-2')
680
- {
681
- wizard1Wins++;
682
- }
683
- else
684
- {
685
- draws++;
686
- }
687
- }
688
-
689
- const winner: FightWinner = wizard1Wins > wizard2Wins ? 'wizard-1'
690
- : wizard2Wins > wizard1Wins ? 'wizard-2'
691
- : 'draw';
692
-
693
- return {wizard1Wins, wizard2Wins, draws, winner, matches};
694
- }
695
-
696
- /**
697
- * Run a full match simulation.
698
- *
699
- * @param options.skipHistory - When true, skips recording per-tick history snapshots.
700
- * This dramatically improves performance (no deep cloning per tick) and is used
701
- * by the optimizer and fight() scoring. The returned history array will be empty
702
- * and finalState will still be populated.
703
- */
704
- export function simulate(
705
- wizard1AI: WizardFunction,
706
- wizard2AI: WizardFunction,
707
- options: {
708
- maxTicks?: number;
709
- seed?: number;
710
- spawnDistance?: number;
711
- skipHistory?: boolean;
712
- } = {},
713
- ): SimulateResult
714
- {
715
- // Validate AI functions (Lesson #1)
716
- if (typeof wizard1AI !== 'function')
717
- {
718
- throw new Error('wizard1AI must be a function');
719
- }
720
- if (typeof wizard2AI !== 'function')
721
- {
722
- throw new Error('wizard2AI must be a function');
723
- }
724
-
725
- // Sanitize options (Lesson #3)
726
- const {maxTicks: rawMaxTicks = MATCH_DURATION, seed: rawSeed = Date.now(), spawnDistance: rawSpawnDistance, skipHistory = false} = options;
727
-
728
- // Sanitize seed - ensure valid 32-bit integer
729
- const seed = Number.isFinite(rawSeed) ? Math.floor(rawSeed) & 0x7FFFFFFF : Date.now() & 0x7FFFFFFF;
730
-
731
- // Sanitize maxTicks - ensure positive integer with reasonable limit
732
- const maxTicks = Number.isFinite(rawMaxTicks) && rawMaxTicks > 0
733
- ? Math.min(Math.floor(rawMaxTicks), 100000) // Cap at 100k ticks (~16 min)
734
- : MATCH_DURATION;
735
-
736
- // Sanitize spawnDistance - must be positive and fit in arena
737
- const spawnDistance = Number.isFinite(rawSpawnDistance) && rawSpawnDistance! > 0
738
- ? Math.min(rawSpawnDistance!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100) // Leave room for wizards
739
- : SPAWN_DISTANCE;
740
-
741
- const config: GameConfig = Object.freeze({
742
- arenaSize: Object.freeze({width: ARENA_WIDTH, height: ARENA_HEIGHT}),
743
- tickRate: TICKS_PER_SECOND,
744
- maxTicks,
745
- });
746
-
747
- resetAllHooks();
748
-
749
- const initialState = createInitialState(seed, spawnDistance);
750
- const history: GameState[] = skipHistory ? [] : [initialState];
751
- let currentTick = 0;
752
- let wizards: InternalWizardState[] = [
753
- {
754
- id: 'wizard-1',
755
- position: {x: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
756
- rotation: 0,
757
- health: WIZARD_HEALTH,
758
- maxHealth: WIZARD_HEALTH,
759
- state: 'idle',
760
- blinkCooldown: 0,
761
- velocity: {x: 0, y: 0},
762
- damageDealt: 0,
763
- damageTaken: 0,
764
- lastHitTick: 0,
765
- },
766
- {
767
- id: 'wizard-2',
768
- position: {x: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
769
- rotation: 180,
770
- health: WIZARD_HEALTH,
771
- maxHealth: WIZARD_HEALTH,
772
- state: 'idle',
773
- blinkCooldown: 0,
774
- velocity: {x: 0, y: 0},
775
- damageDealt: 0,
776
- damageTaken: 0,
777
- lastHitTick: 0,
778
- },
779
- ];
780
- let projectiles: ProjectileState[] = [];
781
- const missileAIs = new Map<string, MissileAIFunction>();
782
- const allErrors: BotError[] = [];
783
-
784
- while (currentTick < maxTicks)
785
- {
786
- const result = tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, seed);
787
- currentTick = result.nextTick;
788
- wizards = result.wizards;
789
- projectiles = result.projectiles;
790
- if (result.errors.length > 0) allErrors.push(...result.errors);
791
-
792
- if (!skipHistory)
793
- {
794
- // Deep-clone snapshot for history (needed for replay)
795
- history.push(getPlayerState(0, wizards, projectiles, currentTick));
796
- }
797
-
798
- if (wizards[0]!.health <= 0 || wizards[1]!.health <= 0)
799
- {
800
- break;
801
- }
802
- }
803
-
804
- let winner: MatchWinner = null;
805
- if (wizards[0]!.health <= 0 && wizards[1]!.health <= 0)
806
- {
807
- winner = 'draw';
808
- }
809
- else if (wizards[1]!.health <= 0)
810
- {
811
- winner = 'wizard-1';
812
- }
813
- else if (wizards[0]!.health <= 0)
814
- {
815
- winner = 'wizard-2';
816
- }
817
-
818
- // Always create final state snapshot (needed for result)
819
- const finalState = getPlayerState(0, wizards, projectiles, currentTick);
820
-
821
- return {
822
- winner,
823
- ticks: currentTick,
824
- finalState,
825
- history: skipHistory ? [] : history,
826
- errors: allErrors,
827
- };
828
- }
1
+ import {
2
+ GameState,
3
+ WizardState,
4
+ ProjectileState,
5
+ WizardFunction,
6
+ WizardActions,
7
+ MissileAIFunction,
8
+ MissileActions,
9
+ GameConfig,
10
+ MissileConfig,
11
+ } from '../types.js';
12
+ import {
13
+ ARENA_WIDTH,
14
+ ARENA_HEIGHT,
15
+ SPAWN_DISTANCE,
16
+ WIZARD_HEALTH,
17
+ TICKS_PER_SECOND,
18
+ calculateBlinkCooldown,
19
+ BLINK_RANGE,
20
+ WIZARD_RADIUS,
21
+ MATCH_DURATION,
22
+ validateMissileConfig,
23
+ calculateMissileRadius,
24
+ ARENA_WATER_BUFFER,
25
+ } from '../rules.js';
26
+ import {moveWizard, moveProjectile, sweptCircleCollision, clampToArena, resolveWizardCollision} from './physics.js';
27
+ import {applyDamage, updateShield, startCast, completeCast} from './spells.js';
28
+ import {runWithHooks, resetAllHooks, clearHooks} from './hooks-runtime.js';
29
+ import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
30
+ import {createRandom, createEntitySeed} from '../utils/random.js';
31
+
32
+ export interface InternalWizardState extends WizardState
33
+ {
34
+ missileConfig?: MissileConfig;
35
+ missileAI?: MissileAIFunction;
36
+ blinkTarget?: {x: number; y: number};
37
+ // Combat tracking
38
+ damageDealt: number;
39
+ damageTaken: number;
40
+ lastHitTick: number;
41
+ }
42
+
43
+ /**
44
+ * Initialize a new match state.
45
+ */
46
+ export function createInitialState(_seed: number, spawnDist: number = SPAWN_DISTANCE): GameState
47
+ {
48
+ const center = {x: ARENA_WIDTH / 2, y: ARENA_HEIGHT / 2};
49
+ const offset = spawnDist / 2;
50
+
51
+ const wizard1: WizardState = {
52
+ id: 'wizard-1',
53
+ position: {x: center.x - offset, y: center.y},
54
+ rotation: 0,
55
+ health: WIZARD_HEALTH,
56
+ maxHealth: WIZARD_HEALTH,
57
+ state: 'idle',
58
+ blinkCooldown: 0,
59
+ velocity: {x: 0, y: 0},
60
+ };
61
+
62
+ const wizard2: WizardState = {
63
+ id: 'wizard-2',
64
+ position: {x: center.x + offset, y: center.y},
65
+ rotation: 180,
66
+ health: WIZARD_HEALTH,
67
+ maxHealth: WIZARD_HEALTH,
68
+ state: 'idle',
69
+ blinkCooldown: 0,
70
+ velocity: {x: 0, y: 0},
71
+ };
72
+
73
+ return {
74
+ tick: 0,
75
+ position: wizard1.position,
76
+ rotation: wizard1.rotation,
77
+ health: wizard1.health,
78
+ maxHealth: wizard1.maxHealth,
79
+ state: wizard1.state,
80
+ blinkCooldown: wizard1.blinkCooldown,
81
+ velocity: wizard1.velocity,
82
+ enemies: [wizard2],
83
+ projectiles: [],
84
+ myProjectiles: [],
85
+ damageDealt: 0,
86
+ damageTaken: 0,
87
+ lastHitTick: 0,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Process one game tick.
93
+ */
94
+ export function tick(
95
+ currentTick: number,
96
+ wizard1AI: WizardFunction,
97
+ wizard2AI: WizardFunction,
98
+ config: GameConfig,
99
+ wizards: InternalWizardState[],
100
+ projectiles: ProjectileState[],
101
+ missileAIs: Map<string, MissileAIFunction>,
102
+ matchSeed: number,
103
+ ): {
104
+ nextTick: number;
105
+ wizards: InternalWizardState[];
106
+ projectiles: ProjectileState[];
107
+ errors: BotError[];
108
+ }
109
+ {
110
+ const nextTick = currentTick + 1;
111
+ const errors: BotError[] = [];
112
+
113
+ // 1. Run wizard AIs with isolated random generators
114
+ // Each wizard gets a fresh random generator seeded by (matchSeed, wizardId, tick)
115
+ const random1 = createRandom(createEntitySeed(matchSeed, wizards[0]!.id, nextTick));
116
+ const random2 = createRandom(createEntitySeed(matchSeed, wizards[1]!.id, nextTick));
117
+
118
+ // Wrap AI calls in try-catch - if AI throws, wizard does nothing (Lesson #21)
119
+ let actions1: WizardActions = {move: {x: 0, y: 0}};
120
+ try
121
+ {
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}};
127
+ }
128
+ catch(e)
129
+ {
130
+ errors.push({tick: nextTick, entityId: 'wizard-1', message: e instanceof Error ? e.message : String(e)});
131
+ }
132
+
133
+ let actions2: WizardActions = {move: {x: 0, y: 0}};
134
+ try
135
+ {
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}};
141
+ }
142
+ catch(e)
143
+ {
144
+ errors.push({tick: nextTick, entityId: 'wizard-2', message: e instanceof Error ? e.message : String(e)});
145
+ }
146
+
147
+ const actions = [actions1, actions2];
148
+
149
+ // 2. Update wizards
150
+ wizards.forEach((wizard, i) =>
151
+ {
152
+ const action = actions[i]!;
153
+
154
+ // Handle cooldowns
155
+ if (wizard.blinkCooldown > 0)
156
+ {
157
+ wizard.blinkCooldown--;
158
+ }
159
+ if (wizard.state === 'gcd_locked' && wizard.gcdRemaining !== undefined)
160
+ {
161
+ wizard.gcdRemaining--;
162
+ if (wizard.gcdRemaining <= 0)
163
+ {
164
+ wizard.state = 'idle';
165
+ delete wizard.gcdRemaining;
166
+ }
167
+ }
168
+
169
+ // Auto-aim: face the enemy by default, bot can override with aimDirection
170
+ const enemy = wizards[1 - i]!;
171
+ if (action.aimDirection !== undefined && Number.isFinite(action.aimDirection))
172
+ {
173
+ wizard.rotation = action.aimDirection;
174
+ }
175
+ else
176
+ {
177
+ wizard.rotation = angleTo(wizard.position, enemy.position);
178
+ }
179
+
180
+ // Handle casting
181
+ if (wizard.state === 'casting' && wizard.castProgress !== undefined && wizard.castDuration !== undefined)
182
+ {
183
+ wizard.castProgress++;
184
+ if (wizard.castProgress >= wizard.castDuration)
185
+ {
186
+ const spell = wizard.castingSpell;
187
+
188
+ if (spell === 'missile' && wizard.missileConfig && wizard.missileAI)
189
+ {
190
+ // Validate missile config to enforce minimums (Lesson #9)
191
+ const validConfig = validateMissileConfig(wizard.missileConfig);
192
+ const id = `missile-${wizard.id}-${nextTick}`;
193
+ const projectile: ProjectileState = {
194
+ id,
195
+ type: 'missile',
196
+ ownerId: wizard.id,
197
+ position: {...wizard.position},
198
+ rotation: wizard.rotation, // Use wizard's rotation at time of firing
199
+ speed: validConfig.speed,
200
+ turnRate: validConfig.turnRate,
201
+ damage: validConfig.damage,
202
+ remainingTicks: validConfig.duration,
203
+ };
204
+ projectiles.push(projectile);
205
+ missileAIs.set(id, wizard.missileAI);
206
+ // Track last missile for warmup system
207
+ wizard.lastMissileConfig = {...validConfig};
208
+ delete wizard.missileConfig;
209
+ delete wizard.missileAI;
210
+ }
211
+ else if (spell === 'blink' && wizard.blinkTarget)
212
+ {
213
+ // Clamp blink target to BLINK_RANGE from current position
214
+ const dx = wizard.blinkTarget.x - wizard.position.x;
215
+ const dy = wizard.blinkTarget.y - wizard.position.y;
216
+ const distance = Math.sqrt(dx * dx + dy * dy);
217
+
218
+ let targetPos = wizard.blinkTarget;
219
+ if (distance > BLINK_RANGE)
220
+ {
221
+ // Clamp to max range in the same direction
222
+ const scale = BLINK_RANGE / distance;
223
+ targetPos = {
224
+ x: wizard.position.x + dx * scale,
225
+ y: wizard.position.y + dy * scale,
226
+ };
227
+ }
228
+
229
+ // Clamp to arena bounds and calculate actual distance traveled
230
+ const oldPos = wizard.position;
231
+ wizard.position = clampToArena(targetPos, WIZARD_RADIUS);
232
+ const actualDx = wizard.position.x - oldPos.x;
233
+ const actualDy = wizard.position.y - oldPos.y;
234
+ const actualDistance = Math.sqrt(actualDx * actualDx + actualDy * actualDy);
235
+ wizard.blinkCooldown = calculateBlinkCooldown(actualDistance);
236
+ delete wizard.blinkTarget;
237
+ }
238
+
239
+ completeCast(wizard);
240
+ }
241
+ }
242
+
243
+ // Handle movement (default to no movement if bot didn't provide move)
244
+ const oldPos = wizard.position;
245
+ const move = action.move ?? {x: 0, y: 0};
246
+ wizard.position = moveWizard(wizard, move, 1);
247
+ wizard.velocity = {
248
+ x: wizard.position.x - oldPos.x,
249
+ y: wizard.position.y - oldPos.y,
250
+ };
251
+
252
+ // Handle shield — cancel goes to idle (no GCD)
253
+ if (wizard.state === 'channeling')
254
+ {
255
+ updateShield(wizard, 1);
256
+ if (action.cancel)
257
+ {
258
+ wizard.state = 'idle';
259
+ delete wizard.channelingSpell;
260
+ delete wizard.channelDuration;
261
+ }
262
+ }
263
+
264
+ // Handle start cast
265
+ if (wizard.state === 'idle' && action.startCast)
266
+ {
267
+ if (action.startCast.spell === 'blink' && wizard.blinkCooldown > 0)
268
+ {
269
+ // Cannot blink yet
270
+ }
271
+ else
272
+ {
273
+ startCast(wizard, action.startCast.spell, action.startCast.spell === 'missile' ? action.startCast.config : undefined);
274
+ if (action.startCast.spell === 'missile')
275
+ {
276
+ wizard.missileConfig = action.startCast.config;
277
+ wizard.missileAI = action.startCast.missileAI;
278
+ if (action.startCast.direction !== undefined)
279
+ {
280
+ wizard.rotation = action.startCast.direction;
281
+ }
282
+ }
283
+ else if (action.startCast.spell === 'blink')
284
+ {
285
+ wizard.blinkTarget = action.startCast.target;
286
+ }
287
+ }
288
+ }
289
+
290
+ // Handle cancel
291
+ if (wizard.state === 'casting' && action.cancel)
292
+ {
293
+ wizard.state = 'idle';
294
+ delete wizard.castingSpell;
295
+ delete wizard.castProgress;
296
+ delete wizard.castDuration;
297
+ delete wizard.missileConfig;
298
+ delete wizard.missileAI;
299
+ delete wizard.blinkTarget;
300
+ }
301
+ });
302
+
303
+ // 2b. Resolve wizard body collision (push apart if overlapping)
304
+ resolveWizardCollision(wizards[0]!, wizards[1]!);
305
+
306
+ // 3. Update projectiles
307
+ const remainingProjectiles: ProjectileState[] = [];
308
+ projectiles.forEach((projectile) =>
309
+ {
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));
316
+
317
+ // Wrap missile AI in try-catch - if it throws, missile continues straight (Lesson #21)
318
+ let missileActions: MissileActions = {};
319
+ try
320
+ {
321
+ missileActions = runWithHooks(projectile.id, () => ai({
322
+ missileState: projectileView(projectile),
323
+ worldState: getPlayerStateView(ownerIndex, wizards, projectiles, nextTick),
324
+ random: missileRandom,
325
+ })) ?? {};
326
+ }
327
+ catch(e)
328
+ {
329
+ errors.push({tick: nextTick, entityId: projectile.id, message: e instanceof Error ? e.message : String(e)});
330
+ }
331
+
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);
341
+ }
342
+ }
343
+
344
+ const oldPos = projectile.position;
345
+ projectile.position = moveProjectile(projectile, 1);
346
+ projectile.remainingTicks--;
347
+
348
+ // Check collisions
349
+ let hit = false;
350
+ const missileRadius = calculateMissileRadius(projectile.damage);
351
+ wizards.forEach((wizard) =>
352
+ {
353
+ if (wizard.id !== projectile.ownerId && !hit)
354
+ {
355
+ if (sweptCircleCollision(oldPos, projectile.position, missileRadius, wizard.position, WIZARD_RADIUS))
356
+ {
357
+ const actualDamage = applyDamage(wizard, projectile.damage);
358
+ hit = true;
359
+
360
+ // Track damage for combat tracking hooks
361
+ wizard.damageTaken += actualDamage;
362
+ wizard.lastHitTick = nextTick;
363
+
364
+ // Find the owner and track their damage dealt
365
+ const owner = wizards.find((w) => w.id === projectile.ownerId);
366
+ if (owner)
367
+ {
368
+ owner.damageDealt += actualDamage;
369
+ }
370
+ }
371
+ }
372
+ });
373
+
374
+ // Check if projectile is beyond the water buffer
375
+ const isOutOfBounds =
376
+ projectile.position.x < -ARENA_WATER_BUFFER ||
377
+ projectile.position.x > ARENA_WIDTH + ARENA_WATER_BUFFER ||
378
+ projectile.position.y < -ARENA_WATER_BUFFER ||
379
+ projectile.position.y > ARENA_HEIGHT + ARENA_WATER_BUFFER;
380
+
381
+ if (!hit && projectile.remainingTicks > 0 && !isOutOfBounds)
382
+ {
383
+ remainingProjectiles.push(projectile);
384
+ }
385
+ else
386
+ {
387
+ missileAIs.delete(projectile.id);
388
+ clearHooks(projectile.id);
389
+ }
390
+ });
391
+
392
+ return {
393
+ nextTick,
394
+ wizards,
395
+ projectiles: remainingProjectiles,
396
+ errors,
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Deep clone a wizard state to prevent mutation.
402
+ */
403
+ function cloneWizard(wizard: WizardState): WizardState
404
+ {
405
+ return {
406
+ id: wizard.id,
407
+ position: {...wizard.position},
408
+ rotation: wizard.rotation,
409
+ health: wizard.health,
410
+ maxHealth: wizard.maxHealth,
411
+ state: wizard.state,
412
+ castingSpell: wizard.castingSpell,
413
+ castProgress: wizard.castProgress,
414
+ castDuration: wizard.castDuration,
415
+ channelingSpell: wizard.channelingSpell,
416
+ channelDuration: wizard.channelDuration,
417
+ gcdRemaining: wizard.gcdRemaining,
418
+ blinkCooldown: wizard.blinkCooldown,
419
+ velocity: {...wizard.velocity},
420
+ };
421
+ }
422
+
423
+ /**
424
+ * Deep clone a projectile state to prevent mutation.
425
+ */
426
+ function cloneProjectile(projectile: ProjectileState): ProjectileState
427
+ {
428
+ return {
429
+ id: projectile.id,
430
+ type: projectile.type,
431
+ ownerId: projectile.ownerId,
432
+ position: {...projectile.position},
433
+ rotation: projectile.rotation,
434
+ speed: projectile.speed,
435
+ turnRate: projectile.turnRate,
436
+ damage: projectile.damage,
437
+ remainingTicks: projectile.remainingTicks,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Get the game state from a specific player's perspective.
443
+ * Returns a deep clone to prevent mutation of history entries.
444
+ * Used for history recording where independent snapshots are needed.
445
+ */
446
+ export function getPlayerState(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
447
+ {
448
+ const me = wizards[playerIndex]!;
449
+ const enemies = wizards.filter((_, i) => i !== playerIndex).map(cloneWizard);
450
+ const clonedProjectiles = projectiles.map(cloneProjectile);
451
+
452
+ return {
453
+ tick,
454
+ position: {...me.position},
455
+ rotation: me.rotation,
456
+ health: me.health,
457
+ maxHealth: me.maxHealth,
458
+ state: me.state,
459
+ castingSpell: me.castingSpell,
460
+ castProgress: me.castProgress,
461
+ castDuration: me.castDuration,
462
+ channelingSpell: me.channelingSpell,
463
+ channelDuration: me.channelDuration,
464
+ gcdRemaining: me.gcdRemaining,
465
+ blinkCooldown: me.blinkCooldown,
466
+ velocity: {...me.velocity},
467
+ lastMissileConfig: me.lastMissileConfig ? {...me.lastMissileConfig} : undefined,
468
+ warmupMultiplier: me.warmupMultiplier,
469
+ enemies,
470
+ projectiles: clonedProjectiles,
471
+ myProjectiles: clonedProjectiles.filter((p) => p.ownerId === me.id),
472
+ // Combat tracking
473
+ damageDealt: me.damageDealt,
474
+ damageTaken: me.damageTaken,
475
+ lastHitTick: me.lastHitTick,
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Create a lightweight read-only view of a projectile.
481
+ * Clones only the mutable sub-objects (position) to prevent
482
+ * bot code from corrupting internal simulation state.
483
+ * Scalar fields are safe since they're copied by value.
484
+ */
485
+ function projectileView(p: ProjectileState): ProjectileState
486
+ {
487
+ return {
488
+ id: p.id,
489
+ type: p.type,
490
+ ownerId: p.ownerId,
491
+ position: {x: p.position.x, y: p.position.y},
492
+ rotation: p.rotation,
493
+ speed: p.speed,
494
+ turnRate: p.turnRate,
495
+ damage: p.damage,
496
+ remainingTicks: p.remainingTicks,
497
+ };
498
+ }
499
+
500
+ /**
501
+ * Extract public WizardState fields from an InternalWizardState.
502
+ * Clones position and velocity ({x,y} spreads) to prevent bot code
503
+ * from mutating internal simulation state. Scalar fields are safe.
504
+ */
505
+ function extractWizardView(wizard: InternalWizardState): WizardState
506
+ {
507
+ return {
508
+ id: wizard.id,
509
+ position: {x: wizard.position.x, y: wizard.position.y},
510
+ rotation: wizard.rotation,
511
+ health: wizard.health,
512
+ maxHealth: wizard.maxHealth,
513
+ state: wizard.state,
514
+ castingSpell: wizard.castingSpell,
515
+ castProgress: wizard.castProgress,
516
+ castDuration: wizard.castDuration,
517
+ channelingSpell: wizard.channelingSpell,
518
+ channelDuration: wizard.channelDuration,
519
+ gcdRemaining: wizard.gcdRemaining,
520
+ blinkCooldown: wizard.blinkCooldown,
521
+ velocity: {x: wizard.velocity.x, y: wizard.velocity.y},
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Get a lightweight read-only view of game state for AI calls.
527
+ * Clones only the small mutable sub-objects (position, velocity as {x,y})
528
+ * to prevent bot code from corrupting internal simulation state.
529
+ * Scalar fields are copied by value and safe from mutation.
530
+ *
531
+ * This is much faster than getPlayerState() (which deep-clones everything)
532
+ * because it only spreads the tiny {x,y} objects, not full deep clones.
533
+ */
534
+ function getPlayerStateView(playerIndex: number, wizards: InternalWizardState[], projectiles: ProjectileState[], tick: number): GameState
535
+ {
536
+ const me = wizards[playerIndex]!;
537
+ const enemies = wizards.filter((_, i) => i !== playerIndex).map(extractWizardView);
538
+ const projViews = projectiles.map(projectileView);
539
+
540
+ return {
541
+ tick,
542
+ position: {x: me.position.x, y: me.position.y},
543
+ rotation: me.rotation,
544
+ health: me.health,
545
+ maxHealth: me.maxHealth,
546
+ state: me.state,
547
+ castingSpell: me.castingSpell,
548
+ castProgress: me.castProgress,
549
+ castDuration: me.castDuration,
550
+ channelingSpell: me.channelingSpell,
551
+ channelDuration: me.channelDuration,
552
+ gcdRemaining: me.gcdRemaining,
553
+ blinkCooldown: me.blinkCooldown,
554
+ velocity: {x: me.velocity.x, y: me.velocity.y},
555
+ lastMissileConfig: me.lastMissileConfig ? {...me.lastMissileConfig} : undefined,
556
+ warmupMultiplier: me.warmupMultiplier,
557
+ enemies,
558
+ projectiles: projViews,
559
+ myProjectiles: projViews.filter((p) => p.ownerId === me.id),
560
+ // Combat tracking
561
+ damageDealt: me.damageDealt,
562
+ damageTaken: me.damageTaken,
563
+ lastHitTick: me.lastHitTick,
564
+ };
565
+ }
566
+
567
+ /** Winner of a single match: a wizard ID, 'draw' (simultaneous kill), or null (timeout). */
568
+ export type MatchWinner = 'wizard-1' | 'wizard-2' | 'draw' | null;
569
+
570
+ /** Winner of a fight (aggregate): a wizard ID or 'draw'. Never null. */
571
+ export type FightWinner = 'wizard-1' | 'wizard-2' | 'draw';
572
+
573
+ /**
574
+ * Result of a simulation.
575
+ */
576
+ /** A runtime error captured from a bot or missile AI function. */
577
+ export interface BotError
578
+ {
579
+ tick: number;
580
+ entityId: string;
581
+ message: string;
582
+ }
583
+
584
+ export interface SimulateResult
585
+ {
586
+ /** 'wizard-1'/'wizard-2' = killed opponent, 'draw' = simultaneous kill, null = timeout */
587
+ winner: MatchWinner;
588
+ ticks: number;
589
+ finalState: GameState;
590
+ history: GameState[];
591
+ /** Runtime errors thrown by bot or missile AI functions (empty if no errors). */
592
+ errors: BotError[];
593
+ }
594
+
595
+ /**
596
+ * Result of a fight (best-of-5 at different spawn distances).
597
+ */
598
+ export interface FightResult
599
+ {
600
+ wizard1Wins: number;
601
+ wizard2Wins: number;
602
+ draws: number;
603
+ /** Winner of the fight: 'wizard-1', 'wizard-2', or 'draw' (never null) */
604
+ winner: FightWinner;
605
+ /**
606
+ * Individual match results (one per spawn distance, non-swapped only).
607
+ * Used for visual playback in the web viewer. Scoring includes both sides.
608
+ */
609
+ matches: SimulateResult[];
610
+ }
611
+
612
+ /** Spawn distances for the 5 matches in a fight (creates butterfly effect variation) */
613
+ export const FIGHT_SPAWN_DISTANCES = [500, 550, 600, 650, 700];
614
+
615
+ /**
616
+ * Run a fight: 10 matches (5 spawn distances × 2 sides) between two bots.
617
+ * Each spawn distance is played twice — once with each bot on each side —
618
+ * to ensure results are independent of starting position.
619
+ *
620
+ * The `matches` array contains only the 5 non-swapped matches (for visual playback).
621
+ * The scoring aggregates (wizard1Wins, wizard2Wins, draws) include all 10 matches.
622
+ *
623
+ * This is the standard way to determine who wins a matchup.
624
+ * Used by both the tournament system and the visual UI.
625
+ */
626
+ export function fight(
627
+ wizard1AI: WizardFunction,
628
+ wizard2AI: WizardFunction,
629
+ options: {
630
+ seed?: number;
631
+ maxTicks?: number;
632
+ } = {},
633
+ ): FightResult
634
+ {
635
+ const matches: SimulateResult[] = [];
636
+ let wizard1Wins = 0;
637
+ let wizard2Wins = 0;
638
+ let draws = 0;
639
+
640
+ for (const spawnDistance of FIGHT_SPAWN_DISTANCES)
641
+ {
642
+ // Normal side: wizard1 on left, wizard2 on right
643
+ const result = simulate(wizard1AI, wizard2AI, {
644
+ seed: options.seed,
645
+ maxTicks: options.maxTicks,
646
+ spawnDistance,
647
+ });
648
+
649
+ matches.push(result);
650
+
651
+ if (result.winner === 'wizard-1')
652
+ {
653
+ wizard1Wins++;
654
+ }
655
+ else if (result.winner === 'wizard-2')
656
+ {
657
+ wizard2Wins++;
658
+ }
659
+ else
660
+ {
661
+ draws++;
662
+ }
663
+
664
+ // Swapped side: wizard2 on left, wizard1 on right
665
+ // skipHistory: swapped matches are only for scoring, not visual playback
666
+ const swapped = simulate(wizard2AI, wizard1AI, {
667
+ seed: options.seed,
668
+ maxTicks: options.maxTicks,
669
+ spawnDistance,
670
+ skipHistory: true,
671
+ });
672
+
673
+ // Don't push swapped match to matches array (it's only for scoring)
674
+ if (swapped.winner === 'wizard-1')
675
+ {
676
+ // wizard-1 in swapped match = wizard2 from original perspective
677
+ wizard2Wins++;
678
+ }
679
+ else if (swapped.winner === 'wizard-2')
680
+ {
681
+ wizard1Wins++;
682
+ }
683
+ else
684
+ {
685
+ draws++;
686
+ }
687
+ }
688
+
689
+ const winner: FightWinner = wizard1Wins > wizard2Wins ? 'wizard-1'
690
+ : wizard2Wins > wizard1Wins ? 'wizard-2'
691
+ : 'draw';
692
+
693
+ return {wizard1Wins, wizard2Wins, draws, winner, matches};
694
+ }
695
+
696
+ /**
697
+ * Run a full match simulation.
698
+ *
699
+ * @param options.skipHistory - When true, skips recording per-tick history snapshots.
700
+ * This dramatically improves performance (no deep cloning per tick) and is used
701
+ * by the optimizer and fight() scoring. The returned history array will be empty
702
+ * and finalState will still be populated.
703
+ */
704
+ export function simulate(
705
+ wizard1AI: WizardFunction,
706
+ wizard2AI: WizardFunction,
707
+ options: {
708
+ maxTicks?: number;
709
+ seed?: number;
710
+ spawnDistance?: number;
711
+ skipHistory?: boolean;
712
+ } = {},
713
+ ): SimulateResult
714
+ {
715
+ // Validate AI functions (Lesson #1)
716
+ if (typeof wizard1AI !== 'function')
717
+ {
718
+ throw new Error('wizard1AI must be a function');
719
+ }
720
+ if (typeof wizard2AI !== 'function')
721
+ {
722
+ throw new Error('wizard2AI must be a function');
723
+ }
724
+
725
+ // Sanitize options (Lesson #3)
726
+ const {maxTicks: rawMaxTicks = MATCH_DURATION, seed: rawSeed = Date.now(), spawnDistance: rawSpawnDistance, skipHistory = false} = options;
727
+
728
+ // Sanitize seed - ensure valid 32-bit integer
729
+ const seed = Number.isFinite(rawSeed) ? Math.floor(rawSeed) & 0x7FFFFFFF : Date.now() & 0x7FFFFFFF;
730
+
731
+ // Sanitize maxTicks - ensure positive integer with reasonable limit
732
+ const maxTicks = Number.isFinite(rawMaxTicks) && rawMaxTicks > 0
733
+ ? Math.min(Math.floor(rawMaxTicks), 100000) // Cap at 100k ticks (~16 min)
734
+ : MATCH_DURATION;
735
+
736
+ // Sanitize spawnDistance - must be positive and fit in arena
737
+ const spawnDistance = Number.isFinite(rawSpawnDistance) && rawSpawnDistance! > 0
738
+ ? Math.min(rawSpawnDistance!, Math.min(ARENA_WIDTH, ARENA_HEIGHT) - 100) // Leave room for wizards
739
+ : SPAWN_DISTANCE;
740
+
741
+ const config: GameConfig = Object.freeze({
742
+ arenaSize: Object.freeze({width: ARENA_WIDTH, height: ARENA_HEIGHT}),
743
+ tickRate: TICKS_PER_SECOND,
744
+ maxTicks,
745
+ });
746
+
747
+ resetAllHooks();
748
+
749
+ const initialState = createInitialState(seed, spawnDistance);
750
+ const history: GameState[] = skipHistory ? [] : [initialState];
751
+ let currentTick = 0;
752
+ let wizards: InternalWizardState[] = [
753
+ {
754
+ id: 'wizard-1',
755
+ position: {x: ARENA_WIDTH / 2 - spawnDistance / 2, y: ARENA_HEIGHT / 2},
756
+ rotation: 0,
757
+ health: WIZARD_HEALTH,
758
+ maxHealth: WIZARD_HEALTH,
759
+ state: 'idle',
760
+ blinkCooldown: 0,
761
+ velocity: {x: 0, y: 0},
762
+ damageDealt: 0,
763
+ damageTaken: 0,
764
+ lastHitTick: 0,
765
+ },
766
+ {
767
+ id: 'wizard-2',
768
+ position: {x: ARENA_WIDTH / 2 + spawnDistance / 2, y: ARENA_HEIGHT / 2},
769
+ rotation: 180,
770
+ health: WIZARD_HEALTH,
771
+ maxHealth: WIZARD_HEALTH,
772
+ state: 'idle',
773
+ blinkCooldown: 0,
774
+ velocity: {x: 0, y: 0},
775
+ damageDealt: 0,
776
+ damageTaken: 0,
777
+ lastHitTick: 0,
778
+ },
779
+ ];
780
+ let projectiles: ProjectileState[] = [];
781
+ const missileAIs = new Map<string, MissileAIFunction>();
782
+ const allErrors: BotError[] = [];
783
+
784
+ while (currentTick < maxTicks)
785
+ {
786
+ const result = tick(currentTick, wizard1AI, wizard2AI, config, wizards, projectiles, missileAIs, seed);
787
+ currentTick = result.nextTick;
788
+ wizards = result.wizards;
789
+ projectiles = result.projectiles;
790
+ if (result.errors.length > 0) allErrors.push(...result.errors);
791
+
792
+ if (!skipHistory)
793
+ {
794
+ // Deep-clone snapshot for history (needed for replay)
795
+ history.push(getPlayerState(0, wizards, projectiles, currentTick));
796
+ }
797
+
798
+ if (wizards[0]!.health <= 0 || wizards[1]!.health <= 0)
799
+ {
800
+ break;
801
+ }
802
+ }
803
+
804
+ let winner: MatchWinner = null;
805
+ if (wizards[0]!.health <= 0 && wizards[1]!.health <= 0)
806
+ {
807
+ winner = 'draw';
808
+ }
809
+ else if (wizards[1]!.health <= 0)
810
+ {
811
+ winner = 'wizard-1';
812
+ }
813
+ else if (wizards[0]!.health <= 0)
814
+ {
815
+ winner = 'wizard-2';
816
+ }
817
+
818
+ // Always create final state snapshot (needed for result)
819
+ const finalState = getPlayerState(0, wizards, projectiles, currentTick);
820
+
821
+ return {
822
+ winner,
823
+ ticks: currentTick,
824
+ finalState,
825
+ history: skipHistory ? [] : history,
826
+ errors: allErrors,
827
+ };
828
+ }