@vibemancer/core 1.0.10 → 1.0.12

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.
@@ -1,407 +1,413 @@
1
- /**
2
- * VIBEMANCER - STATE HOOKS
3
- *
4
- * React-style hooks for reading game state.
5
- * These handle the "subconscious" perception that humans do instinctively.
6
- *
7
- * UNITS REFERENCE (100 ticks = 1 second):
8
- * Position: absolute world coordinates, 0-860 on each axis. The arena is 860x860,
9
- * but only [30, 830] is safe — the outer 30 units are lethal lava.
10
- * Velocity: units per tick on each axis (player max speed = 1 u/t)
11
- * Health: hit points (max 60)
12
- * Ticks: game ticks (10ms each, 100/sec). Divide by 100 for seconds.
13
- * Shield: block multiplier 0.0-0.9 (0.9 = blocks 90% damage)
14
- */
15
-
16
- import {Position, Velocity, ProjectileState} from '../types.js';
17
- import {getWizardContext, useMemo} from '../engine/hooks-runtime.js';
18
- import {RULES, TICKS_PER_SECOND} from '../rules.js';
19
- import type {EnemyState, AnalyzedThreat} from './types.js';
20
- import {analyzeThreats} from './threat-analysis.js';
21
-
22
- // ============================================================
23
- // SELF STATE HOOKS
24
- // ============================================================
25
-
26
- /**
27
- * Get a seeded random number generator. Returns a function that produces
28
- * deterministic values in [0, 1) — same seed + same tick = same sequence.
29
- * Use this instead of Math.random() so replays are deterministic.
30
- */
31
- export function useRandom(): () => number
32
- {
33
- return getWizardContext().random;
34
- }
35
-
36
- /**
37
- * Get your current health (0-60). Wizard dies at 0.
38
- */
39
- export function useHealth(): number
40
- {
41
- return getWizardContext().health;
42
- }
43
-
44
- /**
45
- * Get your current position as {x, y} in world coordinates (0-860).
46
- *
47
- * Clamped to [5, 855] — the ARENA bounds minus the wizard radius, NOT the playfield. That
48
- * distinction is the difference between living and dying: the playfield is [30, 830] and
49
- * everything outside it is lava, so you can walk straight out of the safe area and be
50
- * killed. Nothing stops you.
51
- *
52
- * And the number you actually need is neither of those. Lava is tested against your EDGE,
53
- * not your centre (`isInLava` checks `x + WIZARD_RADIUS > ARENA_MAX`), and this returns your
54
- * CENTRE — so with a radius of 5 you die once your centre passes 825. The safe range to
55
- * steer by is [35, 825]. Aiming for 828 because "the playfield is [30, 830]" is fatal, which
56
- * is exactly what a real fight trace showed: LAVA_DEATH at (826, 430).
57
- *
58
- * (This previously quoted the playfield-sized bounds as the clamp, which told the reader
59
- * they were safely fenced in. They are not — see docs-match-engine.test.ts.)
60
- */
61
- export function usePosition(): Position
62
- {
63
- const ctx = getWizardContext();
64
- return {x: ctx.position.x, y: ctx.position.y};
65
- }
66
-
67
- /**
68
- * Get your current velocity as {x, y} in units/tick.
69
- * Max magnitude is 1 u/t when idle, 0.5 u/t when casting, 0 when channeling shield.
70
- */
71
- export function useVelocity(): Velocity
72
- {
73
- const ctx = getWizardContext();
74
- return {x: ctx.velocity.x, y: ctx.velocity.y};
75
- }
76
-
77
- /**
78
- * Get your current status:
79
- * - 'idle': free to act
80
- * - 'casting': casting a spell (missile or blink). Can move at 50% speed.
81
- * - 'channeling': channeling shield. Cannot move. Cancel anytime with cancel().
82
- * - 'gcd_locked': global cooldown after spell. Can move at full speed, but cannot cast.
83
- */
84
- export function useStatus(): 'idle' | 'casting' | 'channeling' | 'gcd_locked'
85
- {
86
- return getWizardContext().state;
87
- }
88
-
89
- /**
90
- * Get ticks until you can start a new spell.
91
- *
92
- * Returns 0 when idle or channeling (shield can be canceled immediately).
93
- * During casting: remaining cast ticks. During GCD: remaining GCD ticks.
94
- *
95
- * Note: 100 ticks = 1 second.
96
- */
97
- export function useTicksUntilReady(): number
98
- {
99
- const ctx = getWizardContext();
100
-
101
- switch (ctx.state)
102
- {
103
- case 'idle':
104
- return 0;
105
- case 'casting':
106
- // Remaining cast time
107
- if (ctx.castDuration !== undefined && ctx.castProgress !== undefined)
108
- {
109
- return ctx.castDuration - ctx.castProgress;
110
- }
111
- return 0;
112
- case 'channeling':
113
- // Can cancel anytime, so 0
114
- return 0;
115
- case 'gcd_locked':
116
- return ctx.gcdRemaining ?? 0;
117
- default:
118
- return 0;
119
- }
120
- }
121
-
122
- /**
123
- * Get current shield block multiplier.
124
- *
125
- * Returns 0 if not channeling shield.
126
- * Returns 0.3-0.9 if channeling: starts at 0.9 (blocks 90%), decays by 0.2/sec,
127
- * minimum 0.3 (blocks 30%). The remaining damage gets through:
128
- * actualDamage = incomingDamage × (1 - shieldStrength).
129
- */
130
- export function useShieldStrength(): number
131
- {
132
- const ctx = getWizardContext();
133
-
134
- if (ctx.state !== 'channeling' || ctx.channelingSpell !== 'shield')
135
- {
136
- return 0;
137
- }
138
-
139
- // Calculate shield strength based on channel duration
140
- const channelDuration = ctx.channelDuration ?? 0;
141
- const decayPerTick = RULES.SHIELD_DECAY_PER_SECOND / TICKS_PER_SECOND;
142
- const strength = RULES.SHIELD_MAX_BLOCK - (channelDuration * decayPerTick);
143
- return Math.max(RULES.SHIELD_MIN_BLOCK, strength);
144
- }
145
-
146
- /**
147
- * Get all projectiles currently in flight (yours and enemy's).
148
- * Used for blink safety calculations and threat analysis.
149
- */
150
- export function useProjectiles(): ProjectileState[]
151
- {
152
- return getWizardContext().projectiles;
153
- }
154
-
155
- /**
156
- * Get the config of the last missile you fired, or undefined if none fired yet.
157
- * Used for the warmup system: consecutive similar missiles cast faster.
158
- */
159
- export function useLastMissileConfig(): import('../types.js').MissileConfig | undefined
160
- {
161
- return getWizardContext().lastMissileConfig;
162
- }
163
-
164
- /**
165
- * Get blink cooldown remaining in ticks. Returns 0 if ready to blink.
166
- *
167
- * Cooldown scales with distance used:
168
- * - 100 units → ~100 ticks (1s)
169
- * - 300 units (max range) 2000 ticks (20s)
170
- *
171
- * Note: 100 ticks = 1 second.
172
- */
173
- export function useBlinkCooldown(): number
174
- {
175
- return getWizardContext().blinkCooldown;
176
- }
177
-
178
- /**
179
- * Get currently casting spell, or null if not casting.
180
- * Returns 'missile', 'shield', or 'blink'.
181
- */
182
- export function useCastingSpell(): 'missile' | 'shield' | 'blink' | null
183
- {
184
- const ctx = getWizardContext();
185
- if (ctx.state !== 'casting')
186
- {
187
- return null;
188
- }
189
- return ctx.castingSpell ?? null;
190
- }
191
-
192
- /**
193
- * Get cast progress as {current, total} in ticks, or null if not casting.
194
- *
195
- * current = ticks elapsed, total = ticks needed. When current >= total, spell fires.
196
- * Note: 100 ticks = 1 second.
197
- */
198
- export function useCastProgress(): {current: number; total: number} | null
199
- {
200
- const ctx = getWizardContext();
201
- if (ctx.state !== 'casting')
202
- {
203
- return null;
204
- }
205
- if (ctx.castProgress === undefined || ctx.castDuration === undefined)
206
- {
207
- return null;
208
- }
209
- return {current: ctx.castProgress, total: ctx.castDuration};
210
- }
211
-
212
- // ============================================================
213
- // ENEMY STATE HOOK
214
- // ============================================================
215
-
216
- /**
217
- * Get enemy wizard state.
218
- *
219
- * Returns position, velocity, health, status, casting spell, and shield strength.
220
- * Note: you cannot see the enemy's missile configs or exact cooldown timers —
221
- * only their status and what's visible on the field.
222
- */
223
- export function useEnemy(): EnemyState
224
- {
225
- const ctx = getWizardContext();
226
- const enemy = ctx.enemies[0];
227
-
228
- if (!enemy)
229
- {
230
- // No enemy - return default state
231
- return {
232
- position: {x: 0, y: 0},
233
- velocity: {x: 0, y: 0},
234
- health: 0,
235
- status: 'idle',
236
- castingSpell: null,
237
- castProgress: 0,
238
- castDuration: 0,
239
- gcdRemaining: 0,
240
- channelDuration: 0,
241
- shieldStrength: 0,
242
- };
243
- }
244
-
245
- // Calculate enemy shield strength
246
- let shieldStrength = 0;
247
- if (enemy.state === 'channeling' && enemy.channelingSpell === 'shield')
248
- {
249
- const channelDuration = enemy.channelDuration ?? 0;
250
- const decayPerTick = RULES.SHIELD_DECAY_PER_SECOND / TICKS_PER_SECOND;
251
- shieldStrength = Math.max(RULES.SHIELD_MIN_BLOCK, RULES.SHIELD_MAX_BLOCK - (channelDuration * decayPerTick));
252
- }
253
-
254
- return {
255
- position: {x: enemy.position.x, y: enemy.position.y},
256
- velocity: {x: enemy.velocity.x, y: enemy.velocity.y},
257
- health: enemy.health,
258
- status: enemy.state,
259
- castingSpell: enemy.state === 'casting' ? (enemy.castingSpell ?? null) : null,
260
- castProgress: enemy.castProgress ?? 0,
261
- castDuration: enemy.castDuration ?? 0,
262
- gcdRemaining: enemy.gcdRemaining ?? 0,
263
- channelDuration: enemy.channelDuration ?? 0,
264
- shieldStrength,
265
- };
266
- }
267
-
268
- // ============================================================
269
- // PROJECTILE HOOKS
270
- // ============================================================
271
-
272
- /**
273
- * Get all your active (in-flight) projectiles.
274
- * Each has position, rotation (degrees), speed (u/t), turnRate, remainingTicks.
275
- */
276
- export function useMyProjectiles(): ProjectileState[]
277
- {
278
- return getWizardContext().myProjectiles;
279
- }
280
-
281
- // ============================================================
282
- // COMBAT TRACKING HOOKS
283
- // ============================================================
284
-
285
- /**
286
- * Get total damage you've dealt this match.
287
- */
288
- export function useDamageDealt(): number
289
- {
290
- return getWizardContext().damageDealt;
291
- }
292
-
293
- /**
294
- * Get total damage you've taken this match.
295
- */
296
- export function useDamageTaken(): number
297
- {
298
- return getWizardContext().damageTaken;
299
- }
300
-
301
- /**
302
- * Get the tick number when you last took damage. Returns 0 if never hit.
303
- * Compare with useTick() to get ticks since last hit.
304
- */
305
- export function useLastHitTick(): number
306
- {
307
- return getWizardContext().lastHitTick;
308
- }
309
-
310
- // ============================================================
311
- // ARENA HOOKS
312
- // ============================================================
313
-
314
- /**
315
- * Get arena dimensions. Default: {width: 860, height: 860} — the FULL arena, lava included.
316
- * The safe playfield is [30, 830]; the outer 30 units on every side are lethal.
317
- *
318
- * Wizards are clamped to [5, 855] (arena bounds minus the radius of 5), which does not keep
319
- * them out of the lava.
320
- */
321
- export function useArenaSize(): {width: number; height: number}
322
- {
323
- const ctx = getWizardContext();
324
- return {width: ctx.arenaWidth, height: ctx.arenaHeight};
325
- }
326
-
327
- /**
328
- * Get current game tick (starts at 0, increments each tick).
329
- * 100 ticks = 1 second. Match ends at 30,000 ticks (5 minutes).
330
- */
331
- export function useTick(): number
332
- {
333
- return getWizardContext().tick;
334
- }
335
-
336
- // ============================================================
337
- // THREAT ANALYSIS HOOKS
338
- // ============================================================
339
-
340
- /**
341
- * Get analyzed threats from all incoming enemy projectiles.
342
- * Sorted by ticksToImpact (soonest first). Only includes missiles within 500 units
343
- * or that are predicted to hit.
344
- *
345
- * Each threat includes:
346
- * - ticksToImpact: ticks until hit (Infinity if will miss)
347
- * - willHit: true if missile hits your current position
348
- * - canDodgeLeft/Right: whether strafing perpendicular to missile heading works
349
- * - canOutrun: whether moving away from missile escapes it
350
- * - bestDodgeDirection: {x, y} unit vector to dodge optimally, or null if undodgeable
351
- * - canBlockInTime: whether you can raise shield before impact
352
- * - ticksToStartShield: when to START channeling shield to block in time
353
- */
354
- export function useThreats(): AnalyzedThreat[]
355
- {
356
- const ctx = getWizardContext();
357
- const ticksUntilReady = useTicksUntilReady();
358
-
359
- // Memoize to avoid recalculating every call
360
- return useMemo(() =>
361
- {
362
- return analyzeThreats(
363
- ctx.position,
364
- ctx.projectiles,
365
- ctx.myProjectiles,
366
- ticksUntilReady,
367
- );
368
- }, [ctx.projectiles, ctx.myProjectiles, ctx.position.x, ctx.position.y, ticksUntilReady]);
369
- }
370
-
371
- /**
372
- * Get the most imminent threat, or null if no threats.
373
- * Shorthand for useThreats()[0].
374
- */
375
- export function useClosestThreat(): AnalyzedThreat | null
376
- {
377
- const threats = useThreats();
378
- return threats[0] ?? null;
379
- }
380
-
381
- /**
382
- * Get your missiles analyzed from the enemy's perspective.
383
- * Useful to predict when enemy will shield/dodge your attacks.
384
- */
385
- export function useMyThreatsToEnemy(): AnalyzedThreat[]
386
- {
387
- const ctx = getWizardContext();
388
- const enemy = ctx.enemies[0];
389
-
390
- if (!enemy)
391
- {
392
- return [];
393
- }
394
-
395
- // Memoize to avoid recalculating every call
396
- return useMemo(() =>
397
- {
398
- // Analyze from enemy's perspective
399
- // Pass empty array for "enemy's own projectiles" since we're analyzing our missiles as threats TO them
400
- return analyzeThreats(
401
- enemy.position,
402
- ctx.myProjectiles, // Our projectiles are their threats
403
- [], // Enemy has no "own projectiles" in this context
404
- 0, // We don't know enemy's ticksUntilReady
405
- );
406
- }, [ctx.myProjectiles, enemy.position.x, enemy.position.y]);
407
- }
1
+ /**
2
+ * VIBEMANCER - STATE HOOKS
3
+ *
4
+ * React-style hooks for reading game state.
5
+ * These handle the "subconscious" perception that humans do instinctively.
6
+ *
7
+ * UNITS REFERENCE (100 ticks = 1 second):
8
+ * Position: absolute world coordinates, 0-860 on each axis. The arena is 860x860,
9
+ * but only [30, 830] is safe — the outer 30 units are lethal lava.
10
+ * Velocity: units per tick on each axis (player max speed = 1 u/t)
11
+ * Health: hit points (max 60)
12
+ * Ticks: game ticks (10ms each, 100/sec). Divide by 100 for seconds.
13
+ * Shield: block multiplier 0.0-0.9 (0.9 = blocks 90% damage)
14
+ */
15
+
16
+ import {Position, Velocity, ProjectileState} from '../types.js';
17
+ import {getWizardContext, useMemo} from '../engine/hooks-runtime.js';
18
+ import {RULES, TICKS_PER_SECOND} from '../rules.js';
19
+ import type {EnemyState, AnalyzedThreat} from './types.js';
20
+ import {analyzeThreats} from './threat-analysis.js';
21
+
22
+ // ============================================================
23
+ // SELF STATE HOOKS
24
+ // ============================================================
25
+
26
+ /**
27
+ * Get a seeded random number generator. Returns a function that produces
28
+ * deterministic values in [0, 1) — same seed + same tick = same sequence.
29
+ * Use this instead of Math.random() so replays are deterministic.
30
+ */
31
+ export function useRandom(): () => number
32
+ {
33
+ return getWizardContext().random;
34
+ }
35
+
36
+ /**
37
+ * Get your current health (0-60). Wizard dies at 0.
38
+ */
39
+ export function useHealth(): number
40
+ {
41
+ return getWizardContext().health;
42
+ }
43
+
44
+ /**
45
+ * Get your current position as {x, y} in world coordinates (0-860).
46
+ *
47
+ * Clamped to [5, 855] — the ARENA bounds minus the wizard radius, NOT the playfield. That
48
+ * distinction is the difference between living and dying: the playfield is [30, 830] and
49
+ * everything outside it is lava, so you can walk straight out of the safe area and be
50
+ * killed. Nothing stops you.
51
+ *
52
+ * And the number you actually need is neither of those. Lava is tested against your EDGE,
53
+ * not your centre (`isInLava` checks `x + WIZARD_RADIUS > ARENA_MAX`), and this returns your
54
+ * CENTRE — so with a radius of 5 you die once your centre passes 825. The safe range to
55
+ * steer by is [35, 825]. Aiming for 828 because "the playfield is [30, 830]" is fatal, which
56
+ * is exactly what a real fight trace showed: LAVA_DEATH at (826, 430).
57
+ *
58
+ * (This previously quoted the playfield-sized bounds as the clamp, which told the reader
59
+ * they were safely fenced in. They are not — see docs-match-engine.test.ts.)
60
+ */
61
+ export function usePosition(): Position
62
+ {
63
+ const ctx = getWizardContext();
64
+ return {x: ctx.position.x, y: ctx.position.y};
65
+ }
66
+
67
+ /**
68
+ * Get your current velocity as {x, y} in units/tick.
69
+ * Max magnitude is 1 u/t when idle, 0.5 u/t when casting, 0 when channeling shield.
70
+ */
71
+ export function useVelocity(): Velocity
72
+ {
73
+ const ctx = getWizardContext();
74
+ return {x: ctx.velocity.x, y: ctx.velocity.y};
75
+ }
76
+
77
+ /**
78
+ * Get your current status:
79
+ * - 'idle': free to act
80
+ * - 'casting': casting a spell (missile, blink OR shield). Can move at 33% speed.
81
+ * - 'channeling': channeling shield. Cannot move. Cancel anytime with cancel().
82
+ * - 'gcd_locked': global cooldown after spell. Can move at full speed, but cannot cast.
83
+ */
84
+ export function useStatus(): 'idle' | 'casting' | 'channeling' | 'gcd_locked'
85
+ {
86
+ return getWizardContext().state;
87
+ }
88
+
89
+ /**
90
+ * Get ticks until you can start a new spell.
91
+ *
92
+ * Returns 0 when idle or channeling (shield can be canceled immediately).
93
+ * During casting: remaining cast ticks. During GCD: remaining GCD ticks.
94
+ *
95
+ * Note: 100 ticks = 1 second.
96
+ */
97
+ export function useTicksUntilReady(): number
98
+ {
99
+ const ctx = getWizardContext();
100
+
101
+ switch (ctx.state)
102
+ {
103
+ case 'idle':
104
+ return 0;
105
+ case 'casting':
106
+ // Remaining cast time
107
+ if (ctx.castDuration !== undefined && ctx.castProgress !== undefined)
108
+ {
109
+ return ctx.castDuration - ctx.castProgress;
110
+ }
111
+ return 0;
112
+ case 'channeling':
113
+ // Can cancel anytime, so 0
114
+ return 0;
115
+ case 'gcd_locked':
116
+ return ctx.gcdRemaining ?? 0;
117
+ default:
118
+ return 0;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get current shield block multiplier.
124
+ *
125
+ * Returns 0 if not channeling shield.
126
+ * Returns 0.3-0.9 if channeling: starts at 0.9 (blocks 90%), decays by 0.2/sec,
127
+ * minimum 0.3 (blocks 30%). The remaining damage gets through:
128
+ * actualDamage = incomingDamage × (1 - shieldStrength).
129
+ */
130
+ export function useShieldStrength(): number
131
+ {
132
+ const ctx = getWizardContext();
133
+
134
+ if (ctx.state !== 'channeling' || ctx.channelingSpell !== 'shield')
135
+ {
136
+ return 0;
137
+ }
138
+
139
+ // Calculate shield strength based on channel duration
140
+ const channelDuration = ctx.channelDuration ?? 0;
141
+ const decayPerTick = RULES.SHIELD_DECAY_PER_SECOND / TICKS_PER_SECOND;
142
+ const strength = RULES.SHIELD_MAX_BLOCK - (channelDuration * decayPerTick);
143
+ return Math.max(RULES.SHIELD_MIN_BLOCK, strength);
144
+ }
145
+
146
+ /**
147
+ * Get all projectiles currently in flight (yours and enemy's).
148
+ * Used for blink safety calculations and threat analysis.
149
+ */
150
+ export function useProjectiles(): ProjectileState[]
151
+ {
152
+ return getWizardContext().projectiles;
153
+ }
154
+
155
+ /**
156
+ * Get the config of the last missile you fired, or undefined if none fired yet.
157
+ * Used for the warmup system: consecutive similar missiles cast faster.
158
+ */
159
+ export function useLastMissileConfig(): import('../types.js').MissileConfig | undefined
160
+ {
161
+ return getWizardContext().lastMissileConfig;
162
+ }
163
+
164
+ /**
165
+ * Get blink cooldown remaining in ticks. Returns 0 if ready to blink.
166
+ *
167
+ * Cooldown scales with distance used:
168
+ * - 10 units → 100 ticks (1s); 100 units → 667 ticks; 300 → 2000 (20s)
169
+ * - The cooldown scales with DISTANCE, so a micro-blink is cheap and a full-range one is
170
+ * not. This said 100 units → ~100 ticks, which understated it by 6.7x.
171
+ * - 300 units (max range) → 2000 ticks (20s)
172
+ *
173
+ * Note: 100 ticks = 1 second.
174
+ */
175
+ export function useBlinkCooldown(): number
176
+ {
177
+ return getWizardContext().blinkCooldown;
178
+ }
179
+
180
+ /**
181
+ * Get currently casting spell, or null if not casting.
182
+ * Returns 'missile', 'shield', or 'blink'.
183
+ */
184
+ export function useCastingSpell(): 'missile' | 'shield' | 'blink' | null
185
+ {
186
+ const ctx = getWizardContext();
187
+ if (ctx.state !== 'casting')
188
+ {
189
+ return null;
190
+ }
191
+ return ctx.castingSpell ?? null;
192
+ }
193
+
194
+ /**
195
+ * Get cast progress as {current, total} in ticks, or null if not casting.
196
+ *
197
+ * current = ticks elapsed, total = ticks needed. When current >= total, spell fires.
198
+ * Note: 100 ticks = 1 second.
199
+ */
200
+ export function useCastProgress(): {current: number; total: number} | null
201
+ {
202
+ const ctx = getWizardContext();
203
+ if (ctx.state !== 'casting')
204
+ {
205
+ return null;
206
+ }
207
+ if (ctx.castProgress === undefined || ctx.castDuration === undefined)
208
+ {
209
+ return null;
210
+ }
211
+ return {current: ctx.castProgress, total: ctx.castDuration};
212
+ }
213
+
214
+ // ============================================================
215
+ // ENEMY STATE HOOK
216
+ // ============================================================
217
+
218
+ /**
219
+ * Get enemy wizard state.
220
+ *
221
+ * Returns position, velocity, health, status, casting spell, and shield strength.
222
+ * Note: you cannot see the enemy's missile configs or exact cooldown timers —
223
+ * only their status and what's visible on the field.
224
+ */
225
+ export function useEnemy(): EnemyState
226
+ {
227
+ const ctx = getWizardContext();
228
+ const enemy = ctx.enemies[0];
229
+
230
+ if (!enemy)
231
+ {
232
+ // No enemy - return default state
233
+ return {
234
+ position: {x: 0, y: 0},
235
+ velocity: {x: 0, y: 0},
236
+ health: 0,
237
+ status: 'idle',
238
+ castingSpell: null,
239
+ castProgress: 0,
240
+ castDuration: 0,
241
+ gcdRemaining: 0,
242
+ channelDuration: 0,
243
+ shieldStrength: 0,
244
+ };
245
+ }
246
+
247
+ // Calculate enemy shield strength
248
+ let shieldStrength = 0;
249
+ if (enemy.state === 'channeling' && enemy.channelingSpell === 'shield')
250
+ {
251
+ const channelDuration = enemy.channelDuration ?? 0;
252
+ const decayPerTick = RULES.SHIELD_DECAY_PER_SECOND / TICKS_PER_SECOND;
253
+ shieldStrength = Math.max(RULES.SHIELD_MIN_BLOCK, RULES.SHIELD_MAX_BLOCK - (channelDuration * decayPerTick));
254
+ }
255
+
256
+ return {
257
+ position: {x: enemy.position.x, y: enemy.position.y},
258
+ velocity: {x: enemy.velocity.x, y: enemy.velocity.y},
259
+ health: enemy.health,
260
+ status: enemy.state,
261
+ castingSpell: enemy.state === 'casting' ? (enemy.castingSpell ?? null) : null,
262
+ castProgress: enemy.castProgress ?? 0,
263
+ castDuration: enemy.castDuration ?? 0,
264
+ gcdRemaining: enemy.gcdRemaining ?? 0,
265
+ channelDuration: enemy.channelDuration ?? 0,
266
+ shieldStrength,
267
+ };
268
+ }
269
+
270
+ // ============================================================
271
+ // PROJECTILE HOOKS
272
+ // ============================================================
273
+
274
+ /**
275
+ * Get all your active (in-flight) projectiles.
276
+ * Each has position, rotation (degrees), speed (u/t), turnRate, remainingTicks.
277
+ */
278
+ export function useMyProjectiles(): ProjectileState[]
279
+ {
280
+ return getWizardContext().myProjectiles;
281
+ }
282
+
283
+ // ============================================================
284
+ // COMBAT TRACKING HOOKS
285
+ // ============================================================
286
+
287
+ /**
288
+ * Get total damage you've dealt this match.
289
+ */
290
+ export function useDamageDealt(): number
291
+ {
292
+ return getWizardContext().damageDealt;
293
+ }
294
+
295
+ /**
296
+ * Get total damage you've taken this match.
297
+ */
298
+ export function useDamageTaken(): number
299
+ {
300
+ return getWizardContext().damageTaken;
301
+ }
302
+
303
+ /**
304
+ * Get the tick number when you last took damage. Returns 0 if never hit.
305
+ * Compare with useTick() to get ticks since last hit.
306
+ */
307
+ export function useLastHitTick(): number
308
+ {
309
+ return getWizardContext().lastHitTick;
310
+ }
311
+
312
+ // ============================================================
313
+ // ARENA HOOKS
314
+ // ============================================================
315
+
316
+ /**
317
+ * Get arena dimensions. Default: {width: 860, height: 860} — the FULL arena, lava included.
318
+ * The safe playfield is [30, 830]; the outer 30 units on every side are lethal.
319
+ *
320
+ * Wizards are clamped to [5, 855] (arena bounds minus the radius of 5), which does not keep
321
+ * them out of the lava.
322
+ */
323
+ export function useArenaSize(): {width: number; height: number}
324
+ {
325
+ const ctx = getWizardContext();
326
+ return {width: ctx.arenaWidth, height: ctx.arenaHeight};
327
+ }
328
+
329
+ /**
330
+ * Get current game tick (starts at 0, increments each tick).
331
+ * 100 ticks = 1 second. Match ends at 30,000 ticks (5 minutes).
332
+ */
333
+ export function useTick(): number
334
+ {
335
+ return getWizardContext().tick;
336
+ }
337
+
338
+ // ============================================================
339
+ // THREAT ANALYSIS HOOKS
340
+ // ============================================================
341
+
342
+ /**
343
+ * Get analyzed threats from all incoming enemy projectiles.
344
+ * Sorted by ticksToImpact (soonest first). Only includes missiles within 500 units
345
+ * or that are predicted to hit.
346
+ *
347
+ * Each threat includes:
348
+ * - ticksToImpact: ticks until hit (Infinity if will miss)
349
+ * - willHit: true if missile hits your current position
350
+ * - canDodgeLeft/Right: whether strafing perpendicular to missile heading works
351
+ * - canOutrun: whether moving away from missile escapes it
352
+ * - bestDodgeDirection: {x, y} unit vector to dodge optimally, or null if undodgeable
353
+ * - canBlockInTime: whether you can raise shield before impact
354
+ * - ticksToStartShield: when to START channeling shield to block in time
355
+ */
356
+ export function useThreats(): AnalyzedThreat[]
357
+ {
358
+ const ctx = getWizardContext();
359
+ const ticksUntilReady = useTicksUntilReady();
360
+
361
+ // Memoize to avoid recalculating every call
362
+ return useMemo(() =>
363
+ {
364
+ return analyzeThreats(
365
+ ctx.position,
366
+ ctx.projectiles,
367
+ ctx.myProjectiles,
368
+ ticksUntilReady,
369
+ // Mid-CAST you are one cancel() away from being able to shield, so the analysis
370
+ // must not charge you the whole remainder of the cast. A GCD cannot be cancelled,
371
+ // so it keeps the pessimistic reading.
372
+ {canCancelCurrentCast: ctx.state === 'casting'},
373
+ );
374
+ }, [ctx.projectiles, ctx.myProjectiles, ctx.position.x, ctx.position.y, ticksUntilReady, ctx.state]);
375
+ }
376
+
377
+ /**
378
+ * Get the most imminent threat, or null if no threats.
379
+ * Shorthand for useThreats()[0].
380
+ */
381
+ export function useClosestThreat(): AnalyzedThreat | null
382
+ {
383
+ const threats = useThreats();
384
+ return threats[0] ?? null;
385
+ }
386
+
387
+ /**
388
+ * Get your missiles analyzed from the enemy's perspective.
389
+ * Useful to predict when enemy will shield/dodge your attacks.
390
+ */
391
+ export function useMyThreatsToEnemy(): AnalyzedThreat[]
392
+ {
393
+ const ctx = getWizardContext();
394
+ const enemy = ctx.enemies[0];
395
+
396
+ if (!enemy)
397
+ {
398
+ return [];
399
+ }
400
+
401
+ // Memoize to avoid recalculating every call
402
+ return useMemo(() =>
403
+ {
404
+ // Analyze from enemy's perspective
405
+ // Pass empty array for "enemy's own projectiles" since we're analyzing our missiles as threats TO them
406
+ return analyzeThreats(
407
+ enemy.position,
408
+ ctx.myProjectiles, // Our projectiles are their threats
409
+ [], // Enemy has no "own projectiles" in this context
410
+ 0, // We don't know enemy's ticksUntilReady
411
+ );
412
+ }, [ctx.myProjectiles, enemy.position.x, enemy.position.y]);
413
+ }