Skip to main content

wowlab_engine_application/enemies/
resolver.rs

1//! [`EnemyStatResolver`]: application-layer enemy-stat resolution over raw `DataResolver` lookups.
2
3use wowlab_engine_domain::dbc::{DifficultyId, EXPANSION_LEVEL_SCALING};
4use wowlab_engine_ports::{DataResolver, DynDataResolver};
5use wowlab_types::{
6    data::{ContentTuningXDifficultyFlat, ExpectedStatFlat},
7    sim::{DifficultyContext, EnemyDefinition, EnemyHealthInput, EnemyIdentityInput},
8};
9
10use super::{
11    error::{EnemyStatError, EnemyStatResolutionError},
12    provenance::{
13        EnemyStatField, EnemyStatProvenanceStep, OP_EMPTY_NAME_FALLBACK, OP_EXACT_KEY_LOOKUP,
14        OP_FOREIGN_KEY_LOOKUP, OP_MULTIPLY_ARMOR_CONSTANT_MOD, ProvenanceValue,
15        TABLE_CONTENT_TUNING_X_DIFFICULTY, TABLE_CONTENT_TUNING_X_EXPECTED, TABLE_CREATURE,
16        TABLE_CREATURE_DIFFICULTY, TABLE_EXPECTED_STAT, TABLE_EXPECTED_STAT_MOD,
17    },
18    result::{
19        EnemyStatInspection, ResolvedCreatureTuning, ResolvedEnemyIdentity, ResolvedEnemyStats,
20    },
21};
22
23/// Shared Mythic+ dungeon content tuning: every `MapDifficulty.ItemContext == 16` row carries it (J5).
24const MYTHIC_PLUS_CONTENT_TUNING_ID: i32 = 1279;
25/// Armor: explicit override, else the level-only base `CreatureArmor` row (J2/J11).
26fn resolve_armor(
27    enemy: &EnemyDefinition,
28    base: &BaseStatRow,
29    provenance: &mut Vec<EnemyStatProvenanceStep>,
30) -> Result<f64, EnemyStatResolutionError> {
31    if let Some(value) = enemy.armor_override {
32        provenance.push(EnemyStatProvenanceStep::ExplicitOverride {
33            field: EnemyStatField::Armor,
34            value,
35        });
36
37        return Ok(value);
38    }
39
40    base_component(
41        base,
42        EnemyStatField::Armor,
43        |row| row.creature_armor,
44        ValueRange::NonNegative,
45        provenance,
46    )
47}
48
49/// Application-layer enemy-stat resolution service over one data backend.
50pub struct EnemyStatResolver<'a> {
51    resolver: &'a DynDataResolver<'a>,
52}
53
54impl std::fmt::Debug for EnemyStatResolver<'_> {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("EnemyStatResolver").finish_non_exhaustive()
57    }
58}
59
60impl<'a> EnemyStatResolver<'a> {
61    #[must_use]
62    pub const fn new(resolver: &'a DynDataResolver<'a>) -> Self {
63        Self { resolver }
64    }
65
66    /// Resolve every component required for combat construction.
67    pub async fn resolve(
68        &self,
69        enemy: &EnemyDefinition,
70    ) -> Result<ResolvedEnemyStats, EnemyStatResolutionError> {
71        self.inspect(enemy).await?.into_resolved()
72    }
73
74    /// Inspect all components, returning a typed partial resolution.
75    pub async fn inspect(
76        &self,
77        enemy: &EnemyDefinition,
78    ) -> Result<EnemyStatInspection, EnemyStatResolutionError> {
79        let mut provenance = Vec::new();
80        let identity = component(
81            self.resolve_identity(&enemy.identity, &mut provenance)
82                .await,
83        )?;
84        let creature_tuning = match (&identity, &enemy.identity) {
85            (Ok(_), EnemyIdentityInput::Npc { npc_id }) => {
86                self.creature_tuning(*npc_id, &mut provenance).await?
87            }
88            _ => None,
89        };
90        let base = self.base_stat_row(enemy).await?;
91        let max_health = component(self.max_health(enemy, &base, &mut provenance).await)?;
92        let armor = component(resolve_armor(enemy, &base, &mut provenance))?;
93        let armor_constant = component(self.armor_constant(enemy, &base, &mut provenance).await)?;
94        let auto_attack_dps = component(base_budget(
95            &base,
96            enemy.auto_attack_dps_override,
97            EnemyStatField::AutoAttackDps,
98            |row| row.creature_auto_attack_dps,
99            &mut provenance,
100        ))?;
101        let spell_damage = component(base_budget(
102            &base,
103            enemy.spell_damage_override,
104            EnemyStatField::SpellDamage,
105            |row| row.creature_spell_damage,
106            &mut provenance,
107        ))?;
108
109        Ok(EnemyStatInspection {
110            identity,
111            creature_tuning,
112            level: enemy.level,
113            max_health,
114            armor,
115            armor_constant,
116            auto_attack_dps,
117            spell_damage,
118            provenance,
119        })
120    }
121
122    async fn resolve_identity(
123        &self,
124        input: &EnemyIdentityInput,
125        provenance: &mut Vec<EnemyStatProvenanceStep>,
126    ) -> Result<ResolvedEnemyIdentity, EnemyStatResolutionError> {
127        let npc_id = match input {
128            EnemyIdentityInput::Anonymous { display_name } => {
129                return Ok(ResolvedEnemyIdentity {
130                    display_name: display_name.clone(),
131                    npc_id: None,
132                    classification: None,
133                    creature_type: None,
134                });
135            }
136            EnemyIdentityInput::Npc { npc_id } => *npc_id,
137        };
138        let creature_id = to_i32("npc_id", npc_id)?;
139        let key = format!("{TABLE_CREATURE}(ID={creature_id})");
140        let creature = match self.resolver.get_creature(creature_id).await {
141            Ok(creature) => creature,
142            Err(error) if error.is_creature_not_found() => {
143                return Err(EnemyStatError::missing(EnemyStatField::DisplayName, key).into());
144            }
145            Err(source) => return Err(EnemyStatResolutionError::resolver(key, source)),
146        };
147        let input_key = format!("(ID={creature_id})");
148        let (display_name, name_operation) = if creature.name.is_empty() {
149            (format!("NPC {npc_id}"), OP_EMPTY_NAME_FALLBACK)
150        } else {
151            (creature.name.clone(), OP_EXACT_KEY_LOOKUP)
152        };
153
154        provenance.push(EnemyStatProvenanceStep::Lookup {
155            table: TABLE_CREATURE,
156            row_id: creature.id,
157            field: EnemyStatField::DisplayName,
158            input: input_key.clone(),
159            operation: name_operation,
160            output: ProvenanceValue::Text(display_name.clone()),
161        });
162        provenance.push(EnemyStatProvenanceStep::Lookup {
163            table: TABLE_CREATURE,
164            row_id: creature.id,
165            field: EnemyStatField::Classification,
166            input: input_key.clone(),
167            operation: OP_EXACT_KEY_LOOKUP,
168            output: ProvenanceValue::Number(f64::from(creature.classification)),
169        });
170        provenance.push(EnemyStatProvenanceStep::Lookup {
171            table: TABLE_CREATURE,
172            row_id: creature.id,
173            field: EnemyStatField::CreatureType,
174            input: input_key,
175            operation: OP_EXACT_KEY_LOOKUP,
176            output: ProvenanceValue::Number(f64::from(creature.creature_type)),
177        });
178
179        Ok(ResolvedEnemyIdentity {
180            display_name,
181            npc_id: Some(npc_id),
182            classification: Some(creature.classification),
183            creature_type: Some(creature.creature_type),
184        })
185    }
186
187    /// Record the creature-to-tuning join (J9) as metadata only when a single variant exists (A3).
188    async fn creature_tuning(
189        &self,
190        npc_id: u32,
191        provenance: &mut Vec<EnemyStatProvenanceStep>,
192    ) -> Result<Option<ResolvedCreatureTuning>, EnemyStatResolutionError> {
193        let creature_id = to_i32("npc_id", npc_id)?;
194        let rows = self
195            .resolver
196            .get_creature_difficulties(creature_id)
197            .await
198            .map_err(|source| {
199                EnemyStatResolutionError::resolver(
200                    format!("{TABLE_CREATURE_DIFFICULTY}(CreatureID={creature_id})"),
201                    source,
202                )
203            })?;
204        let [row] = rows.as_slice() else {
205            return Ok(None);
206        };
207        let mut steps = Vec::with_capacity(1);
208
209        steps.push(EnemyStatProvenanceStep::Lookup {
210            table: TABLE_CREATURE_DIFFICULTY,
211            row_id: row.id,
212            field: EnemyStatField::ContentTuning,
213            input: format!("(CreatureID={creature_id})"),
214            operation: OP_FOREIGN_KEY_LOOKUP,
215            output: ProvenanceValue::Number(f64::from(row.content_tuning_id)),
216        });
217        let mut mappings = self
218            .resolver
219            .get_content_tuning_x_difficulty(row.content_tuning_id)
220            .await
221            .map_err(|source| {
222                EnemyStatResolutionError::resolver(
223                    format!(
224                        "{TABLE_CONTENT_TUNING_X_DIFFICULTY}(ContentTuningID={})",
225                        row.content_tuning_id
226                    ),
227                    source,
228                )
229            })?;
230
231        mappings.sort_by_key(|mapping| mapping.id);
232        append_difficulty_provenance(row.content_tuning_id, &mappings, &mut steps);
233        provenance.extend(steps);
234
235        Ok(Some(ResolvedCreatureTuning {
236            creature_difficulty_row_id: row.id,
237            content_tuning_id: row.content_tuning_id,
238            difficulty_ids: mappings
239                .iter()
240                .map(|mapping| mapping.difficulty_id)
241                .collect(),
242        }))
243    }
244
245    /// Base `ExpectedStat` row: `Generic` uses exact `(expansion_id, level)` (J11), else `(-2, level)`.
246    async fn base_stat_row(
247        &self,
248        enemy: &EnemyDefinition,
249    ) -> Result<BaseStatRow, EnemyStatResolutionError> {
250        let expansion_id = match enemy.difficulty {
251            DifficultyContext::Generic { expansion_id } => to_i32("expansion_id", expansion_id)?,
252            _ => EXPANSION_LEVEL_SCALING,
253        };
254        let lvl = i32::from(enemy.level);
255        let input = format!("(ExpansionID={expansion_id}, Lvl={lvl})");
256
257        match self.resolver.get_expected_stats(expansion_id, lvl).await {
258            Ok(row) => Ok(BaseStatRow::Found { row, input }),
259            Err(error) if error.is_expected_stats_not_found() => Ok(BaseStatRow::Missing {
260                key: format!("{TABLE_EXPECTED_STAT}{input}"),
261            }),
262            Err(source) => Err(EnemyStatResolutionError::resolver(
263                format!("{TABLE_EXPECTED_STAT}{input}"),
264                source,
265            )),
266        }
267    }
268
269    async fn max_health(
270        &self,
271        enemy: &EnemyDefinition,
272        base: &BaseStatRow,
273        provenance: &mut Vec<EnemyStatProvenanceStep>,
274    ) -> Result<f64, EnemyStatResolutionError> {
275        let explicit = match enemy.health {
276            EnemyHealthInput::Fixed { max_health } => Some(max_health),
277            EnemyHealthInput::ScriptedLinear {
278                display_max_health, ..
279            } => Some(display_max_health),
280            EnemyHealthInput::Auto => None,
281        };
282
283        if let Some(value) = explicit {
284            provenance.push(EnemyStatProvenanceStep::ExplicitOverride {
285                field: EnemyStatField::MaxHealth,
286                value,
287            });
288
289            return Ok(value);
290        }
291
292        if let DifficultyContext::Generic { .. } = enemy.difficulty {
293            base_component(
294                base,
295                EnemyStatField::MaxHealth,
296                |row| row.creature_health,
297                ValueRange::StrictlyPositive,
298                provenance,
299            )
300        } else {
301            let error = self
302                .unproven_inference_error(EnemyStatField::MaxHealth, enemy)
303                .await?;
304
305            Err(error.into())
306        }
307    }
308
309    async fn armor_constant(
310        &self,
311        enemy: &EnemyDefinition,
312        base: &BaseStatRow,
313        provenance: &mut Vec<EnemyStatProvenanceStep>,
314    ) -> Result<f64, EnemyStatResolutionError> {
315        match enemy.difficulty {
316            DifficultyContext::Generic { .. } => base_component(
317                base,
318                EnemyStatField::ArmorConstant,
319                |row| row.armor_constant,
320                ValueRange::StrictlyPositive,
321                provenance,
322            ),
323            DifficultyContext::MythicPlus {
324                difficulty_id,
325                season_id,
326                ..
327            } if difficulty_id == DifficultyId::MythicPlus.as_u32() => {
328                self.mythic_plus_armor_constant(season_id, base, provenance)
329                    .await
330            }
331            _ => {
332                let error = self
333                    .unproven_inference_error(EnemyStatField::ArmorConstant, enemy)
334                    .await?;
335
336                Err(error.into())
337            }
338        }
339    }
340
341    /// Mythic+ armor constant (J3): base value times every season-selected `ExpectedStatMod` (J5/J6); empty set is missing data.
342    async fn mythic_plus_armor_constant(
343        &self,
344        season_id: u32,
345        base: &BaseStatRow,
346        provenance: &mut Vec<EnemyStatProvenanceStep>,
347    ) -> Result<f64, EnemyStatResolutionError> {
348        let season = to_i32("season_id", season_id)?;
349        let candidates_key = format!(
350            "{TABLE_CONTENT_TUNING_X_EXPECTED}(ContentTuningID={MYTHIC_PLUS_CONTENT_TUNING_ID})"
351        );
352        let rows = self
353            .resolver
354            .get_content_tuning_x_expected(MYTHIC_PLUS_CONTENT_TUNING_ID)
355            .await
356            .map_err(|source| EnemyStatResolutionError::resolver(candidates_key.clone(), source))?;
357        let mut selected: Vec<_> = rows
358            .into_iter()
359            .filter(|row| {
360                season_selects(
361                    row.min_mythic_plus_season_id,
362                    row.max_mythic_plus_season_id,
363                    season,
364                )
365            })
366            .collect();
367
368        selected.sort_by_key(|row| row.id);
369
370        if selected.is_empty() {
371            return Err(EnemyStatError::missing(
372                EnemyStatField::ArmorConstant,
373                format!("{candidates_key} MythicPlusSeasonID={season}"),
374            )
375            .into());
376        }
377
378        let mut steps = Vec::with_capacity(selected.len() + 1);
379        let mut value = base_component(
380            base,
381            EnemyStatField::ArmorConstant,
382            |row| row.armor_constant,
383            ValueRange::StrictlyPositive,
384            &mut steps,
385        )?;
386
387        for row in &selected {
388            // #t(rust_alloc_in_loop) each selected row renders its own error key
389            let mod_key = format!("{TABLE_EXPECTED_STAT_MOD}(ID={})", row.expected_stat_mod_id);
390            let stat_mod = match self
391                .resolver
392                .get_expected_stat_mod(row.expected_stat_mod_id)
393                .await
394            {
395                Ok(stat_mod) => stat_mod,
396                Err(error) if error.is_expected_stat_mod_not_found() => {
397                    return Err(
398                        EnemyStatError::missing(EnemyStatField::ArmorConstant, mod_key).into(),
399                    );
400                }
401                Err(source) => {
402                    return Err(EnemyStatResolutionError::resolver(mod_key, source));
403                }
404            };
405            let multiplier = f64::from(stat_mod.armor_constant_mod);
406
407            if !ValueRange::StrictlyPositive.accepts(multiplier) {
408                return Err(EnemyStatError::invalid(
409                    EnemyStatField::ArmorConstant,
410                    mod_key,
411                    multiplier,
412                )
413                .into());
414            }
415
416            steps.push(EnemyStatProvenanceStep::Lookup {
417                table: TABLE_EXPECTED_STAT_MOD,
418                row_id: stat_mod.id,
419                field: EnemyStatField::ArmorConstant,
420                // #t(rust_alloc_in_loop) each provenance step owns its rendered input key
421                input: format!(
422                    "(ContentTuningID={MYTHIC_PLUS_CONTENT_TUNING_ID}, \
423                     ContentTuningXExpectedID={}, MythicPlusSeasonID={season})",
424                    row.id
425                ),
426                operation: OP_MULTIPLY_ARMOR_CONSTANT_MOD,
427                output: ProvenanceValue::Number(multiplier),
428            });
429            value *= multiplier;
430        }
431
432        provenance.extend(steps);
433
434        Ok(value)
435    }
436
437    /// Typed error for an unproven non-generic inference; the creature walk only reports candidate rows (A1-A6).
438    async fn unproven_inference_error(
439        &self,
440        field: EnemyStatField,
441        enemy: &EnemyDefinition,
442    ) -> Result<EnemyStatError, EnemyStatResolutionError> {
443        let walk = matches!(
444            enemy.difficulty,
445            DifficultyContext::Dungeon { .. } | DifficultyContext::Raid { .. }
446        );
447
448        if let (true, EnemyIdentityInput::Npc { npc_id }) = (walk, &enemy.identity) {
449            let creature_id = to_i32("npc_id", *npc_id)?;
450            let rows = self
451                .resolver
452                .get_creature_difficulties(creature_id)
453                .await
454                .map_err(|source| {
455                    EnemyStatResolutionError::resolver(
456                        format!("{TABLE_CREATURE_DIFFICULTY}(CreatureID={creature_id})"),
457                        source,
458                    )
459                })?;
460
461            if rows.len() > 1 {
462                let mut candidate_row_ids: Vec<i32> = rows.iter().map(|row| row.id).collect();
463
464                candidate_row_ids.sort_unstable();
465
466                return Ok(EnemyStatError::ambiguous(field, candidate_row_ids));
467            }
468
469            if let [row] = rows.as_slice() {
470                let candidates = self
471                    .resolver
472                    .get_content_tuning_x_expected(row.content_tuning_id)
473                    .await
474                    .map_err(|source| {
475                        EnemyStatResolutionError::resolver(
476                            format!(
477                                "{TABLE_CONTENT_TUNING_X_EXPECTED}(ContentTuningID={})",
478                                row.content_tuning_id
479                            ),
480                            source,
481                        )
482                    })?;
483
484                if candidates.len() > 1 {
485                    let mut candidate_row_ids: Vec<i32> =
486                        candidates.iter().map(|candidate| candidate.id).collect();
487
488                    candidate_row_ids.sort_unstable();
489
490                    return Ok(EnemyStatError::ambiguous(field, candidate_row_ids));
491                }
492            }
493        }
494
495        Ok(EnemyStatError::missing(
496            field,
497            difficulty_key(&enemy.difficulty),
498        ))
499    }
500}
501
502enum BaseStatRow {
503    Found {
504        row: ExpectedStatFlat,
505        input: String,
506    },
507    Missing {
508        key: String,
509    },
510}
511
512#[derive(Clone, Copy)]
513enum ValueRange {
514    StrictlyPositive,
515    NonNegative,
516}
517
518impl ValueRange {
519    fn accepts(self, value: f64) -> bool {
520        value.is_finite()
521            && match self {
522                Self::StrictlyPositive => value > 0.0,
523                Self::NonNegative => value >= 0.0,
524            }
525    }
526}
527
528fn base_component(
529    base: &BaseStatRow,
530    field: EnemyStatField,
531    extract: fn(&ExpectedStatFlat) -> f32,
532    range: ValueRange,
533    steps: &mut Vec<EnemyStatProvenanceStep>,
534) -> Result<f64, EnemyStatResolutionError> {
535    match base {
536        BaseStatRow::Missing { key } => Err(EnemyStatError::missing(field, key.clone()).into()),
537        BaseStatRow::Found { row, input } => {
538            let value = f64::from(extract(row));
539
540            if !range.accepts(value) {
541                return Err(EnemyStatError::invalid(
542                    field,
543                    format!("{TABLE_EXPECTED_STAT}{input}"),
544                    value,
545                )
546                .into());
547            }
548
549            steps.push(EnemyStatProvenanceStep::Lookup {
550                table: TABLE_EXPECTED_STAT,
551                row_id: row.id,
552                field,
553                input: input.clone(),
554                operation: OP_EXACT_KEY_LOOKUP,
555                output: ProvenanceValue::Number(value),
556            });
557
558            Ok(value)
559        }
560    }
561}
562
563/// Damage budget: explicit override, else the proven base value; difficulty-modified budgets are never inferred (A7).
564fn base_budget(
565    base: &BaseStatRow,
566    override_value: Option<f64>,
567    field: EnemyStatField,
568    extract: fn(&ExpectedStatFlat) -> f32,
569    provenance: &mut Vec<EnemyStatProvenanceStep>,
570) -> Result<f64, EnemyStatResolutionError> {
571    if let Some(value) = override_value {
572        provenance.push(EnemyStatProvenanceStep::ExplicitOverride { field, value });
573
574        return Ok(value);
575    }
576
577    base_component(base, field, extract, ValueRange::NonNegative, provenance)
578}
579
580fn append_difficulty_provenance(
581    content_tuning_id: i32,
582    mappings: &[ContentTuningXDifficultyFlat],
583    steps: &mut Vec<EnemyStatProvenanceStep>,
584) {
585    let input = format!("(ContentTuningID={content_tuning_id})");
586
587    steps.extend(
588        mappings
589            .iter()
590            .map(|mapping| EnemyStatProvenanceStep::Lookup {
591                table: TABLE_CONTENT_TUNING_X_DIFFICULTY,
592                row_id: mapping.id,
593                field: EnemyStatField::Difficulty,
594                input: input.clone(),
595                operation: OP_FOREIGN_KEY_LOOKUP,
596                output: ProvenanceValue::Number(f64::from(mapping.difficulty_id)),
597            }),
598    );
599}
600
601/// J6 season bounds: `min <= season < max`, where `0` means unbounded.
602pub(super) const fn season_selects(min: i32, max: i32, season: i32) -> bool {
603    (min == 0 || season >= min) && (max == 0 || season < max)
604}
605
606fn difficulty_key(context: &DifficultyContext) -> String {
607    match context {
608        DifficultyContext::Generic { expansion_id } => {
609            format!("Generic(ExpansionID={expansion_id})")
610        }
611        DifficultyContext::Dungeon {
612            expansion_id,
613            difficulty_id,
614        } => format!("Dungeon(ExpansionID={expansion_id}, DifficultyID={difficulty_id})"),
615        DifficultyContext::Raid {
616            expansion_id,
617            difficulty_id,
618        } => format!("Raid(ExpansionID={expansion_id}, DifficultyID={difficulty_id})"),
619        DifficultyContext::MythicPlus {
620            expansion_id,
621            difficulty_id,
622            season_id,
623            keystone_level,
624        } => format!(
625            "MythicPlus(ExpansionID={expansion_id}, DifficultyID={difficulty_id}, \
626             MythicPlusSeasonID={season_id}, KeystoneLevel={keystone_level})"
627        ),
628    }
629}
630
631fn component<T>(
632    result: Result<T, EnemyStatResolutionError>,
633) -> Result<Result<T, EnemyStatError>, EnemyStatResolutionError> {
634    match result {
635        Ok(value) => Ok(Ok(value)),
636        Err(error) => match error.into_data() {
637            Ok(data) => Ok(Err(data)),
638            Err(fatal) => Err(fatal),
639        },
640    }
641}
642
643fn to_i32(name: &'static str, value: u32) -> Result<i32, EnemyStatResolutionError> {
644    i32::try_from(value)
645        .map_err(|source| EnemyStatResolutionError::input_out_of_range(name, value, source))
646}