Skip to main content

forge/
encounter_debug.rs

1// #t(file: rust_alloc_in_loop) CLI report assembly is bounded by fixture targets and timeline events.
2// #t(file: rust_clone_in_loop) report rows own resolver/dictionary labels after their sources drop.
3// #t(file: rust_floating_point_eq) initial activation mirrors the canonical exact zero-time rules.
4
5#![expect(
6    clippy::cast_precision_loss,
7    reason = "telemetry damage counters are converted to diagnostic display values"
8)]
9
10//! Deterministic encounter fixture inspection and telemetry debug rendering.
11
12use 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;