Skip to main content

wowlab_engine_ports/
error.rs

1wowlab_engine_macros::define_error! {
2/// Failures while turning an authored encounter into combat-ready data.
3#[derive(Debug)]
4pub struct EncounterConstructionError {
5    #[source]
6    kind: EncounterConstructionErrorKind,
7}
8
9#[derive(Debug, thiserror::Error)]
10enum EncounterConstructionErrorKind {
11    #[error("invalid encounter definition: {0}")]
12    InvalidDefinition(#[from] wowlab_types::sim::EncounterValidationError),
13    #[error("resolved encounter enemy count mismatch: expected {expected}, found {found}")]
14    ResolvedEnemyCountMismatch { expected: usize, found: usize },
15    #[error("resolved encounter enemy index {index} does not fit EnemyIdx")]
16    ResolvedEnemyIndexOutOfRange {
17        index: usize,
18        #[source]
19        source: std::num::TryFromIntError,
20    },
21    #[error(
22        "resolved encounter identity mismatch at position {index}: authored {authored:?}, resolved {resolved:?}"
23    )]
24    ResolvedEnemyIdentityMismatch {
25        index: usize,
26        authored: wowlab_types::sim::EnemyIdx,
27        resolved: wowlab_types::sim::EnemyIdx,
28    },
29    #[error("resolved enemy {enemy:?} level mismatch: expected {expected}, found {found}")]
30    ResolvedEnemyLevelMismatch {
31        enemy: wowlab_types::sim::EnemyIdx,
32        expected: u16,
33        found: u16,
34    },
35    #[error("resolved enemy {enemy:?} does not match its authored identity input")]
36    ResolvedEnemyIdentityMetadataMismatch { enemy: wowlab_types::sim::EnemyIdx },
37    #[error("resolved enemy {enemy:?} does not match its authored runtime metadata")]
38    ResolvedEnemyMetadataMismatch { enemy: wowlab_types::sim::EnemyIdx },
39    #[error("resolved enemy {enemy:?} health model requires max health {expected}, found {found}")]
40    ResolvedHealthModelMismatch {
41        enemy: wowlab_types::sim::EnemyIdx,
42        expected: f64,
43        found: f64,
44    },
45    #[error("resolved enemy {enemy:?} has an empty display name")]
46    EmptyResolvedDisplayName { enemy: wowlab_types::sim::EnemyIdx },
47    #[error("resolved enemy {enemy:?} has invalid {field}: {found}")]
48    InvalidResolvedEnemyStat {
49        enemy: wowlab_types::sim::EnemyIdx,
50        field: &'static str,
51        found: f64,
52    },
53    #[error(
54        "combat creature armor for enemy {enemy:?} does not match resolved armor: expected {expected}, found {found}"
55    )]
56    CombatCreatureArmorMismatch {
57        enemy: wowlab_types::sim::EnemyIdx,
58        expected: f64,
59        found: f64,
60    },
61    #[error("combat armor constant for enemy {enemy:?} must be finite and positive, found {found}")]
62    InvalidCombatArmorConstant {
63        enemy: wowlab_types::sim::EnemyIdx,
64        found: f64,
65    },
66    #[error(
67        "combat armor constant modifier for enemy {enemy:?} must be finite and positive, found {found}"
68    )]
69    InvalidCombatArmorConstantMod {
70        enemy: wowlab_types::sim::EnemyIdx,
71        found: f64,
72    },
73    #[error(
74        "effective combat armor constant for enemy {enemy:?} does not match resolved value: expected {expected}, found {found} from armor_constant {armor_constant} * armor_constant_mod {armor_constant_mod}"
75    )]
76    CombatEffectiveArmorConstantMismatch {
77        enemy: wowlab_types::sim::EnemyIdx,
78        expected: f64,
79        found: f64,
80        armor_constant: f64,
81        armor_constant_mod: f64,
82    },
83}
84}
85
86impl EncounterConstructionError {
87    pub(crate) fn resolved_enemy_count_mismatch(expected: usize, found: usize) -> Self {
88        Self {
89            kind: EncounterConstructionErrorKind::ResolvedEnemyCountMismatch { expected, found },
90        }
91    }
92
93    pub(crate) fn resolved_enemy_index_out_of_range(
94        index: usize,
95        source: std::num::TryFromIntError,
96    ) -> Self {
97        Self {
98            kind: EncounterConstructionErrorKind::ResolvedEnemyIndexOutOfRange { index, source },
99        }
100    }
101
102    pub(crate) fn resolved_enemy_identity_mismatch(
103        index: usize,
104        authored: wowlab_types::sim::EnemyIdx,
105        resolved: wowlab_types::sim::EnemyIdx,
106    ) -> Self {
107        Self {
108            kind: EncounterConstructionErrorKind::ResolvedEnemyIdentityMismatch {
109                index,
110                authored,
111                resolved,
112            },
113        }
114    }
115
116    pub(crate) fn resolved_enemy_level_mismatch(
117        enemy: wowlab_types::sim::EnemyIdx,
118        expected: u16,
119        found: u16,
120    ) -> Self {
121        Self {
122            kind: EncounterConstructionErrorKind::ResolvedEnemyLevelMismatch {
123                enemy,
124                expected,
125                found,
126            },
127        }
128    }
129
130    pub(crate) fn resolved_enemy_identity_metadata_mismatch(
131        enemy: wowlab_types::sim::EnemyIdx,
132    ) -> Self {
133        Self {
134            kind: EncounterConstructionErrorKind::ResolvedEnemyIdentityMetadataMismatch { enemy },
135        }
136    }
137
138    pub(crate) fn resolved_enemy_metadata_mismatch(enemy: wowlab_types::sim::EnemyIdx) -> Self {
139        Self {
140            kind: EncounterConstructionErrorKind::ResolvedEnemyMetadataMismatch { enemy },
141        }
142    }
143
144    pub(crate) fn resolved_health_model_mismatch(
145        enemy: wowlab_types::sim::EnemyIdx,
146        expected: f64,
147        found: f64,
148    ) -> Self {
149        Self {
150            kind: EncounterConstructionErrorKind::ResolvedHealthModelMismatch {
151                enemy,
152                expected,
153                found,
154            },
155        }
156    }
157
158    pub(crate) fn empty_resolved_display_name(enemy: wowlab_types::sim::EnemyIdx) -> Self {
159        Self {
160            kind: EncounterConstructionErrorKind::EmptyResolvedDisplayName { enemy },
161        }
162    }
163
164    pub(crate) fn invalid_resolved_enemy_stat(
165        enemy: wowlab_types::sim::EnemyIdx,
166        field: &'static str,
167        found: f64,
168    ) -> Self {
169        Self {
170            kind: EncounterConstructionErrorKind::InvalidResolvedEnemyStat {
171                enemy,
172                field,
173                found,
174            },
175        }
176    }
177
178    pub(crate) fn combat_creature_armor_mismatch(
179        enemy: wowlab_types::sim::EnemyIdx,
180        expected: f64,
181        found: f64,
182    ) -> Self {
183        Self {
184            kind: EncounterConstructionErrorKind::CombatCreatureArmorMismatch {
185                enemy,
186                expected,
187                found,
188            },
189        }
190    }
191
192    pub(crate) fn invalid_combat_armor_constant(
193        enemy: wowlab_types::sim::EnemyIdx,
194        found: f64,
195    ) -> Self {
196        Self {
197            kind: EncounterConstructionErrorKind::InvalidCombatArmorConstant { enemy, found },
198        }
199    }
200
201    pub(crate) fn invalid_combat_armor_constant_mod(
202        enemy: wowlab_types::sim::EnemyIdx,
203        found: f64,
204    ) -> Self {
205        Self {
206            kind: EncounterConstructionErrorKind::InvalidCombatArmorConstantMod { enemy, found },
207        }
208    }
209
210    pub(crate) fn combat_effective_armor_constant_mismatch(
211        enemy: wowlab_types::sim::EnemyIdx,
212        expected: f64,
213        found: f64,
214        armor_constant: f64,
215        armor_constant_mod: f64,
216    ) -> Self {
217        Self {
218            kind: EncounterConstructionErrorKind::CombatEffectiveArmorConstantMismatch {
219                enemy,
220                expected,
221                found,
222                armor_constant,
223                armor_constant_mod,
224            },
225        }
226    }
227
228    #[cfg(test)]
229    #[must_use]
230    pub(crate) fn is_combat_creature_armor_mismatch(&self) -> bool {
231        matches!(
232            self.kind,
233            EncounterConstructionErrorKind::CombatCreatureArmorMismatch { .. }
234        )
235    }
236
237    #[cfg(test)]
238    #[must_use]
239    pub(crate) fn is_invalid_combat_armor_constant(&self) -> bool {
240        matches!(
241            self.kind,
242            EncounterConstructionErrorKind::InvalidCombatArmorConstant { .. }
243        )
244    }
245
246    #[cfg(test)]
247    #[must_use]
248    pub(crate) fn is_invalid_combat_armor_constant_mod(&self) -> bool {
249        matches!(
250            self.kind,
251            EncounterConstructionErrorKind::InvalidCombatArmorConstantMod { .. }
252        )
253    }
254
255    #[cfg(test)]
256    #[must_use]
257    pub(crate) fn is_combat_effective_armor_constant_mismatch(&self) -> bool {
258        matches!(
259            self.kind,
260            EncounterConstructionErrorKind::CombatEffectiveArmorConstantMismatch { .. }
261        )
262    }
263}
264
265impl From<wowlab_types::sim::EncounterValidationError> for EncounterConstructionError {
266    fn from(error: wowlab_types::sim::EncounterValidationError) -> Self {
267        Self {
268            kind: EncounterConstructionErrorKind::InvalidDefinition(error),
269        }
270    }
271}
272
273wowlab_engine_macros::define_error! {
274/// Failure while driving one simulation iteration.
275#[derive(Debug)]
276#[non_exhaustive]
277pub struct SimRunError {
278    #[source]
279    kind: SimRunErrorKind,
280}
281
282#[derive(Debug, thiserror::Error)]
283enum SimRunErrorKind {
284    #[error("event budget exceeded: simulator processed {count} events without converging")]
285    EventBudgetExceeded { count: u32 },
286    #[error("spec handler failed while processing an event")]
287    Handler {
288        #[source]
289        source: crate::SpecRuntimeError,
290    },
291}
292}
293
294impl SimRunError {
295    /// Creates a non-converging event-loop failure.
296    #[must_use]
297    pub const fn event_budget_exceeded(count: u32) -> Self {
298        Self {
299            kind: SimRunErrorKind::EventBudgetExceeded { count },
300        }
301    }
302
303    /// Creates a handler-dispatch failure while retaining its runtime source.
304    #[must_use]
305    pub const fn handler(source: crate::SpecRuntimeError) -> Self {
306        Self {
307            kind: SimRunErrorKind::Handler { source },
308        }
309    }
310
311    /// Returns the processed-event count for a budget failure.
312    #[must_use]
313    pub const fn event_count(&self) -> Option<u32> {
314        match self.kind {
315            SimRunErrorKind::EventBudgetExceeded { count } => Some(count),
316            SimRunErrorKind::Handler { .. } => None,
317        }
318    }
319}
320
321/// Construction stage that retained a lower-layer failure.
322#[derive(Clone, Copy, Debug, Eq, PartialEq)]
323#[non_exhaustive]
324pub enum EngineConstructionStage {
325    RotationCompile,
326    SpecConstruction,
327}
328
329wowlab_engine_macros::define_error! {
330/// Typed engine-construction failure with its concrete lower-layer source.
331#[non_exhaustive]
332pub struct EngineConstructionError {
333    #[source]
334    kind: EngineConstructionErrorKind,
335}
336
337#[derive(Debug, thiserror::Error)]
338enum EngineConstructionErrorKind {
339    #[error("rotation compile error: {source}")]
340    RotationCompile {
341        #[source]
342        source: Box<dyn std::error::Error + Send + Sync>,
343    },
344    #[error("spec construction error: {source}")]
345    SpecConstruction {
346        #[source]
347        source: Box<dyn std::error::Error + Send + Sync>,
348    },
349}
350}
351
352impl EngineConstructionError {
353    fn new(
354        stage: EngineConstructionStage,
355        source: impl std::error::Error + Send + Sync + 'static,
356    ) -> Self {
357        let source = Box::new(source);
358        let kind = match stage {
359            EngineConstructionStage::RotationCompile => {
360                EngineConstructionErrorKind::RotationCompile { source }
361            }
362            EngineConstructionStage::SpecConstruction => {
363                EngineConstructionErrorKind::SpecConstruction { source }
364            }
365        };
366
367        Self { kind }
368    }
369
370    /// Returns the construction stage that failed.
371    #[must_use]
372    pub const fn stage(&self) -> EngineConstructionStage {
373        match self.kind {
374            EngineConstructionErrorKind::RotationCompile { .. } => {
375                EngineConstructionStage::RotationCompile
376            }
377            EngineConstructionErrorKind::SpecConstruction { .. } => {
378                EngineConstructionStage::SpecConstruction
379            }
380        }
381    }
382}
383
384impl std::fmt::Debug for EngineConstructionError {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        f.debug_struct("EngineConstructionError")
387            .field("stage", &self.stage())
388            .finish_non_exhaustive()
389    }
390}
391
392wowlab_engine_macros::define_error! {
393/// Engine error categories.
394#[non_exhaustive]
395pub struct EngineError {
396    #[source]
397    kind: EngineErrorKind,
398}
399
400#[derive(Debug, thiserror::Error)]
401enum EngineErrorKind {
402    #[error("intent validation error: {message}")]
403    IntentValidation { message: String },
404    #[error("chunk validation error: {message}")]
405    ChunkValidation { message: String },
406    #[error("resolver error: {0}")]
407    Resolver(#[from] crate::resolver::ResolverError),
408    #[error("encounter construction error: {0}")]
409    EncounterConstruction(#[from] EncounterConstructionError),
410    #[error("spec metadata error: {0}")]
411    SpecMetadata(#[from] crate::DeclaredSpecMetadataError),
412    #[error("content catalog error: {0}")]
413    ContentCatalog(#[from] crate::ContentCatalogError),
414    #[error("{context}: {source}")]
415    SpellIdConversion {
416        context: &'static str,
417        #[source]
418        source: crate::SpellIdConversionError,
419    },
420    #[error("{0}")]
421    Construction(#[source] EngineConstructionError),
422    #[error("spec construction error: {message}")]
423    SpecConstruction { message: String },
424    #[error("spec not found: {message}")]
425    SpecNotFound { message: String },
426    #[error("simulation runtime error: {message}")]
427    SimulationRuntime { message: String },
428    #[error("simulation runtime error: {0}")]
429    SimulationRun(#[from] SimRunError),
430}
431}
432
433macro_rules! engine_error_ctor {
434    ($($ctor:ident => $variant:ident),+ $(,)?) => {
435        impl EngineError {
436            $(
437                pub fn $ctor(message: impl Into<String>) -> Self {
438                    Self {
439                        kind: EngineErrorKind::$variant {
440                            message: message.into(),
441                        },
442                    }
443                }
444            )+
445        }
446    };
447}
448
449impl EngineError {
450    /// Retains a typed rotation-construction source behind the Ports boundary.
451    #[must_use]
452    pub fn rotation_compile(source: impl std::error::Error + Send + Sync + 'static) -> Self {
453        Self {
454            kind: EngineErrorKind::Construction(EngineConstructionError::new(
455                EngineConstructionStage::RotationCompile,
456                source,
457            )),
458        }
459    }
460
461    /// Retains a typed combat-system construction source behind the Ports boundary.
462    #[must_use]
463    pub fn spec_construction_source(
464        source: impl std::error::Error + Send + Sync + 'static,
465    ) -> Self {
466        Self {
467            kind: EngineErrorKind::Construction(EngineConstructionError::new(
468                EngineConstructionStage::SpecConstruction,
469                source,
470            )),
471        }
472    }
473
474    /// Wraps a runtime-to-database spell-ID conversion with use-case context.
475    #[must_use]
476    pub const fn spell_id_conversion(
477        context: &'static str,
478        source: crate::SpellIdConversionError,
479    ) -> Self {
480        Self {
481            kind: EngineErrorKind::SpellIdConversion { context, source },
482        }
483    }
484
485    #[must_use]
486    pub fn is_intent_validation(&self) -> bool {
487        matches!(self.kind, EngineErrorKind::IntentValidation { .. })
488    }
489
490    #[must_use]
491    pub fn is_chunk_validation(&self) -> bool {
492        matches!(self.kind, EngineErrorKind::ChunkValidation { .. })
493    }
494
495    /// Returns whether this is a spec-construction failure.
496    #[must_use]
497    pub fn is_spec_construction(&self) -> bool {
498        match &self.kind {
499            EngineErrorKind::Construction(error) => {
500                error.stage() == EngineConstructionStage::SpecConstruction
501            }
502            EngineErrorKind::SpecConstruction { .. }
503            | EngineErrorKind::SpecMetadata(_)
504            | EngineErrorKind::ContentCatalog(_)
505            | EngineErrorKind::SpellIdConversion { .. } => true,
506            _ => false,
507        }
508    }
509
510    #[must_use]
511    pub fn message(&self) -> Option<&str> {
512        match &self.kind {
513            EngineErrorKind::IntentValidation { message }
514            | EngineErrorKind::ChunkValidation { message }
515            | EngineErrorKind::SpecConstruction { message }
516            | EngineErrorKind::SpecNotFound { message }
517            | EngineErrorKind::SimulationRuntime { message } => Some(message),
518            EngineErrorKind::Resolver(_)
519            | EngineErrorKind::EncounterConstruction(_)
520            | EngineErrorKind::SpecMetadata(_)
521            | EngineErrorKind::ContentCatalog(_)
522            | EngineErrorKind::Construction(_)
523            | EngineErrorKind::SpellIdConversion { .. }
524            | EngineErrorKind::SimulationRun(_) => None,
525        }
526    }
527
528    #[cfg(test)]
529    pub(crate) fn into_encounter_construction(self) -> Option<EncounterConstructionError> {
530        match self.kind {
531            EngineErrorKind::EncounterConstruction(error) => Some(error),
532            _ => None,
533        }
534    }
535}
536
537impl From<wowlab_engine_gamedata::GameDataError> for EngineError {
538    fn from(source: wowlab_engine_gamedata::GameDataError) -> Self {
539        Self::spec_construction_source(source)
540    }
541}
542
543impl std::fmt::Debug for EngineError {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        if let EngineErrorKind::Construction(error) = &self.kind {
546            return f
547                .debug_struct("EngineError")
548                .field("construction_stage", &error.stage())
549                .finish_non_exhaustive();
550        }
551
552        f.debug_struct("EngineError")
553            .field("kind", &self.kind)
554            .finish()
555    }
556}
557
558impl From<crate::resolver::ResolverError> for EngineError {
559    fn from(error: crate::resolver::ResolverError) -> Self {
560        Self {
561            kind: EngineErrorKind::Resolver(error),
562        }
563    }
564}
565
566impl From<EncounterConstructionError> for EngineError {
567    fn from(error: EncounterConstructionError) -> Self {
568        Self {
569            kind: EngineErrorKind::EncounterConstruction(error),
570        }
571    }
572}
573
574impl From<wowlab_types::sim::EncounterValidationError> for EngineError {
575    fn from(error: wowlab_types::sim::EncounterValidationError) -> Self {
576        EncounterConstructionError::from(error).into()
577    }
578}
579
580impl From<crate::DeclaredSpecMetadataError> for EngineError {
581    fn from(error: crate::DeclaredSpecMetadataError) -> Self {
582        Self {
583            kind: EngineErrorKind::SpecMetadata(error),
584        }
585    }
586}
587
588impl From<crate::ContentCatalogError> for EngineError {
589    fn from(error: crate::ContentCatalogError) -> Self {
590        Self {
591            kind: EngineErrorKind::ContentCatalog(error),
592        }
593    }
594}
595
596impl From<SimRunError> for EngineError {
597    fn from(error: SimRunError) -> Self {
598        Self {
599            kind: EngineErrorKind::SimulationRun(error),
600        }
601    }
602}
603
604engine_error_ctor! {
605    intent_validation   => IntentValidation,
606    chunk_validation    => ChunkValidation,
607    spec_construction   => SpecConstruction,
608    spec_not_found      => SpecNotFound,
609    simulation_runtime  => SimulationRuntime,
610}
611
612#[cfg(test)]
613mod construction_error_tests {
614    use std::error::Error as _;
615
616    use googletest::prelude::*;
617
618    use super::*;
619
620    #[gtest]
621    fn typed_construction_source_preserves_stage_display_chain_and_redaction() -> Result<()> {
622        let error = EngineError::spec_construction_source(std::io::Error::new(
623            std::io::ErrorKind::InvalidData,
624            "private builder payload",
625        ));
626
627        verify_true!(error.is_spec_construction())?;
628        verify_that!(
629            error.to_string(),
630            eq("spec construction error: private builder payload")
631        )?;
632        let construction = error
633            .source()
634            .and_then(std::error::Error::source)
635            .and_then(|source| source.downcast_ref::<EngineConstructionError>())
636            .or_fail()?;
637
638        verify_that!(
639            construction.stage(),
640            eq(EngineConstructionStage::SpecConstruction)
641        )?;
642        verify_true!(
643            construction
644                .source()
645                .and_then(std::error::Error::source)
646                .is_some_and(<dyn std::error::Error>::is::<std::io::Error>)
647        )?;
648        let debug = format!("{error:?}");
649
650        verify_that!(&debug, contains_substring("Construction"))?;
651
652        verify_that!(&debug, not(contains_substring("private builder payload")))
653    }
654
655    #[gtest]
656    fn game_data_error_conversion_preserves_spec_construction_source() -> Result<()> {
657        let mut builder = wowlab_engine_gamedata::ResolvedGameData::builder();
658
659        builder.insert_base_points(wowlab_types::sim::SpellIdx::from_raw(1), 1, 1.0);
660        let source = builder
661            .build()
662            .require_base_points(wowlab_types::sim::SpellIdx::from_raw(2), 1)
663            .err()
664            .or_fail()?;
665        let error = EngineError::from(source);
666
667        verify_true!(error.is_spec_construction())?;
668
669        verify_true!(
670            error
671                .source()
672                .and_then(std::error::Error::source)
673                .and_then(|source| source.downcast_ref::<EngineConstructionError>())
674                .and_then(std::error::Error::source)
675                .and_then(std::error::Error::source)
676                .is_some_and(<dyn std::error::Error>::is::<wowlab_engine_gamedata::GameDataError>)
677        )
678    }
679}