@vibemancer/core 1.0.7 → 1.0.9

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.
package/src/trace.ts CHANGED
@@ -1,490 +1,497 @@
1
- /**
2
- * VIBEMANCER - FIGHT TRACE
3
- *
4
- * Extracts a structured event log from a SimulateResult history.
5
- * Both bots' actions are tracked: state changes, missile launches with
6
- * full config, hits, damage, dodge proximity, movement patterns.
7
- *
8
- * Used by:
9
- * - vibemancer trace (CLI debug command)
10
- * - scripts/fight-trace.ts (internal diagnostic)
11
- * - User tests that want event-level analysis
12
- */
13
-
14
- import type {GameState, ProjectileState, SimEvent} from './types.js';
15
- import type {SimulateResult, BotError} from './engine/simulation.js';
16
- import {distanceTo} from './utils/distance.js';
17
-
18
- // ─── Types ───────────────────────────────────────────────────────────────────
19
-
20
- export interface TraceEvent
21
- {
22
- tick: number;
23
- /** 'W1' = wizard-1, 'W2' = wizard-2 */
24
- actor: 'W1' | 'W2';
25
- type: TraceEventType;
26
- detail: string;
27
- }
28
-
29
- export type TraceEventType =
30
- | 'STATE' // State transition (idle→casting, etc.)
31
- | 'FIRE' // Missile launched (includes config + range)
32
- | 'HIT' // Damage dealt to opponent
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)
39
- | 'DODGE_START' // Enemy missile enters 150u proximity
40
- | 'DODGE_CLOSE' // Enemy missile enters 50u proximity
41
- | 'MOVE' // Movement summary (every 100 ticks)
42
- | 'ERROR' // Bot or missile AI threw a runtime error
43
- | 'WARNING'; // Common mistake detected (move/blink confusion, etc.)
44
-
45
- export interface TraceSummary
46
- {
47
- winner: string;
48
- ticks: number;
49
- w1Name: string;
50
- w2Name: string;
51
- w1FinalHp: number;
52
- w2FinalHp: number;
53
- w1: TraceBotSummary;
54
- w2: TraceBotSummary;
55
- }
56
-
57
- export interface TraceBotSummary
58
- {
59
- missilesLaunched: number;
60
- hits: number;
61
- damageDealt: number;
62
- damageReceived: number;
63
- shields: number;
64
- blinks: number;
65
- dodgeEncounters: number;
66
- causeOfDeath: 'missile' | 'lava' | 'alive' | 'timeout';
67
- movement: {strafe: number; approach: number; retreat: number; still: number};
68
- }
69
-
70
- // ─── Event Extraction ────────────────────────────────────────────────────────
71
-
72
- /**
73
- * Extract a structured event log from simulation history.
74
- * Tracks both bots' state changes, missile launches (with full config + range),
75
- * hits, damage, dodge proximity, movement patterns, and runtime errors.
76
- */
77
- export function extractTraceEvents(history: GameState[], errors?: BotError[]): TraceEvent[]
78
- {
79
- const events: TraceEvent[] = [];
80
-
81
- let prevW1State = '';
82
- let prevW2State = '';
83
- let prevW1Health = history[0]?.health ?? 60;
84
- let prevW2Health = history[0]?.enemies[0]?.health ?? 60;
85
- let prevW1Spell = '';
86
- let prevW2Spell = '';
87
- const prevProjectileIds = new Set<string>();
88
-
89
- for (let i = 0; i < history.length; i++)
90
- {
91
- const state = history[i]!;
92
- const tick = state.tick;
93
- const enemy = state.enemies[0]!;
94
- const distance = distanceTo(state.position, enemy.position);
95
-
96
- // --- W1 state transitions ---
97
- const w1State = state.state;
98
- const w1Spell = state.castingSpell ?? state.channelingSpell ?? '';
99
- if (w1State !== prevW1State || w1Spell !== prevW1Spell)
100
- {
101
- let detail = `${prevW1State || '?'}→${w1State}`;
102
- if (w1Spell) detail += ` (${w1Spell})`;
103
- if (w1State === 'casting' && state.castDuration) detail += ` [${state.castDuration}t]`;
104
- detail += ` dist=${distance.toFixed(0)}`;
105
- events.push({tick, actor: 'W1', type: 'STATE', detail});
106
- }
107
-
108
- // --- W2 state transitions ---
109
- const w2State = enemy.state;
110
- const w2Spell = enemy.castingSpell ?? enemy.channelingSpell ?? '';
111
- if (w2State !== prevW2State || w2Spell !== prevW2Spell)
112
- {
113
- let detail = `${prevW2State || '?'}→${w2State}`;
114
- if (w2Spell) detail += ` (${w2Spell})`;
115
- if (w2State === 'casting' && enemy.castDuration) detail += ` [${enemy.castDuration}t]`;
116
- detail += ` dist=${distance.toFixed(0)}`;
117
- events.push({tick, actor: 'W2', type: 'STATE', detail});
118
- }
119
-
120
- // --- Damage events ---
121
- if (enemy.health < prevW2Health)
122
- {
123
- const dmg = prevW2Health - enemy.health;
124
- events.push({
125
- tick, actor: 'W1', type: 'HIT',
126
- detail: `dealt ${dmg.toFixed(1)} → enemy HP=${enemy.health.toFixed(1)} dist=${distance.toFixed(0)}`,
127
- });
128
- }
129
- if (state.health < prevW1Health)
130
- {
131
- const dmg = prevW1Health - state.health;
132
- const stateTag = state.state === 'channeling' ? ' (SHIELDED)'
133
- : state.state === 'casting' ? ` (CASTING ${state.castingSpell})`
134
- : state.state === 'gcd_locked' ? ' (GCD)'
135
- : ` (${state.state})`;
136
- events.push({
137
- tick, actor: 'W1', type: 'HURT',
138
- detail: `took ${dmg.toFixed(1)} → HP=${state.health.toFixed(1)}${stateTag} dist=${distance.toFixed(0)}`,
139
- });
140
- }
141
- // W2 damage taken = W1 damage dealt (already covered above as HIT)
142
- // W2 damage dealt = W1 damage taken (already covered above as HURT)
143
-
144
- // --- Missile launches (with full config) ---
145
- const currentIds = new Set<string>();
146
- for (const proj of state.projectiles)
147
- {
148
- currentIds.add(proj.id);
149
- if (!prevProjectileIds.has(proj.id))
150
- {
151
- const owner: 'W1' | 'W2' = proj.ownerId === 'wizard-1' ? 'W1' : 'W2';
152
- const range = (proj.speed * proj.remainingTicks).toFixed(0);
153
- events.push({
154
- tick, actor: owner, type: 'FIRE',
155
- detail: `dmg=${proj.damage} spd=${proj.speed} turn=${proj.turnRate} life=${proj.remainingTicks} range=${range} dist=${distance.toFixed(0)}`,
156
- });
157
- // Warn if missile range < distance (will expire before reaching target)
158
- if (proj.speed * proj.remainingTicks < distance * 0.8 && proj.turnRate >= 0)
159
- {
160
- events.push({
161
- tick, actor: owner, type: 'WARNING',
162
- detail: `missile range (${range}) may be too short for distance (${distance.toFixed(0)}) — consider increasing duration`,
163
- });
164
- }
165
- }
166
- }
167
- prevProjectileIds.clear();
168
- for (const id of currentIds) prevProjectileIds.add(id);
169
-
170
- // --- Dodge proximity events (enemy missiles approaching W1) ---
171
- if (i > 0)
172
- {
173
- const speed = Math.sqrt(state.velocity.x ** 2 + state.velocity.y ** 2);
174
- for (const proj of state.projectiles)
175
- {
176
- if (proj.ownerId === 'wizard-1') continue;
177
- const missileDistance = distanceTo(state.position, proj.position);
178
- const prevProj = history[i - 1]!.projectiles.find((p) => p.id === proj.id);
179
- const prevMissileDist = prevProj ? distanceTo(history[i - 1]!.position, prevProj.position) : Infinity;
180
-
181
- if (missileDistance <= 150 && prevMissileDist > 150)
182
- {
183
- events.push({
184
- tick, actor: 'W1', type: 'DODGE_START',
185
- detail: formatDodgeDetail(state, proj, missileDistance, speed),
186
- });
187
- }
188
- if (missileDistance <= 50 && prevMissileDist > 50)
189
- {
190
- events.push({
191
- tick, actor: 'W1', type: 'DODGE_CLOSE',
192
- detail: formatDodgeDetail(state, proj, missileDistance, speed),
193
- });
194
- }
195
- }
196
- }
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
-
233
- // --- Movement summary every 100 ticks ---
234
- if (tick > 0 && tick % 100 === 0)
235
- {
236
- const speed = Math.sqrt(state.velocity.x ** 2 + state.velocity.y ** 2);
237
- const moveType = classifyMovement(state, enemy, speed);
238
- const incomingMissiles = state.projectiles.filter((p) => p.ownerId !== 'wizard-1').length;
239
- events.push({
240
- tick, actor: 'W1', type: 'MOVE',
241
- detail: `${moveType} spd=${speed.toFixed(2)} dist=${distance.toFixed(0)} missiles=${incomingMissiles} ${state.state} hp=${state.health.toFixed(1)}`,
242
- });
243
- }
244
-
245
- prevW1State = w1State;
246
- prevW2State = w2State;
247
- prevW1Health = state.health;
248
- prevW2Health = enemy.health;
249
- prevW1Spell = w1Spell;
250
- prevW2Spell = w2Spell;
251
- }
252
-
253
- // --- Inject runtime errors as ERROR events ---
254
- if (errors && errors.length > 0)
255
- {
256
- for (const err of errors)
257
- {
258
- const actor: 'W1' | 'W2' = err.entityId === 'wizard-1' ? 'W1'
259
- : err.entityId === 'wizard-2' ? 'W2'
260
- : err.entityId.startsWith('missile') ? (/* missile errors attributed to owner */ 'W1') : 'W1';
261
- events.push({
262
- tick: err.tick,
263
- actor,
264
- type: 'ERROR',
265
- detail: err.message,
266
- });
267
- }
268
- // Re-sort by tick so errors appear in chronological order
269
- events.sort((a, b) => a.tick - b.tick || (a.type === 'ERROR' ? 1 : 0));
270
- }
271
-
272
- return events;
273
- }
274
-
275
- // ─── Summary ─────────────────────────────────────────────────────────────────
276
-
277
- /**
278
- * Generate a summary from trace events and the simulation result.
279
- */
280
- export function summarizeTrace(
281
- events: TraceEvent[],
282
- result: SimulateResult,
283
- w1Name: string,
284
- w2Name: string,
285
- ): TraceSummary
286
- {
287
- const final = result.history[result.history.length - 1];
288
-
289
- function botSummary(actor: 'W1' | 'W2'): TraceBotSummary
290
- {
291
- const myEvents = events.filter((e) => e.actor === actor);
292
- const moveEvents = myEvents.filter((e) => e.type === 'MOVE');
293
- return {
294
- missilesLaunched: myEvents.filter((e) => e.type === 'FIRE').length,
295
- hits: myEvents.filter((e) => e.type === 'HIT').length,
296
- damageDealt: myEvents.filter((e) => e.type === 'HIT').reduce((sum, e) =>
297
- {
298
- const m = e.detail.match(/dealt ([\d.]+)/);
299
- return sum + (m ? parseFloat(m[1]!) : 0);
300
- }, 0),
301
- damageReceived: myEvents.filter((e) => e.type === 'HURT').reduce((sum, e) =>
302
- {
303
- const m = e.detail.match(/took ([\d.]+)/);
304
- return sum + (m ? parseFloat(m[1]!) : 0);
305
- }, 0),
306
- shields: myEvents.filter((e) => e.type === 'STATE' && e.detail.includes('shield')).length,
307
- blinks: myEvents.filter((e) => e.type === 'STATE' && e.detail.includes('blink')).length,
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',
313
- movement: {
314
- strafe: moveEvents.filter((e) => e.detail.startsWith('STRAFE')).length,
315
- approach: moveEvents.filter((e) => e.detail.startsWith('APPROACH')).length,
316
- retreat: moveEvents.filter((e) => e.detail.startsWith('RETREAT')).length,
317
- still: moveEvents.filter((e) => e.detail.startsWith('STILL')).length,
318
- },
319
- };
320
- }
321
-
322
- const winnerName = result.winner === 'wizard-1' ? w1Name
323
- : result.winner === 'wizard-2' ? w2Name
324
- : result.winner === 'draw' ? 'draw'
325
- : 'timeout';
326
-
327
- return {
328
- winner: winnerName,
329
- ticks: result.ticks,
330
- w1Name,
331
- w2Name,
332
- w1FinalHp: final?.health ?? 0,
333
- w2FinalHp: final?.enemies[0]?.health ?? 0,
334
- w1: botSummary('W1'),
335
- w2: botSummary('W2'),
336
- };
337
- }
338
-
339
- // ─── Formatting ──────────────────────────────────────────────────────────────
340
-
341
- /** Format trace events as a human-readable string. */
342
- export function formatTraceEvents(events: TraceEvent[]): string
343
- {
344
- return events.map((e) =>
345
- {
346
- const t = String(e.tick).padStart(5);
347
- const a = e.actor.padEnd(2);
348
- const ty = e.type.padEnd(12);
349
- return ` T${t} ${a} ${ty} ${e.detail}`;
350
- }).join('\n');
351
- }
352
-
353
- /** Format a full trace summary as a human-readable string. */
354
- export function formatTraceSummary(summary: TraceSummary): string
355
- {
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
- }
365
- const lines = [
366
- ` Winner: ${summary.winner} @ T${summary.ticks}`,
367
- ` Final HP: ${w1Name}=${summary.w1FinalHp.toFixed(1)}, ${w2Name}=${summary.w2FinalHp.toFixed(1)}`,
368
- '',
369
- formatBot(w1Name, w1),
370
- formatBot(w2Name, w2),
371
- '',
372
- ` ${w1Name} movement: ${w1.movement.approach} approach, ${w1.movement.strafe} strafe, ${w1.movement.retreat} retreat, ${w1.movement.still} still | ${w1.dodgeEncounters} dodge encounters`,
373
- ];
374
- return lines.join('\n');
375
- }
376
-
377
- /**
378
- * Generate diagnostic tips based on trace analysis.
379
- * Identifies common problems and suggests fixes.
380
- * Returns an array of human-readable tips (empty if no issues found).
381
- */
382
- export function diagnoseTrace(events: TraceEvent[], summary: TraceSummary): string[]
383
- {
384
- const tips: string[] = [];
385
- const {w1} = summary;
386
-
387
- // Check for runtime errors
388
- const errors = events.filter((e) => e.actor === 'W1' && e.type === 'ERROR');
389
- if (errors.length > 0)
390
- {
391
- const firstErr = errors[0]!;
392
- tips.push(`BOT ERROR: Your bot threw "${firstErr.detail}" on tick ${firstErr.tick}. The bot does nothing when it throws. Fix this first.`);
393
- if (errors.length > 1) tips.push(` (${errors.length} total errors — likely the same bug repeating every tick)`);
394
- }
395
-
396
- // Check for range warnings
397
- const rangeWarnings = events.filter((e) => e.actor === 'W1' && e.type === 'WARNING' && e.detail.includes('range'));
398
- if (rangeWarnings.length > 0)
399
- {
400
- tips.push(`RANGE: ${rangeWarnings.length} of your missiles had range shorter than target distance. Increase missile duration or get closer before firing.`);
401
- }
402
-
403
- // No missiles fired
404
- if (w1.missilesLaunched === 0 && errors.length === 0)
405
- {
406
- tips.push('NO OFFENSE: Your bot fired 0 missiles. Check that you return missile() when useTicksUntilReady() === 0.');
407
- }
408
-
409
- // Very low hit rate
410
- if (w1.missilesLaunched >= 3 && w1.hits === 0)
411
- {
412
- tips.push(`ACCURACY: Fired ${w1.missilesLaunched} missiles but 0 hit. Consider: more homing (higher turnRate), or use getLeadPosition() for straight missiles, or get closer.`);
413
- }
414
- else if (w1.missilesLaunched >= 5 && w1.hits / w1.missilesLaunched < 0.25)
415
- {
416
- tips.push(`LOW ACCURACY: Only ${((w1.hits / w1.missilesLaunched) * 100).toFixed(0)}% hit rate. Try increasing turnRate for more homing, or use getLeadPosition() for prediction.`);
417
- }
418
-
419
- // No shields against incoming damage
420
- if (w1.damageReceived > 20 && w1.shields === 0)
421
- {
422
- tips.push(`NO DEFENSE: Took ${w1.damageReceived.toFixed(0)} damage with 0 shields. Use useThreats() to detect incoming missiles and shield() when you can't dodge.`);
423
- }
424
-
425
- // Too much idle time
426
- if (summary.ticks > 500)
427
- {
428
- const idleMovements = w1.movement.still;
429
- const totalMovements = idleMovements + w1.movement.approach + w1.movement.strafe + w1.movement.retreat;
430
- if (totalMovements > 0 && idleMovements / totalMovements > 0.5)
431
- {
432
- tips.push(`IDLE: Your bot was stationary ${((idleMovements / totalMovements) * 100).toFixed(0)}% of the time. Move to dodge missiles and close distance.`);
433
- }
434
- }
435
-
436
- return tips;
437
- }
438
-
439
- /** Format diagnostic tips as a human-readable string. */
440
- export function formatDiagnosis(tips: string[]): string
441
- {
442
- if (tips.length === 0) return '';
443
- const lines = [' Diagnosis:'];
444
- for (const tip of tips)
445
- {
446
- lines.push(` - ${tip}`);
447
- }
448
- return lines.join('\n');
449
- }
450
-
451
- // ─── Helpers ─────────────────────────────────────────────────────────────────
452
-
453
- function formatDodgeDetail(state: GameState, proj: ProjectileState, missileDistance: number, speed: number): string
454
- {
455
- const angle = dodgeAngle(state.velocity, proj.rotation);
456
- return `missile at ${missileDistance.toFixed(0)}u rot=${proj.rotation.toFixed(0)}° dodge_angle=${angle.toFixed(0)}° ` +
457
- `spd=${speed.toFixed(2)} ${state.state}`;
458
- }
459
-
460
- function dodgeAngle(wizardVelocity: {x: number; y: number}, missileRotation: number): number
461
- {
462
- const speed = Math.sqrt(wizardVelocity.x ** 2 + wizardVelocity.y ** 2);
463
- if (speed < 0.01) return 0;
464
- const missileRad = missileRotation * (Math.PI / 180);
465
- const dot = Math.cos(missileRad) * (wizardVelocity.x / speed) + Math.sin(missileRad) * (wizardVelocity.y / speed);
466
- return Math.acos(Math.max(-1, Math.min(1, Math.abs(dot)))) * (180 / Math.PI);
467
- }
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
-
476
- function classifyMovement(state: GameState, enemy: GameState['enemies'][0], speed: number): string
477
- {
478
- if (speed < 0.1) return 'STILL';
479
- const toEnemyAngle = Math.atan2(enemy.position.y - state.position.y, enemy.position.x - state.position.x);
480
- const moveAngle = Math.atan2(state.velocity.y, state.velocity.x);
481
- let angleDiff = ((moveAngle - toEnemyAngle) * 180 / Math.PI) % 360;
482
- if (angleDiff > 180) angleDiff -= 360;
483
- if (angleDiff < -180) angleDiff += 360;
484
- const absAngle = Math.abs(angleDiff);
485
- if (absAngle < 30) return 'APPROACH';
486
- if (absAngle > 150) return 'RETREAT';
487
- if (absAngle >= 60 && absAngle <= 120) return 'STRAFE';
488
- if (absAngle < 60) return 'APPROACH+STRAFE';
489
- return 'RETREAT+STRAFE';
490
- }
1
+ /**
2
+ * VIBEMANCER - FIGHT TRACE
3
+ *
4
+ * Extracts a structured event log from a SimulateResult history.
5
+ * Both bots' actions are tracked: state changes, missile launches with
6
+ * full config, hits, damage, dodge proximity, movement patterns.
7
+ *
8
+ * Used by:
9
+ * - vibemancer trace (CLI debug command)
10
+ * - scripts/fight-trace.ts (internal diagnostic)
11
+ * - User tests that want event-level analysis
12
+ */
13
+
14
+ import type {GameState, ProjectileState, SimEvent} from './types.js';
15
+ import type {SimulateResult, BotError} from './engine/simulation.js';
16
+ import {distanceTo} from './utils/distance.js';
17
+
18
+ // ─── Types ───────────────────────────────────────────────────────────────────
19
+
20
+ export interface TraceEvent
21
+ {
22
+ tick: number;
23
+ /** 'W1' = wizard-1, 'W2' = wizard-2 */
24
+ actor: 'W1' | 'W2';
25
+ type: TraceEventType;
26
+ detail: string;
27
+ }
28
+
29
+ export type TraceEventType =
30
+ | 'STATE' // State transition (idle→casting, etc.)
31
+ | 'FIRE' // Missile launched (includes config + range)
32
+ | 'HIT' // Damage dealt to opponent
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)
39
+ | 'DODGE_START' // Enemy missile enters 150u proximity
40
+ | 'DODGE_CLOSE' // Enemy missile enters 50u proximity
41
+ | 'MOVE' // Movement summary (every 100 ticks)
42
+ | 'ERROR' // Bot or missile AI threw a runtime error
43
+ | 'WARNING'; // Common mistake detected (move/blink confusion, etc.)
44
+
45
+ export interface TraceSummary
46
+ {
47
+ winner: string;
48
+ ticks: number;
49
+ w1Name: string;
50
+ w2Name: string;
51
+ w1FinalHp: number;
52
+ w2FinalHp: number;
53
+ w1: TraceBotSummary;
54
+ w2: TraceBotSummary;
55
+ }
56
+
57
+ export interface TraceBotSummary
58
+ {
59
+ missilesLaunched: number;
60
+ hits: number;
61
+ damageDealt: number;
62
+ damageReceived: number;
63
+ shields: number;
64
+ blinks: number;
65
+ dodgeEncounters: number;
66
+ causeOfDeath: 'missile' | 'lava' | 'alive' | 'timeout';
67
+ movement: {strafe: number; approach: number; retreat: number; still: number};
68
+ }
69
+
70
+ // ─── Event Extraction ────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Extract a structured event log from simulation history.
74
+ * Tracks both bots' state changes, missile launches (with full config + range),
75
+ * hits, damage, dodge proximity, movement patterns, and runtime errors.
76
+ */
77
+ export function extractTraceEvents(history: GameState[], errors?: BotError[]): TraceEvent[]
78
+ {
79
+ const events: TraceEvent[] = [];
80
+
81
+ let prevW1State = '';
82
+ let prevW2State = '';
83
+ let prevW1Health = history[0]?.health ?? 60;
84
+ let prevW2Health = history[0]?.enemies[0]?.health ?? 60;
85
+ let prevW1Spell = '';
86
+ let prevW2Spell = '';
87
+ const prevProjectileIds = new Set<string>();
88
+
89
+ for (let i = 0; i < history.length; i++)
90
+ {
91
+ const state = history[i]!;
92
+ const tick = state.tick;
93
+ const enemy = state.enemies[0]!;
94
+ const distance = distanceTo(state.position, enemy.position);
95
+
96
+ // --- W1 state transitions ---
97
+ const w1State = state.state;
98
+ const w1Spell = state.castingSpell ?? state.channelingSpell ?? '';
99
+ if (w1State !== prevW1State || w1Spell !== prevW1Spell)
100
+ {
101
+ let detail = `${prevW1State || '?'}→${w1State}`;
102
+ if (w1Spell) detail += ` (${w1Spell})`;
103
+ if (w1State === 'casting' && state.castDuration) detail += ` [${state.castDuration}t]`;
104
+ detail += ` dist=${distance.toFixed(0)}`;
105
+ events.push({tick, actor: 'W1', type: 'STATE', detail});
106
+ }
107
+
108
+ // --- W2 state transitions ---
109
+ const w2State = enemy.state;
110
+ const w2Spell = enemy.castingSpell ?? enemy.channelingSpell ?? '';
111
+ if (w2State !== prevW2State || w2Spell !== prevW2Spell)
112
+ {
113
+ let detail = `${prevW2State || '?'}→${w2State}`;
114
+ if (w2Spell) detail += ` (${w2Spell})`;
115
+ if (w2State === 'casting' && enemy.castDuration) detail += ` [${enemy.castDuration}t]`;
116
+ detail += ` dist=${distance.toFixed(0)}`;
117
+ events.push({tick, actor: 'W2', type: 'STATE', detail});
118
+ }
119
+
120
+ // --- Damage events ---
121
+ if (enemy.health < prevW2Health)
122
+ {
123
+ const dmg = prevW2Health - enemy.health;
124
+ events.push({
125
+ tick, actor: 'W1', type: 'HIT',
126
+ detail: `dealt ${dmg.toFixed(1)} → enemy HP=${enemy.health.toFixed(1)} dist=${distance.toFixed(0)}`,
127
+ });
128
+ }
129
+ if (state.health < prevW1Health)
130
+ {
131
+ const dmg = prevW1Health - state.health;
132
+ const stateTag = state.state === 'channeling' ? ' (SHIELDED)'
133
+ : state.state === 'casting' ? ` (CASTING ${state.castingSpell})`
134
+ : state.state === 'gcd_locked' ? ' (GCD)'
135
+ : ` (${state.state})`;
136
+ events.push({
137
+ tick, actor: 'W1', type: 'HURT',
138
+ detail: `took ${dmg.toFixed(1)} → HP=${state.health.toFixed(1)}${stateTag} dist=${distance.toFixed(0)}`,
139
+ });
140
+ }
141
+ // W2 damage taken = W1 damage dealt (already covered above as HIT)
142
+ // W2 damage dealt = W1 damage taken (already covered above as HURT)
143
+
144
+ // --- Missile launches (with full config) ---
145
+ const currentIds = new Set<string>();
146
+ for (const proj of state.projectiles)
147
+ {
148
+ currentIds.add(proj.id);
149
+ if (!prevProjectileIds.has(proj.id))
150
+ {
151
+ const owner: 'W1' | 'W2' = proj.ownerId === 'wizard-1' ? 'W1' : 'W2';
152
+ const range = (proj.speed * proj.remainingTicks).toFixed(0);
153
+ events.push({
154
+ tick, actor: owner, type: 'FIRE',
155
+ detail: `dmg=${proj.damage} spd=${proj.speed} turn=${proj.turnRate} life=${proj.remainingTicks} range=${range} dist=${distance.toFixed(0)}`,
156
+ });
157
+ // Warn if missile range < distance (will expire before reaching target)
158
+ if (proj.speed * proj.remainingTicks < distance * 0.8 && proj.turnRate >= 0)
159
+ {
160
+ events.push({
161
+ tick, actor: owner, type: 'WARNING',
162
+ detail: `missile range (${range}) may be too short for distance (${distance.toFixed(0)}) — consider increasing duration`,
163
+ });
164
+ }
165
+ }
166
+ }
167
+ prevProjectileIds.clear();
168
+ for (const id of currentIds) prevProjectileIds.add(id);
169
+
170
+ // --- Dodge proximity events (enemy missiles approaching W1) ---
171
+ if (i > 0)
172
+ {
173
+ const speed = Math.sqrt(state.velocity.x ** 2 + state.velocity.y ** 2);
174
+ for (const proj of state.projectiles)
175
+ {
176
+ if (proj.ownerId === 'wizard-1') continue;
177
+ const missileDistance = distanceTo(state.position, proj.position);
178
+ const prevProj = history[i - 1]!.projectiles.find((p) => p.id === proj.id);
179
+ const prevMissileDist = prevProj ? distanceTo(history[i - 1]!.position, prevProj.position) : Infinity;
180
+
181
+ if (missileDistance <= 150 && prevMissileDist > 150)
182
+ {
183
+ events.push({
184
+ tick, actor: 'W1', type: 'DODGE_START',
185
+ detail: formatDodgeDetail(state, proj, missileDistance, speed),
186
+ });
187
+ }
188
+ if (missileDistance <= 50 && prevMissileDist > 50)
189
+ {
190
+ events.push({
191
+ tick, actor: 'W1', type: 'DODGE_CLOSE',
192
+ detail: formatDodgeDetail(state, proj, missileDistance, speed),
193
+ });
194
+ }
195
+ }
196
+ }
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
+
233
+ // --- Movement summary every 100 ticks ---
234
+ if (tick > 0 && tick % 100 === 0)
235
+ {
236
+ const speed = Math.sqrt(state.velocity.x ** 2 + state.velocity.y ** 2);
237
+ const moveType = classifyMovement(state, enemy, speed);
238
+ const incomingMissiles = state.projectiles.filter((p) => p.ownerId !== 'wizard-1').length;
239
+ events.push({
240
+ tick, actor: 'W1', type: 'MOVE',
241
+ detail: `${moveType} spd=${speed.toFixed(2)} dist=${distance.toFixed(0)} missiles=${incomingMissiles} ${state.state} hp=${state.health.toFixed(1)}`,
242
+ });
243
+ }
244
+
245
+ prevW1State = w1State;
246
+ prevW2State = w2State;
247
+ prevW1Health = state.health;
248
+ prevW2Health = enemy.health;
249
+ prevW1Spell = w1Spell;
250
+ prevW2Spell = w2Spell;
251
+ }
252
+
253
+ // --- Inject runtime errors as ERROR events ---
254
+ if (errors && errors.length > 0)
255
+ {
256
+ for (const err of errors)
257
+ {
258
+ // A missile's throw is recorded against the PROJECTILE, and projectile ids are built
259
+ // as `missile-${wizard.id}-${tick}`, so the owner is in the middle of the string.
260
+ // This branch existed with the comment "missile errors attributed to owner" and
261
+ // both arms returned 'W1', so every missile fault in the game was blamed on wizard
262
+ // 1 — telling an idle bot it had thrown 1,333 times for its opponent's code.
263
+ const missileOwner = /^missile-(wizard-[12])-/.exec(err.entityId);
264
+ const actor: 'W1' | 'W2' = err.entityId === 'wizard-1' ? 'W1'
265
+ : err.entityId === 'wizard-2' ? 'W2'
266
+ : missileOwner ? (missileOwner[1] === 'wizard-2' ? 'W2' : 'W1')
267
+ : 'W1';
268
+ events.push({
269
+ tick: err.tick,
270
+ actor,
271
+ type: 'ERROR',
272
+ detail: err.message,
273
+ });
274
+ }
275
+ // Re-sort by tick so errors appear in chronological order
276
+ events.sort((a, b) => a.tick - b.tick || (a.type === 'ERROR' ? 1 : 0));
277
+ }
278
+
279
+ return events;
280
+ }
281
+
282
+ // ─── Summary ─────────────────────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Generate a summary from trace events and the simulation result.
286
+ */
287
+ export function summarizeTrace(
288
+ events: TraceEvent[],
289
+ result: SimulateResult,
290
+ w1Name: string,
291
+ w2Name: string,
292
+ ): TraceSummary
293
+ {
294
+ const final = result.history[result.history.length - 1];
295
+
296
+ function botSummary(actor: 'W1' | 'W2'): TraceBotSummary
297
+ {
298
+ const myEvents = events.filter((e) => e.actor === actor);
299
+ const moveEvents = myEvents.filter((e) => e.type === 'MOVE');
300
+ return {
301
+ missilesLaunched: myEvents.filter((e) => e.type === 'FIRE').length,
302
+ hits: myEvents.filter((e) => e.type === 'HIT').length,
303
+ damageDealt: myEvents.filter((e) => e.type === 'HIT').reduce((sum, e) =>
304
+ {
305
+ const m = e.detail.match(/dealt ([\d.]+)/);
306
+ return sum + (m ? parseFloat(m[1]!) : 0);
307
+ }, 0),
308
+ damageReceived: myEvents.filter((e) => e.type === 'HURT').reduce((sum, e) =>
309
+ {
310
+ const m = e.detail.match(/took ([\d.]+)/);
311
+ return sum + (m ? parseFloat(m[1]!) : 0);
312
+ }, 0),
313
+ shields: myEvents.filter((e) => e.type === 'STATE' && e.detail.includes('shield')).length,
314
+ blinks: myEvents.filter((e) => e.type === 'STATE' && e.detail.includes('blink')).length,
315
+ dodgeEncounters: myEvents.filter((e) => e.type.startsWith('DODGE')).length,
316
+ causeOfDeath: myEvents.some((e) => e.type === 'LAVA_DEATH') ? 'lava'
317
+ : myEvents.some((e) => e.type === 'DEATH') ? 'missile'
318
+ : (final && ((actor === 'W1' ? final.health : final.enemies[0]?.health ?? 0) <= 0)) ? 'missile'
319
+ : result.winner === null ? 'timeout' : 'alive',
320
+ movement: {
321
+ strafe: moveEvents.filter((e) => e.detail.startsWith('STRAFE')).length,
322
+ approach: moveEvents.filter((e) => e.detail.startsWith('APPROACH')).length,
323
+ retreat: moveEvents.filter((e) => e.detail.startsWith('RETREAT')).length,
324
+ still: moveEvents.filter((e) => e.detail.startsWith('STILL')).length,
325
+ },
326
+ };
327
+ }
328
+
329
+ const winnerName = result.winner === 'wizard-1' ? w1Name
330
+ : result.winner === 'wizard-2' ? w2Name
331
+ : result.winner === 'draw' ? 'draw'
332
+ : 'timeout';
333
+
334
+ return {
335
+ winner: winnerName,
336
+ ticks: result.ticks,
337
+ w1Name,
338
+ w2Name,
339
+ w1FinalHp: final?.health ?? 0,
340
+ w2FinalHp: final?.enemies[0]?.health ?? 0,
341
+ w1: botSummary('W1'),
342
+ w2: botSummary('W2'),
343
+ };
344
+ }
345
+
346
+ // ─── Formatting ──────────────────────────────────────────────────────────────
347
+
348
+ /** Format trace events as a human-readable string. */
349
+ export function formatTraceEvents(events: TraceEvent[]): string
350
+ {
351
+ return events.map((e) =>
352
+ {
353
+ const t = String(e.tick).padStart(5);
354
+ const a = e.actor.padEnd(2);
355
+ const ty = e.type.padEnd(12);
356
+ return ` T${t} ${a} ${ty} ${e.detail}`;
357
+ }).join('\n');
358
+ }
359
+
360
+ /** Format a full trace summary as a human-readable string. */
361
+ export function formatTraceSummary(summary: TraceSummary): string
362
+ {
363
+ const {w1Name, w2Name, w1, w2} = summary;
364
+ function formatBot(name: string, b: TraceBotSummary): string
365
+ {
366
+ const hitPct = b.missilesLaunched > 0 ? ((b.hits / b.missilesLaunched) * 100).toFixed(0) : 'N/A';
367
+ const death = b.causeOfDeath === 'lava' ? ' [LAVA DEATH]'
368
+ : b.causeOfDeath === 'missile' ? ' [KILLED]'
369
+ : b.causeOfDeath === 'timeout' ? '' : '';
370
+ 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}`;
371
+ }
372
+ const lines = [
373
+ ` Winner: ${summary.winner} @ T${summary.ticks}`,
374
+ ` Final HP: ${w1Name}=${summary.w1FinalHp.toFixed(1)}, ${w2Name}=${summary.w2FinalHp.toFixed(1)}`,
375
+ '',
376
+ formatBot(w1Name, w1),
377
+ formatBot(w2Name, w2),
378
+ '',
379
+ ` ${w1Name} movement: ${w1.movement.approach} approach, ${w1.movement.strafe} strafe, ${w1.movement.retreat} retreat, ${w1.movement.still} still | ${w1.dodgeEncounters} dodge encounters`,
380
+ ];
381
+ return lines.join('\n');
382
+ }
383
+
384
+ /**
385
+ * Generate diagnostic tips based on trace analysis.
386
+ * Identifies common problems and suggests fixes.
387
+ * Returns an array of human-readable tips (empty if no issues found).
388
+ */
389
+ export function diagnoseTrace(events: TraceEvent[], summary: TraceSummary): string[]
390
+ {
391
+ const tips: string[] = [];
392
+ const {w1} = summary;
393
+
394
+ // Check for runtime errors
395
+ const errors = events.filter((e) => e.actor === 'W1' && e.type === 'ERROR');
396
+ if (errors.length > 0)
397
+ {
398
+ const firstErr = errors[0]!;
399
+ tips.push(`BOT ERROR: Your bot threw "${firstErr.detail}" on tick ${firstErr.tick}. The bot does nothing when it throws. Fix this first.`);
400
+ if (errors.length > 1) tips.push(` (${errors.length} total errors likely the same bug repeating every tick)`);
401
+ }
402
+
403
+ // Check for range warnings
404
+ const rangeWarnings = events.filter((e) => e.actor === 'W1' && e.type === 'WARNING' && e.detail.includes('range'));
405
+ if (rangeWarnings.length > 0)
406
+ {
407
+ tips.push(`RANGE: ${rangeWarnings.length} of your missiles had range shorter than target distance. Increase missile duration or get closer before firing.`);
408
+ }
409
+
410
+ // No missiles fired
411
+ if (w1.missilesLaunched === 0 && errors.length === 0)
412
+ {
413
+ tips.push('NO OFFENSE: Your bot fired 0 missiles. Check that you return missile() when useTicksUntilReady() === 0.');
414
+ }
415
+
416
+ // Very low hit rate
417
+ if (w1.missilesLaunched >= 3 && w1.hits === 0)
418
+ {
419
+ tips.push(`ACCURACY: Fired ${w1.missilesLaunched} missiles but 0 hit. Consider: more homing (higher turnRate), or use getLeadPosition() for straight missiles, or get closer.`);
420
+ }
421
+ else if (w1.missilesLaunched >= 5 && w1.hits / w1.missilesLaunched < 0.25)
422
+ {
423
+ tips.push(`LOW ACCURACY: Only ${((w1.hits / w1.missilesLaunched) * 100).toFixed(0)}% hit rate. Try increasing turnRate for more homing, or use getLeadPosition() for prediction.`);
424
+ }
425
+
426
+ // No shields against incoming damage
427
+ if (w1.damageReceived > 20 && w1.shields === 0)
428
+ {
429
+ tips.push(`NO DEFENSE: Took ${w1.damageReceived.toFixed(0)} damage with 0 shields. Use useThreats() to detect incoming missiles and shield() when you can't dodge.`);
430
+ }
431
+
432
+ // Too much idle time
433
+ if (summary.ticks > 500)
434
+ {
435
+ const idleMovements = w1.movement.still;
436
+ const totalMovements = idleMovements + w1.movement.approach + w1.movement.strafe + w1.movement.retreat;
437
+ if (totalMovements > 0 && idleMovements / totalMovements > 0.5)
438
+ {
439
+ tips.push(`IDLE: Your bot was stationary ${((idleMovements / totalMovements) * 100).toFixed(0)}% of the time. Move to dodge missiles and close distance.`);
440
+ }
441
+ }
442
+
443
+ return tips;
444
+ }
445
+
446
+ /** Format diagnostic tips as a human-readable string. */
447
+ export function formatDiagnosis(tips: string[]): string
448
+ {
449
+ if (tips.length === 0) return '';
450
+ const lines = [' Diagnosis:'];
451
+ for (const tip of tips)
452
+ {
453
+ lines.push(` - ${tip}`);
454
+ }
455
+ return lines.join('\n');
456
+ }
457
+
458
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
459
+
460
+ function formatDodgeDetail(state: GameState, proj: ProjectileState, missileDistance: number, speed: number): string
461
+ {
462
+ const angle = dodgeAngle(state.velocity, proj.rotation);
463
+ return `missile at ${missileDistance.toFixed(0)}u rot=${proj.rotation.toFixed(0) dodge_angle=${angle.toFixed(0)}° ` +
464
+ `spd=${speed.toFixed(2)} ${state.state}`;
465
+ }
466
+
467
+ function dodgeAngle(wizardVelocity: {x: number; y: number}, missileRotation: number): number
468
+ {
469
+ const speed = Math.sqrt(wizardVelocity.x ** 2 + wizardVelocity.y ** 2);
470
+ if (speed < 0.01) return 0;
471
+ const missileRad = missileRotation * (Math.PI / 180);
472
+ const dot = Math.cos(missileRad) * (wizardVelocity.x / speed) + Math.sin(missileRad) * (wizardVelocity.y / speed);
473
+ return Math.acos(Math.max(-1, Math.min(1, Math.abs(dot)))) * (180 / Math.PI);
474
+ }
475
+
476
+ function actorFromId(ev: SimEvent): 'W1' | 'W2'
477
+ {
478
+ if ('wizardId' in ev) return ev.wizardId === 'wizard-1' ? 'W1' : 'W2';
479
+ if ('ownerId' in ev) return ev.ownerId === 'wizard-1' ? 'W1' : 'W2';
480
+ return 'W1';
481
+ }
482
+
483
+ function classifyMovement(state: GameState, enemy: GameState['enemies'][0], speed: number): string
484
+ {
485
+ if (speed < 0.1) return 'STILL';
486
+ const toEnemyAngle = Math.atan2(enemy.position.y - state.position.y, enemy.position.x - state.position.x);
487
+ const moveAngle = Math.atan2(state.velocity.y, state.velocity.x);
488
+ let angleDiff = ((moveAngle - toEnemyAngle) * 180 / Math.PI) % 360;
489
+ if (angleDiff > 180) angleDiff -= 360;
490
+ if (angleDiff < -180) angleDiff += 360;
491
+ const absAngle = Math.abs(angleDiff);
492
+ if (absAngle < 30) return 'APPROACH';
493
+ if (absAngle > 150) return 'RETREAT';
494
+ if (absAngle >= 60 && absAngle <= 120) return 'STRAFE';
495
+ if (absAngle < 60) return 'APPROACH+STRAFE';
496
+ return 'RETREAT+STRAFE';
497
+ }