1use 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#[derive(Clone, Debug, Eq, PartialEq)]
15pub 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 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#[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 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#[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 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#[derive(Clone, Debug)]
284pub struct ResolvedEncounter {
285 definition: EncounterDefinition,
286 enemies: Vec<ResolvedEnemyEncounter>,
287}
288
289impl ResolvedEncounter {
290 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 #[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}