1use 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
23const MYTHIC_PLUS_CONTENT_TUNING_ID: i32 = 1279;
25fn 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
49pub 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 pub async fn resolve(
68 &self,
69 enemy: &EnemyDefinition,
70 ) -> Result<ResolvedEnemyStats, EnemyStatResolutionError> {
71 self.inspect(enemy).await?.into_resolved()
72 }
73
74 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 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 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 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 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 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 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
563fn 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
601pub(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}