Skip to main content

wowlab_engine_application/
encounter.rs

1//! Canonical authored-to-combat encounter resolution boundary.
2
3use wowlab_engine_ports::{
4    DynDataResolver, EncounterConstructionError, ResolvedEncounter, ResolvedEnemyCombatStats,
5    ResolvedEnemyEncounter, ResolvedEnemyIdentityMetadata,
6};
7use wowlab_types::sim::{EncounterDefinition, EnemyIdx};
8
9use crate::{EnemyStatResolutionError, EnemyStatResolver};
10
11wowlab_engine_macros::define_error! {
12/// Failure while resolving an authored encounter into combat-ready enemy definitions.
13#[derive(Debug)]
14#[non_exhaustive]
15pub struct EncounterResolutionError {
16    kind: EncounterResolutionErrorKind,
17}
18
19#[derive(Debug, thiserror::Error)]
20enum EncounterResolutionErrorKind {
21    #[error("invalid encounter definition: {0}")]
22    InvalidDefinition(#[from] wowlab_types::sim::EncounterValidationError),
23    #[error("failed to resolve enemy {enemy:?}: {source}")]
24    Enemy {
25        enemy: EnemyIdx,
26        #[source]
27        source: EnemyStatResolutionError,
28    },
29    #[error("enemy {enemy:?} has no resolved armor constant required by combat")]
30    MissingArmorConstant { enemy: EnemyIdx },
31    #[error("enemy {enemy:?} references an unavailable authored group")]
32    MissingAuthoredGroup { enemy: EnemyIdx },
33    #[error(transparent)]
34    Construction(#[from] EncounterConstructionError),
35}
36}
37
38impl EncounterResolutionError {
39    fn enemy(enemy: EnemyIdx, source: EnemyStatResolutionError) -> Self {
40        Self {
41            kind: EncounterResolutionErrorKind::Enemy { enemy, source },
42        }
43    }
44
45    fn missing_armor_constant(enemy: EnemyIdx) -> Self {
46        Self {
47            kind: EncounterResolutionErrorKind::MissingArmorConstant { enemy },
48        }
49    }
50
51    fn missing_authored_group(enemy: EnemyIdx) -> Self {
52        Self {
53            kind: EncounterResolutionErrorKind::MissingAuthoredGroup { enemy },
54        }
55    }
56}
57
58impl From<wowlab_types::sim::EncounterValidationError> for EncounterResolutionError {
59    fn from(error: wowlab_types::sim::EncounterValidationError) -> Self {
60        Self {
61            kind: EncounterResolutionErrorKind::InvalidDefinition(error),
62        }
63    }
64}
65
66impl From<EncounterConstructionError> for EncounterResolutionError {
67    fn from(error: EncounterConstructionError) -> Self {
68        Self {
69            kind: EncounterResolutionErrorKind::Construction(error),
70        }
71    }
72}
73
74/// Resolve every authored enemy in immutable dense [`EnemyIdx`] order.
75pub(crate) async fn resolve_encounter(
76    definition: EncounterDefinition,
77    resolver: &DynDataResolver<'_>,
78) -> Result<ResolvedEncounter, EncounterResolutionError> {
79    definition.validate()?;
80    let stat_resolver = EnemyStatResolver::new(resolver);
81    let mut enemies = Vec::with_capacity(definition.enemies.len());
82
83    for authored in &definition.enemies {
84        let stats = stat_resolver
85            .resolve(authored)
86            .await
87            .map_err(|source| EncounterResolutionError::enemy(authored.id, source))?;
88        let armor_constant = stats
89            .armor_constant()
90            .ok_or_else(|| EncounterResolutionError::missing_armor_constant(authored.id))?;
91        let resolved_identity = stats.identity();
92        let group = definition
93            .groups
94            .get(authored.group_id.0 as usize)
95            .ok_or_else(|| EncounterResolutionError::missing_authored_group(authored.id))?;
96        let identity = ResolvedEnemyIdentityMetadata::new(
97            authored.id,
98            // #t(rust_clone_in_loop) the proof owns resolver-produced identity metadata
99            resolved_identity.display_name.clone(),
100            resolved_identity.npc_id,
101            resolved_identity.classification,
102            resolved_identity.creature_type,
103        )?;
104        let combat_stats = ResolvedEnemyCombatStats::new(
105            authored.id,
106            stats.max_health(),
107            stats.armor(),
108            armor_constant,
109            stats.auto_attack_dps(),
110            stats.spell_damage(),
111        )?;
112
113        enemies.push(ResolvedEnemyEncounter::new(
114            authored,
115            &group.tags,
116            stats.level(),
117            identity,
118            combat_stats,
119        )?);
120    }
121
122    Ok(ResolvedEncounter::new(definition, enemies)?)
123}
124
125#[cfg(test)]
126mod tests {
127    #![allow(
128        clippy::float_cmp,
129        clippy::float_cmp_const,
130        reason = "serialized fixture round trips and authored exact constants require exact equality"
131    )]
132    use googletest::prelude::*;
133    use wowlab_engine_adapter_data::InMemoryResolver;
134    use wowlab_engine_ports::DynDataResolver;
135    use wowlab_types::{
136        data::{CreatureFlat, ExpectedStatFlat},
137        sim::{EncounterDefinition, EnemyHealthInput, EnemyIdentityInput, EnemyIdx, EnemyRole},
138    };
139
140    use super::{EncounterResolutionErrorKind, resolve_encounter};
141    use crate::EnemyStatField;
142
143    fn expected_stats() -> ExpectedStatFlat {
144        ExpectedStatFlat {
145            id: 586,
146            expansion_id: 11,
147            lvl: 80,
148            creature_health: 37_768.0,
149            creature_auto_attack_dps: 1_463.35,
150            creature_armor: 377.0,
151            armor_constant: 980.543,
152            creature_spell_damage: 65_851.0,
153            ..ExpectedStatFlat::default()
154        }
155    }
156
157    #[gtest]
158    #[tokio::test]
159    async fn encounter_failure_retains_the_nested_conversion_source_chain() -> Result<()> {
160        let mut definition = EncounterDefinition::patchwerk(300.0, 80, 11).or_fail()?;
161
162        definition.enemies[0].identity = EnemyIdentityInput::Npc { npc_id: u32::MAX };
163        let resolver = InMemoryResolver::new().with_expected_stats(expected_stats());
164
165        let error = resolve_encounter(definition, DynDataResolver::from_ref(&resolver))
166            .await
167            .err()
168            .or_fail()?;
169        let enemy_stats = std::error::Error::source(&error).or_fail()?;
170
171        verify_true!(enemy_stats.is::<crate::EnemyStatResolutionError>())?;
172        let conversion = enemy_stats.source().or_fail()?;
173
174        verify_true!(conversion.is::<std::num::TryFromIntError>())?;
175
176        Ok(())
177    }
178
179    #[gtest]
180    #[tokio::test]
181    async fn patchwerk_uses_the_general_enemy_resolution_path() -> Result<()> {
182        let definition = EncounterDefinition::patchwerk(300.0, 80, 11).or_fail()?;
183        let serialized = serde_json::to_value(&definition).or_fail()?;
184        let resolver = InMemoryResolver::new().with_expected_stats(expected_stats());
185
186        let resolved = resolve_encounter(definition, DynDataResolver::from_ref(&resolver))
187            .await
188            .or_fail()?;
189
190        verify_that!(resolved.primary_enemy().creature_armor(), eq(377.0))?;
191        verify_that!(
192            resolved.primary_enemy().armor_constant(),
193            eq(f64::from(980.543_f32))
194        )?;
195        verify_that!(resolved.definition().enemies[0].armor_override, eq(None))?;
196        verify_that!(
197            serde_json::to_value(resolved.definition()).or_fail()?,
198            eq(&serialized)
199        )?;
200
201        Ok(())
202    }
203
204    #[gtest]
205    #[tokio::test]
206    async fn missing_enemy_stats_report_the_immutable_enemy_identity() -> Result<()> {
207        let definition = EncounterDefinition::patchwerk(300.0, 80, 11).or_fail()?;
208        let resolver = InMemoryResolver::new();
209
210        let error = resolve_encounter(definition, DynDataResolver::from_ref(&resolver))
211            .await
212            .err()
213            .or_fail()?;
214
215        let EncounterResolutionErrorKind::Enemy { enemy, source } = error.kind else {
216            panic!("expected enemy-resolution failure");
217        };
218
219        verify_that!(enemy, eq(EnemyIdx::PRIMARY))?;
220        let data = source.into_data().or_fail()?;
221
222        verify_true!(data.is_missing(EnemyStatField::Armor))?;
223
224        Ok(())
225    }
226
227    #[gtest]
228    #[tokio::test]
229    async fn dense_two_enemy_encounter_resolves_identity_and_combat_stats_for_both() -> Result<()> {
230        let mut definition = EncounterDefinition::patchwerk(300.0, 80, 11).or_fail()?;
231
232        definition.groups[0].tags = vec!["training_pack".to_string()];
233        definition.enemies[0].tags = vec!["primary".to_string()];
234
235        let mut second = definition.enemies[0].clone();
236
237        second.id = EnemyIdx(1);
238        second.slug = "resolved_npc".to_string();
239        second.identity = EnemyIdentityInput::Npc { npc_id: 42 };
240        second.health = EnemyHealthInput::Fixed {
241            max_health: 50_000.0,
242        };
243        second.tags = vec!["add".to_string()];
244        second.role = EnemyRole::Normal;
245        definition.groups[0].enemy_ids.push(second.id);
246        definition.enemies.push(second);
247        definition.validate().or_fail()?;
248
249        let resolver = InMemoryResolver::new()
250            .with_expected_stats(expected_stats())
251            .with_creature(CreatureFlat {
252                id: 42,
253                name: "Resolved NPC".to_string(),
254                classification: 1,
255                creature_type: 7,
256                creature_family: 0,
257            });
258
259        let resolved = resolve_encounter(definition, DynDataResolver::from_ref(&resolver))
260            .await
261            .or_fail()?;
262        let primary = resolved.enemy(EnemyIdx::PRIMARY).or_fail()?;
263        let second = resolved.enemy(EnemyIdx(1)).or_fail()?;
264
265        verify_that!(primary.enemy(), eq(EnemyIdx::PRIMARY))?;
266        verify_that!(primary.display_name(), eq("Training Dummy"))?;
267        verify_that!(primary.max_health(), eq(1_000_000.0))?;
268        verify_that!(primary.enemy_tags(), eq(&[Box::<str>::from("primary")]))?;
269        verify_that!(second.enemy(), eq(EnemyIdx(1)))?;
270        verify_that!(second.display_name(), eq("Resolved NPC"))?;
271        verify_that!(second.npc_id(), eq(Some(42)))?;
272        verify_that!(second.classification(), eq(Some(1)))?;
273        verify_that!(second.creature_type(), eq(Some(7)))?;
274        verify_that!(second.max_health(), eq(50_000.0))?;
275        verify_that!(second.creature_armor(), eq(377.0))?;
276        verify_that!(second.armor_constant(), eq(f64::from(980.543_f32)))?;
277        verify_that!(
278            second.group_tags(),
279            eq(&[Box::<str>::from("training_pack")])
280        )?;
281        verify_true!(resolved.enemy(EnemyIdx(2)).is_none())?;
282
283        Ok(())
284    }
285}