@vibemancer/core 0.1.0

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 (78) hide show
  1. package/README.md +28 -0
  2. package/dist/chunk-L7Z7OFXD.js +9140 -0
  3. package/dist/chunk-L7Z7OFXD.js.map +1 -0
  4. package/dist/index-browser.d.ts +2602 -0
  5. package/dist/index-browser.js +407 -0
  6. package/dist/index-browser.js.map +1 -0
  7. package/dist/index.d.ts +150 -0
  8. package/dist/index.js +750 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +79 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +457 -0
  12. package/src/bots/berserker/02_Stormcaller.ts +417 -0
  13. package/src/bots/berserker/03_Stormforger.ts +481 -0
  14. package/src/bots/caster/01_Flamecaller.ts +286 -0
  15. package/src/bots/caster/02_Pyromancer.ts +350 -0
  16. package/src/bots/caster/03_Infernalist.ts +492 -0
  17. package/src/bots/defensive/01_Turtle.ts +151 -0
  18. package/src/bots/defensive/02_Sentinel.ts +134 -0
  19. package/src/bots/defensive/03_Golem.ts +357 -0
  20. package/src/bots/duelist/01_Battlemage.ts +433 -0
  21. package/src/bots/duelist/02_Warmage.ts +438 -0
  22. package/src/bots/duelist/03_Archmage.ts +588 -0
  23. package/src/bots/homing/01_Bonemancer.ts +67 -0
  24. package/src/bots/homing/02_Lich.ts +356 -0
  25. package/src/bots/homing/03_Archlich.ts +220 -0
  26. package/src/bots/index.ts +30 -0
  27. package/src/bots/kiter/01_Spellspinner.ts +398 -0
  28. package/src/bots/kiter/02_Spellweaver.ts +378 -0
  29. package/src/bots/kiter/03_Spellbinder.ts +448 -0
  30. package/src/bots/melee/01_Shadowblade.ts +270 -0
  31. package/src/bots/melee/02_Nightblade.ts +437 -0
  32. package/src/bots/melee/03_Voidblade.ts +582 -0
  33. package/src/bots/registry.ts +207 -0
  34. package/src/bots/shared.ts +472 -0
  35. package/src/bots/sniper/01_Spellshot.ts +385 -0
  36. package/src/bots/sniper/02_Spelltracer.ts +441 -0
  37. package/src/bots/sniper/03_Spellseeker.ts +546 -0
  38. package/src/bots/standalone/Critter.ts +89 -0
  39. package/src/bots/standalone/Doombringer.ts +91 -0
  40. package/src/bots/standalone/Hogger.ts +228 -0
  41. package/src/bots/standalone/Rookie.ts +50 -0
  42. package/src/bots/standalone/TargetDummy.ts +21 -0
  43. package/src/bots/test/cheater.ts +405 -0
  44. package/src/bots/test/crasher.ts +81 -0
  45. package/src/engine/hooks-runtime.ts +394 -0
  46. package/src/engine/manual-match.ts +289 -0
  47. package/src/engine/missile-templates.ts +155 -0
  48. package/src/engine/optimizer.ts +220 -0
  49. package/src/engine/params-runtime.ts +189 -0
  50. package/src/engine/physics.ts +143 -0
  51. package/src/engine/sandbox-browser.ts +671 -0
  52. package/src/engine/sandbox-compile.ts +197 -0
  53. package/src/engine/sandbox-harness.ts +367 -0
  54. package/src/engine/sandbox.ts +332 -0
  55. package/src/engine/simulation.ts +828 -0
  56. package/src/engine/spells.ts +128 -0
  57. package/src/engine-version.ts +11 -0
  58. package/src/hooks/action-builders.ts +210 -0
  59. package/src/hooks/bot-wrapper.ts +84 -0
  60. package/src/hooks/index.ts +75 -0
  61. package/src/hooks/state-hooks.ts +354 -0
  62. package/src/hooks/threat-analysis.ts +365 -0
  63. package/src/hooks/types.ts +142 -0
  64. package/src/index-browser.ts +30 -0
  65. package/src/index.ts +24 -0
  66. package/src/rules.ts +254 -0
  67. package/src/stats.ts +262 -0
  68. package/src/testing.ts +207 -0
  69. package/src/trace.ts +430 -0
  70. package/src/types.ts +193 -0
  71. package/src/utils/angles.ts +47 -0
  72. package/src/utils/combat.ts +279 -0
  73. package/src/utils/distance.ts +21 -0
  74. package/src/utils/index.ts +7 -0
  75. package/src/utils/movement.ts +108 -0
  76. package/src/utils/random.ts +65 -0
  77. package/src/utils/spatial.ts +63 -0
  78. package/src/utils/targeting.ts +45 -0
