Skip to main content

wowlab_engine_ports/
encounter.rs

1//! Data-resolved encounter proof consumed by combat construction.
2
3use wowlab_engine_gamedata::ResolvedGameData;
4use wowlab_types::sim::{
5    EncounterDefinition, EnemyDefinition, EnemyHealthInput, EnemyIdentityInput, EnemyIdx,
6    EnemyRole, GroupId, SpatialTransform,
7};
8
9use crate::EncounterConstructionError;
10
11const SOURCE_RELATIVE_TOLERANCE: f64 = 1.0e-6;
12
13/// Resolver-backed identity metadata for one enemy.
14#[derive(Clone, Debug, Eq, PartialEq)]
15// #t(rust_similar_structs) validated encounter-port identity is distinct from application inspection output
16pub struct ResolvedEnemyIdentityMetadata {
17    display_name: String,
18    npc_id: Option<u32>,
19    classification: Option<i32>,
20    creature_type: Option<i32>,
21}
22
23impl ResolvedEnemyIdentityMetadata {
24    /// Validates resolved identity metadata for an enemy.
25    /// # Errors
26    /// Returns an error when the display name is empty.
27    pub fn new(
28        enemy: EnemyIdx,
29        display_name: String,
30        npc_id: Option<u32>,
31        classification: Option<i32>,
32        creature_type: Option<i32>,
33    ) -> Result<Self, EncounterConstructionError> {
34        if display_name.is_empty() {
35            return Err(EncounterConstructionError::empty_resolved_display_name(
36                enemy,
37            ));
38        }
39
40        Ok(Self {
41            display_name,
42            npc_id,
43            classification,
44            creature_type,
45        })
46    }
47}
48
49/// Resolver-backed combat stats required to construct one enemy actor.
50#[derive(Clone, Copy, Debug, PartialEq)]
51pub struct ResolvedEnemyCombatStats {
52    max_health: f64,
53    armor: f64,
54    armor_constant: f64,
55    auto_attack_dps: Option<f64>,
56    spell_damage: Option<f64>,
57}
58
59impl ResolvedEnemyCombatStats {
60    /// Validates resolver-backed combat statistics for an enemy.
61    /// # Errors
62    /// Returns an error when a required statistic is non-finite or outside its valid range.
63    pub fn new(
64        enemy: EnemyIdx,
65        max_health: f64,
66        armor: f64,
67        armor_constant: f64,
68        auto_attack_dps: Option<f64>,
69        spell_damage: Option<f64>,
70    ) -> Result<Self, EncounterConstructionError> {
71        validate_resolved_stat(enemy, "max health", max_health, true)?;
72        validate_resolved_stat(enemy, "armor", armor, false)?;
73        validate_resolved_stat(enemy, "armor constant", armor_constant, true)?;
74
75        if let Some(value) = auto_attack_dps {
76            validate_resolved_stat(enemy, "auto-attack DPS", value, false)?;
77        }
78
79        if let Some(value) = spell_damage {
80            validate_resolved_stat(enemy, "spell damage", value, false)?;
81        }
82
83        Ok(Self {
84            max_health,
85            armor,
86            armor_constant,
87            auto_attack_dps,
88            spell_damage,
89        })
90    }
91}
92
93/// Resolver-backed static stats and authored runtime metadata for one enemy.
94#[derive(Clone, Debug, PartialEq)]
95pub struct ResolvedEnemyEncounter {
96    enemy: EnemyIdx,
97    display_name: String,
98    npc_id: Option<u32>,
99    classification: Option<i32>,
100    creature_type: Option<i32>,
101    level: u16,
102    health: EnemyHealthInput,
103    max_health: f64,
104    armor: f64,
105    armor_constant: f64,
106    auto_attack_dps: Option<f64>,
107    spell_damage: Option<f64>,
108    creature_aoe_avoidance_pct: f64,
109    initial_transform: SpatialTransform,
110    group_id: GroupId,
111    enemy_tags: Vec<Box<str>>,
112    group_tags: Vec<Box<str>>,
113    role: EnemyRole,
114    spawn_at_s: f64,
115}
116
117impl ResolvedEnemyEncounter {
118    /// Assemble one enemy from an authored definition and resolver-produced values.
119    /// # Errors
120    /// Returns an error when resolved metadata disagrees with the authored enemy.
121    pub fn new(
122        authored: &EnemyDefinition,
123        group_tags: &[String],
124        resolved_level: u16,
125        identity: ResolvedEnemyIdentityMetadata,
126        stats: ResolvedEnemyCombatStats,
127    ) -> Result<Self, EncounterConstructionError> {
128        if authored.level != resolved_level {
129            return Err(EncounterConstructionError::resolved_enemy_level_mismatch(
130                authored.id,
131                authored.level,
132                resolved_level,
133            ));
134        }
135
136        let authored_max_health = match authored.health {
137            EnemyHealthInput::Auto => None,
138            EnemyHealthInput::Fixed { max_health } => Some(max_health),
139            EnemyHealthInput::ScriptedLinear {
140                display_max_health, ..
141            } => Some(display_max_health),
142        };
143
144        if let Some(expected) = authored_max_health {
145            if expected.total_cmp(&stats.max_health) != std::cmp::Ordering::Equal {
146                return Err(EncounterConstructionError::resolved_health_model_mismatch(
147                    authored.id,
148                    expected,
149                    stats.max_health,
150                ));
151            }
152        }
153
154        Ok(Self {
155            enemy: authored.id,
156            display_name: identity.display_name,
157            npc_id: identity.npc_id,
158            classification: identity.classification,
159            creature_type: identity.creature_type,
160            level: authored.level,
161            health: authored.health.clone(),
162            max_health: stats.max_health,
163            armor: stats.armor,
164            armor_constant: stats.armor_constant,
165            auto_attack_dps: stats.auto_attack_dps,
166            spell_damage: stats.spell_damage,
167            creature_aoe_avoidance_pct: authored.creature_aoe_avoidance_pct,
168            initial_transform: authored.initial_transform,
169            group_id: authored.group_id,
170            enemy_tags: authored
171                .tags
172                .iter()
173                .cloned()
174                .map(String::into_boxed_str)
175                .collect(),
176            group_tags: group_tags
177                .iter()
178                .cloned()
179                .map(String::into_boxed_str)
180                .collect(),
181            role: authored.role,
182            spawn_at_s: authored.spawn_at_s,
183        })
184    }
185
186    #[must_use]
187    pub const fn enemy(&self) -> EnemyIdx {
188        self.enemy
189    }
190
191    #[must_use]
192    pub fn display_name(&self) -> &str {
193        &self.display_name
194    }
195
196    #[must_use]
197    pub const fn npc_id(&self) -> Option<u32> {
198        self.npc_id
199    }
200
201    #[must_use]
202    pub const fn classification(&self) -> Option<i32> {
203        self.classification
204    }
205
206    #[must_use]
207    pub const fn creature_type(&self) -> Option<i32> {
208        self.creature_type
209    }
210
211    #[must_use]
212    pub const fn level(&self) -> u16 {
213        self.level
214    }
215
216    #[must_use]
217    pub const fn health(&self) -> &EnemyHealthInput {
218        &self.health
219    }
220
221    #[must_use]
222    pub const fn max_health(&self) -> f64 {
223        self.max_health
224    }
225
226    #[must_use]
227    pub const fn creature_armor(&self) -> f64 {
228        self.armor
229    }
230
231    #[must_use]
232    pub const fn armor_constant(&self) -> f64 {
233        self.armor_constant
234    }
235
236    #[must_use]
237    pub const fn auto_attack_dps(&self) -> Option<f64> {
238        self.auto_attack_dps
239    }
240
241    #[must_use]
242    pub const fn spell_damage(&self) -> Option<f64> {
243        self.spell_damage
244    }
245
246    #[must_use]
247    pub const fn creature_aoe_avoidance_pct(&self) -> f64 {
248        self.creature_aoe_avoidance_pct
249    }
250
251    #[must_use]
252    pub const fn initial_transform(&self) -> SpatialTransform {
253        self.initial_transform
254    }
255
256    #[must_use]
257    pub const fn group_id(&self) -> GroupId {
258        self.group_id
259    }
260
261    #[must_use]
262    pub fn enemy_tags(&self) -> &[Box<str>] {
263        &self.enemy_tags
264    }
265
266    #[must_use]
267    pub fn group_tags(&self) -> &[Box<str>] {
268        &self.group_tags
269    }
270
271    #[must_use]
272    pub const fn role(&self) -> EnemyRole {
273        self.role
274    }
275
276    #[must_use]
277    pub const fn spawn_at_s(&self) -> f64 {
278        self.spawn_at_s
279    }
280}
281
282/// Validated, resolver-backed encounter accepted by the combat runtime.
283#[derive(Clone, Debug)]
284pub struct ResolvedEncounter {
285    definition: EncounterDefinition,
286    enemies: Vec<ResolvedEnemyEncounter>,
287}
288
289impl ResolvedEncounter {
290    /// Validate and assemble a complete combat-ready encounter in immutable [`EnemyIdx`] order.
291    /// # Errors
292    /// Returns an error when the definition is invalid or resolved enemies do not correspond to it.
293    pub fn new(
294        definition: EncounterDefinition,
295        enemies: Vec<ResolvedEnemyEncounter>,
296    ) -> Result<Self, EncounterConstructionError> {
297        definition.validate()?;
298
299        if enemies.len() != definition.enemies.len() {
300            return Err(EncounterConstructionError::resolved_enemy_count_mismatch(
301                definition.enemies.len(),
302                enemies.len(),
303            ));
304        }
305
306        for (index, (authored, resolved)) in definition.enemies.iter().zip(&enemies).enumerate() {
307            validate_resolved_enemy(&definition, index, authored, resolved)?;
308        }
309
310        Ok(Self {
311            definition,
312            enemies,
313        })
314    }
315
316    #[must_use]
317    pub const fn definition(&self) -> &EncounterDefinition {
318        &self.definition
319    }
320
321    #[must_use]
322    pub fn enemies(&self) -> &[ResolvedEnemyEncounter] {
323        &self.enemies
324    }
325
326    #[must_use]
327    pub fn enemy(&self, enemy: EnemyIdx) -> Option<&ResolvedEnemyEncounter> {
328        self.enemies
329            .get(enemy.as_usize())
330            .filter(|resolved| resolved.enemy == enemy)
331    }
332
333    /// Returns the validated primary enemy.
334    ///
335    /// # Panics
336    ///
337    /// Panics only if this value was constructed in violation of [`Self::new`]'s invariants.
338    #[must_use]
339    pub fn primary_enemy(&self) -> &ResolvedEnemyEncounter {
340        self.enemy(EnemyIdx::PRIMARY)
341            .expect("resolved encounters always contain their validated primary enemy")
342    }
343
344    #[must_use]
345    pub fn fight_duration_secs(&self) -> f64 {
346        self.definition
347            .fixed_duration_s
348            .unwrap_or_else(|| wowlab_types::sim::SimTime::MAX.as_secs_f64())
349    }
350
351    pub(crate) fn validate_game_data(
352        &self,
353        game_data: &ResolvedGameData,
354    ) -> Result<(), EncounterConstructionError> {
355        let primary = self.primary_enemy();
356        let enemy = primary.enemy();
357        let expected_armor = primary.creature_armor();
358        let combat_armor = game_data.creature_armor();
359
360        if combat_armor.total_cmp(&expected_armor) != std::cmp::Ordering::Equal {
361            return Err(EncounterConstructionError::combat_creature_armor_mismatch(
362                enemy,
363                expected_armor,
364                combat_armor,
365            ));
366        }
367
368        let armor_constant = game_data.armor_constant();
369
370        if !armor_constant.is_finite() || armor_constant <= 0.0 {
371            return Err(EncounterConstructionError::invalid_combat_armor_constant(
372                enemy,
373                armor_constant,
374            ));
375        }
376
377        let armor_constant_mod = game_data.armor_constant_mod();
378
379        if !armor_constant_mod.is_finite() || armor_constant_mod <= 0.0 {
380            return Err(
381                EncounterConstructionError::invalid_combat_armor_constant_mod(
382                    enemy,
383                    armor_constant_mod,
384                ),
385            );
386        }
387
388        let expected_k = primary.armor_constant();
389        let combat_k = game_data.armor_k();
390
391        if !combat_k.is_finite() || !relative_eq(combat_k, expected_k, SOURCE_RELATIVE_TOLERANCE) {
392            return Err(
393                EncounterConstructionError::combat_effective_armor_constant_mismatch(
394                    enemy,
395                    expected_k,
396                    combat_k,
397                    armor_constant,
398                    armor_constant_mod,
399                ),
400            );
401        }
402
403        Ok(())
404    }
405}
406
407fn validate_resolved_enemy(
408    definition: &EncounterDefinition,
409    index: usize,
410    authored: &EnemyDefinition,
411    resolved: &ResolvedEnemyEncounter,
412) -> Result<(), EncounterConstructionError> {
413    let expected_id = EnemyIdx(u16::try_from(index).map_err(|source| {
414        EncounterConstructionError::resolved_enemy_index_out_of_range(index, source)
415    })?);
416
417    if authored.id != expected_id || resolved.enemy != expected_id {
418        return Err(
419            EncounterConstructionError::resolved_enemy_identity_mismatch(
420                index,
421                authored.id,
422                resolved.enemy,
423            ),
424        );
425    }
426
427    if authored.level != resolved.level {
428        return Err(EncounterConstructionError::resolved_enemy_level_mismatch(
429            expected_id,
430            authored.level,
431            resolved.level,
432        ));
433    }
434
435    validate_resolved_identity(authored, resolved)?;
436
437    validate_resolved_metadata(definition, authored, resolved)
438}
439
440fn validate_resolved_identity(
441    authored: &EnemyDefinition,
442    resolved: &ResolvedEnemyEncounter,
443) -> Result<(), EncounterConstructionError> {
444    let matches = match &authored.identity {
445        EnemyIdentityInput::Anonymous { display_name } => {
446            resolved.npc_id.is_none() && resolved.display_name == *display_name
447        }
448        EnemyIdentityInput::Npc { npc_id } => resolved.npc_id == Some(*npc_id),
449    };
450
451    if matches {
452        Ok(())
453    } else {
454        Err(EncounterConstructionError::resolved_enemy_identity_metadata_mismatch(authored.id))
455    }
456}
457
458fn validate_resolved_metadata(
459    definition: &EncounterDefinition,
460    authored: &EnemyDefinition,
461    resolved: &ResolvedEnemyEncounter,
462) -> Result<(), EncounterConstructionError> {
463    let spawn_matches =
464        authored.spawn_at_s.total_cmp(&resolved.spawn_at_s) == std::cmp::Ordering::Equal;
465    let metadata_matches = authored.health == resolved.health
466        && authored.initial_transform == resolved.initial_transform
467        && authored.group_id == resolved.group_id
468        && authored
469            .tags
470            .iter()
471            .map(String::as_str)
472            .eq(resolved.enemy_tags.iter().map(Box::as_ref))
473        && authored.role == resolved.role
474        && spawn_matches;
475    let group_matches = definition
476        .groups
477        .get(authored.group_id.0 as usize)
478        .is_some_and(|group| {
479            group
480                .tags
481                .iter()
482                .map(String::as_str)
483                .eq(resolved.group_tags.iter().map(Box::as_ref))
484        });
485
486    if metadata_matches && group_matches {
487        Ok(())
488    } else {
489        Err(EncounterConstructionError::resolved_enemy_metadata_mismatch(authored.id))
490    }
491}
492
493fn validate_resolved_stat(
494    enemy: EnemyIdx,
495    field: &'static str,
496    value: f64,
497    strictly_positive: bool,
498) -> Result<(), EncounterConstructionError> {
499    let valid = value.is_finite()
500        && if strictly_positive {
501            value > 0.0
502        } else {
503            value >= 0.0
504        };
505
506    if valid {
507        Ok(())
508    } else {
509        Err(EncounterConstructionError::invalid_resolved_enemy_stat(
510            enemy, field, value,
511        ))
512    }
513}
514
515fn relative_eq(found: f64, expected: f64, tolerance: f64) -> bool {
516    (found - expected).abs() <= tolerance * found.abs().max(expected.abs())
517}