1#![expect(
6 clippy::cast_precision_loss,
7 reason = "telemetry damage counters are converted to diagnostic display values"
8)]
9
10use std::collections::BTreeMap;
13
14use anyhow::{Context, Result, ensure};
15use tabled::Tabled;
16use wowlab_common::output;
17use wowlab_engine_application::{
18 EnemyStatField, EnemyStatProvenanceStep, EnemyStatResolver, ProvenanceValue,
19};
20use wowlab_engine_ports::DynDataResolver;
21use wowlab_types::{
22 proto,
23 sim::{EncounterDefinition, EnemyDefinition},
24};
25
26use crate::encounter_fixture::EncounterFixture;
27
28const DAMAGE_SCALE: f64 = 10.0;
29
30#[derive(Clone, Debug)]
31pub(crate) struct EncounterDebugReport {
32 pub(crate) fixture: EncounterFixture,
33 pub(crate) definition: EncounterDefinition,
34 pub(crate) targets: Vec<TargetDebug>,
35 health_provenance: Vec<HealthProvenanceDebug>,
36 timeline: Vec<EncounterTimelineDebug>,
37 pub(crate) encounter_events: Vec<proto::EncounterTimelineEvent>,
38}
39
40#[derive(Clone, Debug)]
41pub(crate) struct TargetDebug {
42 pub(crate) id: u32,
43 pub(crate) label: String,
44 npc_id: Option<u32>,
45 group: String,
46 max_health: f64,
47 initial_state: ActorState,
48 final_state: ActorState,
49 damage: f64,
50}
51
52#[derive(Clone, Debug)]
53struct HealthProvenanceDebug {
54 target_id: u32,
55 source: String,
56 row: String,
57 operation: String,
58 input: String,
59 output: String,
60}
61
62#[derive(Clone, Copy, Debug)]
63struct ActorState {
64 active: bool,
65 alive: bool,
66 transform: wowlab_types::sim::SpatialTransform,
67}
68
69#[derive(Clone, Debug)]
70struct EncounterTimelineDebug {
71 time_ms: u32,
72 sequence: u64,
73 kind: String,
74 source: String,
75 target: String,
76 pull: String,
77 group: String,
78 position: String,
79 detail: String,
80}
81
82#[derive(Tabled)]
83struct TargetRow {
84 #[tabled(rename = "Target")]
85 target: String,
86 #[tabled(rename = "NPC ID")]
87 npc_id: String,
88 #[tabled(rename = "Group")]
89 group: String,
90 #[tabled(rename = "Resolved Health")]
91 max_health: String,
92 #[tabled(rename = "Start")]
93 initial_state: String,
94 #[tabled(rename = "End")]
95 final_state: String,
96 #[tabled(rename = "Position")]
97 position: String,
98 #[tabled(rename = "Damage")]
99 damage: String,
100}
101
102#[derive(Tabled)]
103struct ProvenanceRow {
104 #[tabled(rename = "Target")]
105 target: String,
106 #[tabled(rename = "Source")]
107 source: String,
108 #[tabled(rename = "Row")]
109 row: String,
110 #[tabled(rename = "Operation")]
111 operation: String,
112 #[tabled(rename = "Input")]
113 input: String,
114 #[tabled(rename = "Output")]
115 output: String,
116}
117
118#[derive(Tabled)]
119struct TimelineRow {
120 #[tabled(rename = "Time")]
121 time: String,
122 #[tabled(rename = "Event")]
123 kind: String,
124 #[tabled(rename = "Source")]
125 source: String,
126 #[tabled(rename = "Target")]
127 target: String,
128 #[tabled(rename = "Pull")]
129 pull: String,
130 #[tabled(rename = "Group")]
131 group: String,
132 #[tabled(rename = "Position")]
133 position: String,
134 #[tabled(rename = "Detail")]
135 detail: String,
136}
137
138pub(crate) async fn build_report(
139 fixture: EncounterFixture,
140 telemetry: &proto::ChunkTelemetry,
141 resolver: &DynDataResolver<'_>,
142) -> Result<EncounterDebugReport> {
143 let definition = fixture.definition()?;
144 let dictionary = telemetry
145 .dictionary
146 .as_ref()
147 .context("fixture telemetry omitted the target dictionary")?;
148 let target_entries: BTreeMap<_, _> = dictionary
149 .targets
150 .iter()
151 .map(|target| (target.id, target))
152 .collect();
153 let damage_by_target: BTreeMap<_, _> = telemetry
154 .damage_profile
155 .as_ref()
156 .map(|profile| {
157 profile
158 .by_target
159 .iter()
160 .map(|damage| {
161 (
162 damage.target_id,
163 damage.total_damage_x10 as f64 / DAMAGE_SCALE,
164 )
165 })
166 .collect()
167 })
168 .unwrap_or_default();
169 let representative = telemetry
170 .representative
171 .as_ref()
172 .context("fixture telemetry omitted the representative timeline")?;
173 let initial_states = authored_initial_states(&definition);
174 let mut states = initial_states.clone();
175
176 for event in &representative.encounter_events {
177 apply_lifecycle_state(event, &mut states);
178 tokio::task::yield_now().await;
179 }
180
181 let stat_resolver = EnemyStatResolver::new(resolver);
182 let mut targets = Vec::with_capacity(definition.enemies.len());
183 let mut health_provenance = Vec::with_capacity(definition.enemies.len());
184
185 for enemy in &definition.enemies {
186 let target_id = u32::from(enemy.id.0);
187 let resolved_stats = stat_resolver
188 .resolve(enemy)
189 .await
190 .with_context(|| format!("failed to resolve fixture target {target_id}"))?;
191 let dictionary_target = target_entries
192 .get(&target_id)
193 .with_context(|| format!("target dictionary omitted fixture target {target_id}"))?;
194
195 ensure!(
196 dictionary_target.label == resolved_stats.identity().display_name,
197 "target {target_id} dictionary label disagrees with resolved identity"
198 );
199 ensure!(
200 dictionary_target.npc_id == resolved_stats.identity().npc_id,
201 "target {target_id} dictionary NPC ID disagrees with resolved identity"
202 );
203 let group = definition
204 .groups
205 .get(enemy.group_id.as_usize())
206 .filter(|group| group.id == enemy.group_id)
207 .with_context(|| format!("target {target_id} references a missing group"))?;
208 let initial_state = *initial_states
209 .get(&target_id)
210 .with_context(|| format!("target {target_id} has no initial state"))?;
211 let final_state = *states
212 .get(&target_id)
213 .with_context(|| format!("target {target_id} has no final state"))?;
214
215 let target_health_provenance_start = health_provenance.len();
216
217 for step in resolved_stats
218 .provenance()
219 .iter()
220 .filter(|step| provenance_field(step) == Some(EnemyStatField::MaxHealth))
221 {
222 health_provenance.push(format_provenance(target_id, step));
223 tokio::task::yield_now().await;
224 }
225
226 ensure!(
227 health_provenance.len() > target_health_provenance_start,
228 "resolved fixture target {target_id} omitted health provenance"
229 );
230 targets.push(TargetDebug {
231 id: target_id,
232 label: resolved_stats.identity().display_name.clone(),
233 npc_id: resolved_stats.identity().npc_id,
234 group: format!("{}:{}", group.id.0, group.slug),
235 max_health: resolved_stats.max_health(),
236 initial_state,
237 final_state,
238 damage: damage_by_target
239 .get(&target_id)
240 .copied()
241 .unwrap_or_default(),
242 });
243 }
244
245 let timeline = build_timeline(dictionary, representative);
246 let encounter_events = representative.encounter_events.clone();
247
248 Ok(EncounterDebugReport {
249 fixture,
250 definition,
251 targets,
252 health_provenance,
253 timeline,
254 encounter_events,
255 })
256}
257
258fn authored_initial_states(definition: &EncounterDefinition) -> BTreeMap<u32, ActorState> {
259 definition
260 .enemies
261 .iter()
262 .map(|enemy| {
263 (
264 u32::from(enemy.id.0),
265 ActorState {
266 active: starts_active_at_encounter_start(definition, enemy),
267 alive: true,
268 transform: enemy.initial_transform,
269 },
270 )
271 })
272 .collect()
273}
274
275pub(crate) fn starts_active_at_encounter_start(
276 definition: &EncounterDefinition,
277 enemy: &EnemyDefinition,
278) -> bool {
279 if enemy.spawn_at_s != 0.0 {
280 return false;
281 }
282
283 let Some(first_pull) = definition.pulls.first() else {
284 return false;
285 };
286 let Some(group) = definition
287 .groups
288 .get(enemy.group_id.as_usize())
289 .filter(|group| group.id == enemy.group_id)
290 else {
291 return false;
292 };
293 let Some(wave) = definition
294 .waves
295 .get(group.wave_id.as_usize())
296 .filter(|wave| wave.id == group.wave_id)
297 else {
298 return false;
299 };
300
301 group.enemy_ids.contains(&enemy.id)
302 && wave.group_ids.contains(&group.id)
303 && first_pull.wave_ids.contains(&wave.id)
304 && wave.pull_id == first_pull.id
305 && wave.minimum_activation_s == 0.0
306 && wave.depends_on_groups.is_empty()
307}
308
309fn provenance_field(step: &EnemyStatProvenanceStep) -> Option<EnemyStatField> {
310 match step {
311 EnemyStatProvenanceStep::Lookup { field, .. }
312 | EnemyStatProvenanceStep::ExplicitOverride { field, .. } => Some(*field),
313 _ => None,
314 }
315}
316
317fn format_provenance(target_id: u32, step: &EnemyStatProvenanceStep) -> HealthProvenanceDebug {
318 match step {
319 EnemyStatProvenanceStep::Lookup {
320 table,
321 row_id,
322 operation,
323 input,
324 output,
325 ..
326 } => HealthProvenanceDebug {
327 target_id,
328 source: (*table).to_string(),
329 row: row_id.to_string(),
330 operation: (*operation).to_string(),
331 input: input.clone(),
332 output: format_provenance_value(output),
333 },
334 EnemyStatProvenanceStep::ExplicitOverride { value, .. } => HealthProvenanceDebug {
335 target_id,
336 source: "authored input".to_string(),
337 row: "-".to_string(),
338 operation: "explicit override".to_string(),
339 input: "-".to_string(),
340 output: format_number(*value),
341 },
342 _ => HealthProvenanceDebug {
343 target_id,
344 source: "unknown".to_string(),
345 row: "-".to_string(),
346 operation: "unknown".to_string(),
347 input: "-".to_string(),
348 output: "-".to_string(),
349 },
350 }
351}
352
353fn format_provenance_value(value: &ProvenanceValue) -> String {
354 match value {
355 ProvenanceValue::Number(value) => format_number(*value),
356 ProvenanceValue::Text(value) => value.clone(),
357 _ => "-".to_string(),
358 }
359}
360
361fn apply_lifecycle_state(
362 event: &proto::EncounterTimelineEvent,
363 states: &mut BTreeMap<u32, ActorState>,
364) {
365 if event.actor_kind != proto::ActorKind::Enemy as i32 {
366 return;
367 }
368
369 let Some(state) = states.get_mut(&event.actor_id) else {
370 return;
371 };
372
373 match proto::EncounterEventKind::try_from(event.kind) {
374 Ok(proto::EncounterEventKind::Spawn) => {
375 state.active = true;
376 state.alive = true;
377 }
378 Ok(proto::EncounterEventKind::Death) => {
379 state.active = false;
380 state.alive = false;
381 }
382 Ok(proto::EncounterEventKind::Despawn) => state.active = false,
383 Ok(
384 proto::EncounterEventKind::Movement
385 | proto::EncounterEventKind::TargetChange
386 | proto::EncounterEventKind::PullTransition
387 | proto::EncounterEventKind::Unspecified,
388 )
389 | Err(_) => {}
390 }
391
392 if let (Some(x), Some(y), Some(heading), Some(layer_id)) =
393 (event.x, event.y, event.heading, event.layer_id)
394 {
395 let Ok(layer) = u16::try_from(layer_id) else {
396 return;
397 };
398
399 state.transform = wowlab_types::sim::SpatialTransform {
400 layer: wowlab_types::sim::SpatialLayerId(layer),
401 position: wowlab_types::sim::Position2 { x, y },
402 heading,
403 };
404 }
405}
406
407fn build_timeline(
408 dictionary: &proto::DictionaryView,
409 representative: &proto::TimelineSnapshot,
410) -> Vec<EncounterTimelineDebug> {
411 let mut rows = Vec::new();
412 let mut absolute_ms = 0_u32;
413 let has_causal_sequence = representative
414 .markers
415 .iter()
416 .any(|marker| marker.sequence != 0)
417 || representative
418 .encounter_events
419 .iter()
420 .any(|event| event.sequence != 0);
421
422 for (offset, marker) in representative.markers.iter().enumerate() {
423 absolute_ms = absolute_ms.saturating_add(marker.delta_time_ms);
424 let kind =
425 proto::MarkerKind::try_from(marker.kind).unwrap_or(proto::MarkerKind::Unspecified);
426
427 if !matches!(kind, proto::MarkerKind::Cast | proto::MarkerKind::Damage) {
428 continue;
429 }
430
431 rows.push(EncounterTimelineDebug {
432 time_ms: absolute_ms,
433 sequence: if has_causal_sequence {
434 marker.sequence
435 } else {
436 u64::try_from(offset).expect("timeline marker count fits u64")
437 },
438 kind: marker_kind_label(kind).to_string(),
439 source: actor_label(dictionary, marker.source_kind, marker.source_id),
440 target: target_label(dictionary, marker.target_id),
441 pull: marker
442 .pull_id
443 .map_or_else(|| "-".to_string(), |id| id.to_string()),
444 group: marker
445 .group_id
446 .map_or_else(|| "-".to_string(), |id| id.to_string()),
447 position: "-".to_string(),
448 detail: if kind == proto::MarkerKind::Damage {
449 format!(
450 "spell={} amount={}{}",
451 marker.spell_or_aura_id,
452 marker.amount,
453 if marker.is_crit { " crit" } else { "" }
454 )
455 } else {
456 format!("spell={}", marker.spell_or_aura_id)
457 },
458 });
459 }
460
461 let marker_count =
462 u64::try_from(representative.markers.len()).expect("timeline marker count fits u64");
463
464 for (offset, event) in representative.encounter_events.iter().enumerate() {
465 let kind = proto::EncounterEventKind::try_from(event.kind)
466 .unwrap_or(proto::EncounterEventKind::Unspecified);
467
468 rows.push(EncounterTimelineDebug {
469 time_ms: event.time_ms,
470 sequence: if has_causal_sequence {
471 event.sequence
472 } else {
473 marker_count + u64::try_from(offset).expect("encounter event count fits u64")
474 },
475 kind: encounter_kind_label(kind).to_string(),
476 source: actor_label(dictionary, event.actor_kind, event.actor_id),
477 target: event.target_id.map_or_else(
478 || "-".to_string(),
479 |target| target_label(dictionary, target),
480 ),
481 pull: event.pull_id.to_string(),
482 group: event
483 .group_id
484 .map_or_else(|| "-".to_string(), |id| id.to_string()),
485 position: event_position(event),
486 detail: event.previous_target_id.map_or_else(
487 || "-".to_string(),
488 |target| format!("previous={}", target_label(dictionary, target)),
489 ),
490 });
491 }
492
493 rows.sort_by_key(|row| (row.time_ms, row.sequence));
494
495 rows
496}
497
498const fn marker_kind_label(kind: proto::MarkerKind) -> &'static str {
499 match kind {
500 proto::MarkerKind::Cast => "cast",
501 proto::MarkerKind::Damage => "damage",
502 proto::MarkerKind::Resource => "resource",
503 proto::MarkerKind::Proc => "proc",
504 proto::MarkerKind::Unspecified => "unspecified",
505 }
506}
507
508const fn encounter_kind_label(kind: proto::EncounterEventKind) -> &'static str {
509 match kind {
510 proto::EncounterEventKind::Spawn => "spawn",
511 proto::EncounterEventKind::Movement => "movement",
512 proto::EncounterEventKind::Death => "death",
513 proto::EncounterEventKind::Despawn => "despawn",
514 proto::EncounterEventKind::TargetChange => "target_change",
515 proto::EncounterEventKind::PullTransition => "pull_transition",
516 proto::EncounterEventKind::Unspecified => "unspecified",
517 }
518}
519
520fn actor_label(dictionary: &proto::DictionaryView, kind: i32, id: u32) -> String {
521 let unit = dictionary
522 .units
523 .iter()
524 .find(|unit| unit.kind == kind && unit.id == id);
525
526 unit.map_or_else(
527 || match proto::ActorKind::try_from(kind) {
528 Ok(proto::ActorKind::Player) => "Player".to_string(),
529 Ok(proto::ActorKind::Pet) => format!("pet {id}"),
530 Ok(proto::ActorKind::Enemy) => target_label(dictionary, id),
531 Ok(proto::ActorKind::Unspecified) | Err(_) => format!("unknown {id}"),
532 },
533 |unit| match proto::ActorKind::try_from(kind) {
534 Ok(proto::ActorKind::Player) => unit.label.clone(),
535 Ok(proto::ActorKind::Pet) => format!("pet {id}:{}", unit.label),
536 Ok(proto::ActorKind::Enemy) => format!("target {id}:{}", unit.label),
537 Ok(proto::ActorKind::Unspecified) | Err(_) => {
538 format!("unknown {id}:{}", unit.label)
539 }
540 },
541 )
542}
543
544fn target_label(dictionary: &proto::DictionaryView, id: u32) -> String {
545 let target = dictionary.targets.iter().find(|target| target.id == id);
546
547 target.map_or_else(
548 || format!("target {id}"),
549 |target| format!("{id}:{}", target.label),
550 )
551}
552
553fn event_position(event: &proto::EncounterTimelineEvent) -> String {
554 match (event.layer_id, event.x, event.y, event.heading) {
555 (Some(layer), Some(x), Some(y), Some(heading)) => {
556 format!("L{layer} ({x:.3}, {y:.3}) h={heading:.3}")
557 }
558 _ => "-".to_string(),
559 }
560}
561
562fn format_transform(transform: wowlab_types::sim::SpatialTransform) -> String {
563 format!(
564 "L{} ({:.3}, {:.3}) h={:.3}",
565 transform.layer.0, transform.position.x, transform.position.y, transform.heading
566 )
567}
568
569const fn state_label(state: ActorState) -> &'static str {
570 match (state.active, state.alive) {
571 (true, true) => "active/alive",
572 (false, true) => "inactive/alive",
573 (false, false) => "inactive/dead",
574 (true, false) => "active/dead",
575 }
576}
577
578fn format_number(value: f64) -> String {
579 if value.fract().abs() <= f64::EPSILON {
580 format!("{value:.0}")
581 } else {
582 format!("{value:.6}")
583 }
584}
585
586pub(crate) fn print_report(report: &EncounterDebugReport) {
587 output::blank();
588 output::header(&format!("Encounter Fixture: {}", report.fixture.slug()));
589 output::subheader("Resolved Targets and Runtime State");
590 output::table(report.targets.iter().map(|target| {
591 TargetRow {
592 target: format!("{}:{}", target.id, target.label),
593 npc_id: target
594 .npc_id
595 .map_or_else(|| "-".to_string(), |id| id.to_string()),
596 group: target.group.clone(),
597 max_health: format_number(target.max_health),
598 initial_state: state_label(target.initial_state).to_string(),
599 final_state: state_label(target.final_state).to_string(),
600 position: format_transform(target.final_state.transform),
601 damage: format!("{:.1}", target.damage),
602 }
603 }));
604
605 output::blank();
606 output::subheader("Resolved Health Provenance");
607 output::table(report.health_provenance.iter().map(|step| ProvenanceRow {
608 target: target_name(report, step.target_id),
609 source: step.source.clone(),
610 row: step.row.clone(),
611 operation: step.operation.clone(),
612 input: step.input.clone(),
613 output: step.output.clone(),
614 }));
615
616 output::blank();
617 output::subheader("Spatial / Target Timeline");
618 output::table(timeline_rows(&report.timeline));
619}
620
621fn timeline_rows(timeline: &[EncounterTimelineDebug]) -> Vec<TimelineRow> {
622 timeline
623 .iter()
624 .map(|event| TimelineRow {
625 time: format!("{:.3}", f64::from(event.time_ms) / 1000.0),
626 kind: event.kind.clone(),
627 source: event.source.clone(),
628 target: event.target.clone(),
629 pull: event.pull.clone(),
630 group: event.group.clone(),
631 position: event.position.clone(),
632 detail: event.detail.clone(),
633 })
634 .collect()
635}
636
637fn target_name(report: &EncounterDebugReport, id: u32) -> String {
638 let target = report.targets.iter().find(|target| target.id == id);
639
640 target.map_or_else(
641 || id.to_string(),
642 |target| format!("{}:{}", id, target.label),
643 )
644}
645
646#[cfg(test)]
647mod tests;
648
649#[cfg(test)]
650mod timeline_tests;