package/src/trace.ts ADDED
@@ -0,0 +1,430 @@
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} 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
+ | 'DODGE_START' // Enemy missile enters 150u proximity
35
+ | 'DODGE_CLOSE' // Enemy missile enters 50u proximity
36
+ | 'MOVE' // Movement summary (every 100 ticks)
37
+ | 'ERROR' // Bot or missile AI threw a runtime error
38
+ | 'WARNING'; // Common mistake detected (move/blink confusion, etc.)
39
+
40
+ export interface TraceSummary
41
+ {
42
+ winner: string;
43
+ ticks: number;
44
+ w1Name: string;
45
+ w2Name: string;
46
+ w1FinalHp: number;
47
+ w2FinalHp: number;
48
+ w1: TraceBotSummary;
49
+ w2: TraceBotSummary;
50
+ }
51
+
52
+ export interface TraceBotSummary
53
+ {
54
+ missilesLaunched: number;
55
+ hits: number;
56
+ damageDealt: number;
57
+ damageReceived: number;
58
+ shields: number;
59
+ blinks: number;
60
+ dodgeEncounters: number;
61
+ movement: {strafe: number; approach: number; retreat: number; still: number};
62
+ }
63
+
64
+ // ─── Event Extraction ────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Extract a structured event log from simulation history.
68
+ * Tracks both bots' state changes, missile launches (with full config + range),
69
+ * hits, damage, dodge proximity, movement patterns, and runtime errors.
70
+ */
71
+ export function extractTraceEvents(history: GameState[], errors?: BotError[]): TraceEvent[]
72
+ {
73
+ const events: TraceEvent[] = [];
74
+
75
+ let prevW1State = '';
76
+ let prevW2State = '';
77
+ let prevW1Health = history[0]?.health ?? 60;
78
+ let prevW2Health = history[0]?.enemies[0]?.health ?? 60;
79
+ let prevW1Spell = '';
80
+ let prevW2Spell = '';
81
+ const prevProjectileIds = new Set<string>();
82
+
83
+ for (let i = 0; i < history.length; i++)
84
+ {
85
+ const state = history[i]!;
86
+ const tick = state.tick;
87
+ const enemy = state.enemies[0]!;
88
+ const distance = distanceTo(state.position, enemy.position);
89
+
90
+ // --- W1 state transitions ---
91
+ const w1State = state.state;
92
+ const w1Spell = state.castingSpell ?? state.channelingSpell ?? '';
93
+ if (w1State !== prevW1State || w1Spell !== prevW1Spell)
94
+ {
95
+ let detail = `${prevW1State || '?'}→${w1State}`;
96
+ if (w1Spell) detail += ` (${w1Spell})`;
97
+ if (w1State === 'casting' && state.castDuration) detail += ` [${state.castDuration}t]`;
98
+ detail += ` dist=${distance.toFixed(0)}`;
99
+ events.push({tick, actor: 'W1', type: 'STATE', detail});
100
+ }
101
+
102
+ // --- W2 state transitions ---
103
+ const w2State = enemy.state;
104
+ const w2Spell = enemy.castingSpell ?? enemy.channelingSpell ?? '';
105
+ if (w2State !== prevW2State || w2Spell !== prevW2Spell)
106
+ {
107
+ let detail = `${prevW2State || '?'}→${w2State}`;
108
+ if (w2Spell) detail += ` (${w2Spell})`;
109
+ if (w2State === 'casting' && enemy.castDuration) detail += ` [${enemy.castDuration}t]`;
110
+ detail += ` dist=${distance.toFixed(0)}`;
111
+ events.push({tick, actor: 'W2', type: 'STATE', detail});
112
+ }
113
+
114
+ // --- Damage events ---
115
+ if (enemy.health < prevW2Health)
116
+ {
117
+ const dmg = prevW2Health - enemy.health;
118
+ events.push({
119
+ tick, actor: 'W1', type: 'HIT',
120
+ detail: `dealt ${dmg.toFixed(1)} → enemy HP=${enemy.health.toFixed(1)} dist=${distance.toFixed(0)}`,
121
+ });
122
+ }
123
+ if (state.health < prevW1Health)
124
+ {
125
+ const dmg = prevW1Health - state.health;
126
+ const stateTag = state.state === 'channeling' ? ' (SHIELDED)'
127
+ : state.state === 'casting' ? ` (CASTING ${state.castingSpell})`
128
+ : state.state === 'gcd_locked' ? ' (GCD)'
129
+ : ` (${state.state})`;
130
+ events.push({
131
+ tick, actor: 'W1', type: 'HURT',
132
+ detail: `took ${dmg.toFixed(1)} → HP=${state.health.toFixed(1)}${stateTag} dist=${distance.toFixed(0)}`,
133
+ });
134
+ }
135
+ // W2 damage taken = W1 damage dealt (already covered above as HIT)
136
+ // W2 damage dealt = W1 damage taken (already covered above as HURT)
137
+
138
+ // --- Missile launches (with full config) ---
139
+ const currentIds = new Set<string>();
140
+ for (const proj of state.projectiles)
141
+ {
142
+ currentIds.add(proj.id);
143
+ if (!prevProjectileIds.has(proj.id))
144
+ {
145
+ const owner: 'W1' | 'W2' = proj.ownerId === 'wizard-1' ? 'W1' : 'W2';
146
+ const range = (proj.speed * proj.remainingTicks).toFixed(0);
147
+ events.push({
148
+ tick, actor: owner, type: 'FIRE',
149
+ detail: `dmg=${proj.damage} spd=${proj.speed} turn=${proj.turnRate} life=${proj.remainingTicks} range=${range} dist=${distance.toFixed(0)}`,
150
+ });
151
+ // Warn if missile range < distance (will expire before reaching target)
152
+ if (proj.speed * proj.remainingTicks < distance * 0.8 && proj.turnRate >= 0)
153
+ {
154
+ events.push({
155
+ tick, actor: owner, type: 'WARNING',
156
+ detail: `missile range (${range}) may be too short for distance (${distance.toFixed(0)}) — consider increasing duration`,
157
+ });
158
+ }
159
+ }
160
+ }
161
+ prevProjectileIds.clear();
162
+ for (const id of currentIds) prevProjectileIds.add(id);
163
+
164
+ // --- Dodge proximity events (enemy missiles approaching W1) ---
165
+ if (i > 0)
166
+ {
167
+ const speed = Math.sqrt(state.velocity.x ** 2 + state.velocity.y ** 2);
168
+ for (const proj of state.projectiles)
169
+ {
170
+ if (proj.ownerId === 'wizard-1') continue;
171
+ const missileDistance = distanceTo(state.position, proj.position);
172
+ const prevProj = history[i - 1]!.projectiles.find((p) => p.id === proj.id);
173
+ const prevMissileDist = prevProj ? distanceTo(history[i - 1]!.position, prevProj.position) : Infinity;
174
+
175
+ if (missileDistance <= 150 && prevMissileDist > 150)
176
+ {
177
+ events.push({
178
+ tick, actor: 'W1', type: 'DODGE_START',
179
+ detail: formatDodgeDetail(state, proj, missileDistance, speed),
180
+ });
181
+ }
182
+ if (missileDistance <= 50 && prevMissileDist > 50)
183
+ {
184
+ events.push({
185
+ tick, actor: 'W1', type: 'DODGE_CLOSE',
186
+ detail: formatDodgeDetail(state, proj, missileDistance, speed),
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ // --- Movement summary every 100 ticks ---
193
+ if (tick > 0 && tick % 100 === 0)
194
+ {
195
+ const speed = Math.sqrt(state.velocity.x ** 2 + state.velocity.y ** 2);
196
+ const moveType = classifyMovement(state, enemy, speed);
197
+ const incomingMissiles = state.projectiles.filter((p) => p.ownerId !== 'wizard-1').length;
198
+ events.push({
199
+ tick, actor: 'W1', type: 'MOVE',
200
+ detail: `${moveType} spd=${speed.toFixed(2)} dist=${distance.toFixed(0)} missiles=${incomingMissiles} ${state.state} hp=${state.health.toFixed(1)}`,
201
+ });
202
+ }
203
+
204
+ prevW1State = w1State;
205
+ prevW2State = w2State;
206
+ prevW1Health = state.health;
207
+ prevW2Health = enemy.health;
208
+ prevW1Spell = w1Spell;
209
+ prevW2Spell = w2Spell;
210
+ }
211
+
212
+ // --- Inject runtime errors as ERROR events ---
213
+ if (errors && errors.length > 0)
214
+ {
215
+ for (const err of errors)
216
+ {
217
+ const actor: 'W1' | 'W2' = err.entityId === 'wizard-1' ? 'W1'
218
+ : err.entityId === 'wizard-2' ? 'W2'
219
+ : err.entityId.startsWith('missile') ? (/* missile errors attributed to owner */ 'W1') : 'W1';
220
+ events.push({
221
+ tick: err.tick,
222
+ actor,
223
+ type: 'ERROR',
224
+ detail: err.message,
225
+ });
226
+ }
227
+ // Re-sort by tick so errors appear in chronological order
228
+ events.sort((a, b) => a.tick - b.tick || (a.type === 'ERROR' ? 1 : 0));
229
+ }
230
+
231
+ return events;
232
+ }
233
+
234
+ // ─── Summary ─────────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Generate a summary from trace events and the simulation result.
238
+ */
239
+ export function summarizeTrace(
240
+ events: TraceEvent[],
241
+ result: SimulateResult,
242
+ w1Name: string,
243
+ w2Name: string,
244
+ ): TraceSummary
245
+ {
246
+ const final = result.history[result.history.length - 1];
247
+
248
+ function botSummary(actor: 'W1' | 'W2'): TraceBotSummary
249
+ {
250
+ const myEvents = events.filter((e) => e.actor === actor);
251
+ const moveEvents = myEvents.filter((e) => e.type === 'MOVE');
252
+ return {
253
+ missilesLaunched: myEvents.filter((e) => e.type === 'FIRE').length,
254
+ hits: myEvents.filter((e) => e.type === 'HIT').length,
255
+ damageDealt: myEvents.filter((e) => e.type === 'HIT').reduce((sum, e) =>
256
+ {
257
+ const m = e.detail.match(/dealt ([\d.]+)/);
258
+ return sum + (m ? parseFloat(m[1]!) : 0);
259
+ }, 0),
260
+ damageReceived: myEvents.filter((e) => e.type === 'HURT').reduce((sum, e) =>
261
+ {
262
+ const m = e.detail.match(/took ([\d.]+)/);
263
+ return sum + (m ? parseFloat(m[1]!) : 0);
264
+ }, 0),
265
+ shields: myEvents.filter((e) => e.type === 'STATE' && e.detail.includes('shield')).length,
266
+ blinks: myEvents.filter((e) => e.type === 'STATE' && e.detail.includes('blink')).length,
267
+ dodgeEncounters: myEvents.filter((e) => e.type.startsWith('DODGE')).length,
268
+ movement: {
269
+ strafe: moveEvents.filter((e) => e.detail.startsWith('STRAFE')).length,
270
+ approach: moveEvents.filter((e) => e.detail.startsWith('APPROACH')).length,
271
+ retreat: moveEvents.filter((e) => e.detail.startsWith('RETREAT')).length,
272
+ still: moveEvents.filter((e) => e.detail.startsWith('STILL')).length,
273
+ },
274
+ };
275
+ }
276
+
277
+ const winnerName = result.winner === 'wizard-1' ? w1Name
278
+ : result.winner === 'wizard-2' ? w2Name
279
+ : result.winner === 'draw' ? 'draw'
280
+ : 'timeout';
281
+
282
+ return {
283
+ winner: winnerName,
284
+ ticks: result.ticks,
285
+ w1Name,
286
+ w2Name,
287
+ w1FinalHp: final?.health ?? 0,
288
+ w2FinalHp: final?.enemies[0]?.health ?? 0,
289
+ w1: botSummary('W1'),
290
+ w2: botSummary('W2'),
291
+ };
292
+ }
293
+
294
+ // ─── Formatting ──────────────────────────────────────────────────────────────
295
+
296
+ /** Format trace events as a human-readable string. */
297
+ export function formatTraceEvents(events: TraceEvent[]): string
298
+ {
299
+ return events.map((e) =>
300
+ {
301
+ const t = String(e.tick).padStart(5);
302
+ const a = e.actor.padEnd(2);
303
+ const ty = e.type.padEnd(12);
304
+ return ` T${t} ${a} ${ty} ${e.detail}`;
305
+ }).join('\n');
306
+ }
307
+
308
+ /** Format a full trace summary as a human-readable string. */
309
+ export function formatTraceSummary(summary: TraceSummary): string
310
+ {
311
+ const {w1Name, w2Name, w1, w2} = summary;
312
+ const lines = [
313
+ ` Winner: ${summary.winner} @ T${summary.ticks}`,
314
+ ` Final HP: ${w1Name}=${summary.w1FinalHp.toFixed(1)}, ${w2Name}=${summary.w2FinalHp.toFixed(1)}`,
315
+ '',
316
+ ` ${w1Name}: ${w1.missilesLaunched} missiles, ${w1.hits} hits (${w1.missilesLaunched > 0 ? ((w1.hits / w1.missilesLaunched) * 100).toFixed(0) : 'N/A'}%), ${w1.damageDealt.toFixed(1)} dmg dealt, ${w1.damageReceived.toFixed(1)} dmg taken, ${w1.shields} shields, ${w1.blinks} blinks`,
317
+ ` ${w2Name}: ${w2.missilesLaunched} missiles, ${w2.hits} hits (${w2.missilesLaunched > 0 ? ((w2.hits / w2.missilesLaunched) * 100).toFixed(0) : 'N/A'}%), ${w2.damageDealt.toFixed(1)} dmg dealt, ${w2.damageReceived.toFixed(1)} dmg taken, ${w2.shields} shields, ${w2.blinks} blinks`,
318
+ '',
319
+ ` ${w1Name} movement: ${w1.movement.approach} approach, ${w1.movement.strafe} strafe, ${w1.movement.retreat} retreat, ${w1.movement.still} still | ${w1.dodgeEncounters} dodge encounters`,
320
+ ];
321
+ return lines.join('\n');
322
+ }
323
+
324
+ /**
325
+ * Generate diagnostic tips based on trace analysis.
326
+ * Identifies common problems and suggests fixes.
327
+ * Returns an array of human-readable tips (empty if no issues found).
328
+ */
329
+ export function diagnoseTrace(events: TraceEvent[], summary: TraceSummary): string[]
330
+ {
331
+ const tips: string[] = [];
332
+ const {w1} = summary;
333
+
334
+ // Check for runtime errors
335
+ const errors = events.filter((e) => e.actor === 'W1' && e.type === 'ERROR');
336
+ if (errors.length > 0)
337
+ {
338
+ const firstErr = errors[0]!;
339
+ tips.push(`BOT ERROR: Your bot threw "${firstErr.detail}" on tick ${firstErr.tick}. The bot does nothing when it throws. Fix this first.`);
340
+ if (errors.length > 1) tips.push(` (${errors.length} total errors — likely the same bug repeating every tick)`);
341
+ }
342
+
343
+ // Check for range warnings
344
+ const rangeWarnings = events.filter((e) => e.actor === 'W1' && e.type === 'WARNING' && e.detail.includes('range'));
345
+ if (rangeWarnings.length > 0)
346
+ {
347
+ tips.push(`RANGE: ${rangeWarnings.length} of your missiles had range shorter than target distance. Increase missile duration or get closer before firing.`);
348
+ }
349
+
350
+ // No missiles fired
351
+ if (w1.missilesLaunched === 0 && errors.length === 0)
352
+ {
353
+ tips.push('NO OFFENSE: Your bot fired 0 missiles. Check that you return missile() when useTicksUntilReady() === 0.');
354
+ }
355
+
356
+ // Very low hit rate
357
+ if (w1.missilesLaunched >= 3 && w1.hits === 0)
358
+ {
359
+ tips.push(`ACCURACY: Fired ${w1.missilesLaunched} missiles but 0 hit. Consider: more homing (higher turnRate), or use getLeadPosition() for straight missiles, or get closer.`);
360
+ }
361
+ else if (w1.missilesLaunched >= 5 && w1.hits / w1.missilesLaunched < 0.25)
362
+ {
363
+ 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.`);
364
+ }
365
+
366
+ // No shields against incoming damage
367
+ if (w1.damageReceived > 20 && w1.shields === 0)
368
+ {
369
+ 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.`);
370
+ }
371
+
372
+ // Too much idle time
373
+ if (summary.ticks > 500)
374
+ {
375
+ const idleMovements = w1.movement.still;
376
+ const totalMovements = idleMovements + w1.movement.approach + w1.movement.strafe + w1.movement.retreat;
377
+ if (totalMovements > 0 && idleMovements / totalMovements > 0.5)
378
+ {
379
+ tips.push(`IDLE: Your bot was stationary ${((idleMovements / totalMovements) * 100).toFixed(0)}% of the time. Move to dodge missiles and close distance.`);
380
+ }
381
+ }
382
+
383
+ return tips;
384
+ }
385
+
386
+ /** Format diagnostic tips as a human-readable string. */
387
+ export function formatDiagnosis(tips: string[]): string
388
+ {
389
+ if (tips.length === 0) return '';
390
+ const lines = [' Diagnosis:'];
391
+ for (const tip of tips)
392
+ {
393
+ lines.push(` - ${tip}`);
394
+ }
395
+ return lines.join('\n');
396
+ }
397
+
398
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
399
+
400
+ function formatDodgeDetail(state: GameState, proj: ProjectileState, missileDistance: number, speed: number): string
401
+ {
402
+ const angle = dodgeAngle(state.velocity, proj.rotation);
403
+ return `missile at ${missileDistance.toFixed(0)}u rot=${proj.rotation.toFixed(0)}° dodge_angle=${angle.toFixed(0)}° ` +
404
+ `spd=${speed.toFixed(2)} ${state.state}`;
405
+ }
406
+
407
+ function dodgeAngle(wizardVelocity: {x: number; y: number}, missileRotation: number): number
408
+ {
409
+ const speed = Math.sqrt(wizardVelocity.x ** 2 + wizardVelocity.y ** 2);
410
+ if (speed < 0.01) return 0;
411
+ const missileRad = missileRotation * (Math.PI / 180);
412
+ const dot = Math.cos(missileRad) * (wizardVelocity.x / speed) + Math.sin(missileRad) * (wizardVelocity.y / speed);
413
+ return Math.acos(Math.max(-1, Math.min(1, Math.abs(dot)))) * (180 / Math.PI);
414
+ }
415
+
416
+ function classifyMovement(state: GameState, enemy: GameState['enemies'][0], speed: number): string
417
+ {
418
+ if (speed < 0.1) return 'STILL';
419
+ const toEnemyAngle = Math.atan2(enemy.position.y - state.position.y, enemy.position.x - state.position.x);
420
+ const moveAngle = Math.atan2(state.velocity.y, state.velocity.x);
421
+ let angleDiff = ((moveAngle - toEnemyAngle) * 180 / Math.PI) % 360;
422
+ if (angleDiff > 180) angleDiff -= 360;
423
+ if (angleDiff < -180) angleDiff += 360;
424
+ const absAngle = Math.abs(angleDiff);
425
+ if (absAngle < 30) return 'APPROACH';
426
+ if (absAngle > 150) return 'RETREAT';
427
+ if (absAngle >= 60 && absAngle <= 120) return 'STRAFE';
428
+ if (absAngle < 60) return 'APPROACH+STRAFE';
429
+ return 'RETREAT+STRAFE';
430
+ }
package/src/types.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * VIBEMANCER - TYPES
3
+ *
4
+ * This file contains all the TypeScript interfaces used by the game engine.
5
+ */
6
+
7
+ /**
8
+ * Position in 2D space.
9
+ */
10
+ export interface Position
11
+ {
12
+ x: number;
13
+ y: number;
14
+ }
15
+
16
+ /**
17
+ * Velocity in 2D space (units per tick).
18
+ */
19
+ export interface Velocity
20
+ {
21
+ x: number;
22
+ y: number;
23
+ }
24
+
25
+ /**
26
+ * Wizard state.
27
+ */
28
+ export interface WizardState
29
+ {
30
+ id: string;
31
+ position: Position;
32
+ rotation: number; // degrees
33
+ health: number;
34
+ maxHealth: number;
35
+ state: 'idle' | 'casting' | 'channeling' | 'gcd_locked';
36
+ castingSpell?: 'missile' | 'shield' | 'blink';
37
+ castProgress?: number; // ticks elapsed
38
+ castDuration?: number; // total ticks needed
39
+ channelingSpell?: 'shield';
40
+ channelDuration?: number; // ticks spent channeling (for decay calc)
41
+ gcdRemaining?: number; // ticks remaining
42
+ blinkCooldown: number; // ticks remaining
43
+ velocity: Velocity;
44
+ lastMissileConfig?: MissileConfig; // last fired missile (for warmup system)
45
+ warmupMultiplier?: number; // warmup multiplier applied to most recent missile cast (0.80 = full bonus, 1.20 = full penalty)
46
+ invincible?: boolean; // manual play mode: damage is ignored when true
47
+ }
48
+
49
+ /**
50
+ * Projectile (missile) state.
51
+ */
52
+ export interface ProjectileState
53
+ {
54
+ id: string;
55
+ type: 'missile';
56
+ ownerId: string;
57
+ position: Position;
58
+ rotation: number; // degrees
59
+ speed: number; // units per tick
60
+ turnRate: number; // degrees per tick
61
+ damage: number;
62
+ remainingTicks: number;
63
+ }
64
+
65
+ /**
66
+ * Game configuration (read-only).
67
+ */
68
+ export interface GameConfig
69
+ {
70
+ arenaSize: {
71
+ width: number;
72
+ height: number;
73
+ };
74
+ tickRate: number;
75
+ maxTicks: number;
76
+ }
77
+
78
+ /**
79
+ * Full game state passed to wizard.
80
+ */
81
+ export interface GameState
82
+ {
83
+ tick: number;
84
+ position: Position;
85
+ rotation: number;
86
+ health: number;
87
+ maxHealth: number;
88
+ state: WizardState['state'];
89
+ castingSpell?: 'missile' | 'shield' | 'blink';
90
+ castProgress?: number;
91
+ castDuration?: number;
92
+ channelingSpell?: 'shield';
93
+ channelDuration?: number;
94
+ gcdRemaining?: number;
95
+ blinkCooldown: number;
96
+ velocity: Velocity;
97
+ lastMissileConfig?: MissileConfig; // last fired missile (for warmup system)
98
+ warmupMultiplier?: number; // warmup multiplier applied to most recent missile cast (0.80 = full bonus, 1.20 = full penalty)
99
+ enemies: WizardState[];
100
+ projectiles: ProjectileState[]; // all projectiles (yours and enemy's)
101
+ myProjectiles: ProjectileState[]; // only your active projectiles
102
+
103
+ // Combat tracking (for new hooks API)
104
+ damageDealt: number; // total damage dealt this match
105
+ damageTaken: number; // total damage taken this match
106
+ lastHitTick: number; // tick when last took damage (0 if never)
107
+ }
108
+
109
+ /**
110
+ * Actions returned by wizard each tick.
111
+ *
112
+ * Movement uses world-space coordinates:
113
+ * - x: +100 = right, -100 = left
114
+ * - y: +100 = down, -100 = up
115
+ * - Diagonal movement is normalized (magnitude capped at 100)
116
+ * - No rotation tracking - just output (x, y) direction
117
+ */
118
+ export interface WizardActions
119
+ {
120
+ /**
121
+ * Movement direction in world-space.
122
+ * Values are clamped to [-100, 100] range.
123
+ * Magnitude is normalized to max 100 for diagonal movement.
124
+ */
125
+ move: {
126
+ x: number;
127
+ y: number;
128
+ };
129
+
130
+ /**
131
+ * Start a new cast (only works if state === 'idle').
132
+ */
133
+ startCast?: {
134
+ spell: 'missile';
135
+ config: MissileConfig;
136
+ missileAI: MissileAIFunction;
137
+ direction?: number; // initial rotation override (auto-aims at enemy by default)
138
+ } | {
139
+ spell: 'shield';
140
+ } | {
141
+ spell: 'blink';
142
+ target: Position; // clamped to max range
143
+ };
144
+
145
+ /**
146
+ * Cancel current cast or channel (works if casting or channeling).
147
+ */
148
+ cancel?: boolean;
149
+
150
+ /**
151
+ * Update aim direction while casting a missile (degrees).
152
+ * The missile fires in this direction at launch, allowing tracking during cast.
153
+ * Only applies while state === 'casting' and castingSpell === 'missile'.
154
+ */
155
+ aimDirection?: number;
156
+ }
157
+
158
+ /**
159
+ * Missile configuration.
160
+ */
161
+ export interface MissileConfig
162
+ {
163
+ damage: number;
164
+ speed: number; // units per tick
165
+ turnRate: number; // degrees per tick
166
+ duration: number; // ticks (determines range and cast time)
167
+ }
168
+
169
+ /**
170
+ * Missile AI function type.
171
+ */
172
+ export type MissileAIFunction = (props: {
173
+ missileState: ProjectileState;
174
+ worldState: GameState;
175
+ random: () => number; // Seeded PRNG, isolated per entity, 0-1 range
176
+ }) => MissileActions;
177
+
178
+ /**
179
+ * Actions returned by missile each tick.
180
+ */
181
+ export interface MissileActions
182
+ {
183
+ turnToward?: Position; // engine handles turn rate limit
184
+ }
185
+
186
+ /**
187
+ * Main wizard function type.
188
+ */
189
+ export type WizardFunction = (props: {
190
+ state: GameState;
191
+ config: GameConfig;
192
+ random: () => number; // Seeded PRNG, isolated per entity, 0-1 range
193
+ }) => WizardActions;
@@ -0,0 +1,47 @@
1
+ import {Position} from '../types.js';
2
+
3
+ /**
4
+ * Calculate the angle from one point to another in degrees.
5
+ * 0° = right, 90° = down, 180° = left, 270° = up.
6
+ */
7
+ export function angleTo(from: Position, to: Position): number
8
+ {
9
+ const dx = to.x - from.x;
10
+ const dy = to.y - from.y;
11
+ const radians = Math.atan2(dy, dx);
12
+ return normalizeAngle(radians * (180 / Math.PI));
13
+ }
14
+
15
+ /**
16
+ * Normalize an angle to the 0-360 range.
17
+ */
18
+ export function normalizeAngle(angle: number): number
19
+ {
20
+ let normalized = angle % 360;
21
+ if (normalized < 0)
22
+ {
23
+ normalized += 360;
24
+ }
25
+ return normalized;
26
+ }
27
+
28
+ /**
29
+ * Calculate the shortest difference between two angles (-180 to +180).
30
+ */
31
+ export function angleDiff(angleA: number, angleB: number): number
32
+ {
33
+ let diff = (angleB - angleA + 180) % 360 - 180;
34
+ if (diff < -180)
35
+ {
36
+ diff += 360;
37
+ }
38
+ return diff;
39
+ }
40
+
41
+ /**
42
+ * Check if an angle is within a certain range of a target angle.
43
+ */
44
+ export function angleInRange(angle: number, target: number, range: number): boolean
45
+ {
46
+ return Math.abs(angleDiff(angle, target)) <= range;
47
+ }