wowlab_engine_application/
encounter.rs1use 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#[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
74pub(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 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}