Skip to main content

wowlab_engine_combat/builder/
mod.rs

1//! Fluent builder API for constructing a combat system.
2
3pub(crate) mod aura_builder;
4pub(crate) mod auto_attack_builder;
5pub(crate) mod buffer_init;
6pub(crate) mod built;
7pub(crate) mod combat_builder;
8mod compile;
9mod def;
10pub(crate) mod impact_effect_proc_builder;
11mod lower;
12pub(crate) mod spell_builder;
13
14pub(crate) use aura_builder::AuraDefinitionDraft;
15pub(crate) use auto_attack_builder::AutoAttackDefinitionDraft;
16pub(crate) use built::BuiltCombatSystem;
17pub(crate) use combat_builder::CombatSystemBuilder;
18pub(crate) use impact_effect_proc_builder::ImpactEffectProcDefinition;
19pub(crate) use lower::{InfoCtx, lower_resolved_damage};
20pub(crate) use spell_builder::SpellDefinitionDraft;
21
22/// Convert a manifest/game-data scalar to an integral millisecond duration.
23///
24/// # Errors
25///
26/// Returns [`BuilderError`] when `value` is non-finite, negative, fractional, or exceeds `u32`.
27pub fn validated_milliseconds(
28    value: f64,
29    spell_id: u32,
30    field: &'static str,
31) -> Result<u32, BuilderError> {
32    if !value.is_finite()
33        || value < 0.0
34        || value > f64::from(u32::MAX)
35        || value.fract().abs() > f64::EPSILON
36    {
37        return Err(BuilderErrorKind::InvalidMilliseconds {
38            spell_id,
39            field,
40            value,
41        }
42        .into());
43    }
44
45    Ok(wowlab_types::numeric::f64_to_u32_saturating_trunc(value))
46}
47
48/// Validate a finite non-negative scalar override.
49///
50/// # Errors
51///
52/// Returns [`BuilderError`] when `value` is negative or non-finite.
53pub fn validated_nonnegative(
54    value: f64,
55    spell_id: u32,
56    field: &'static str,
57) -> Result<f64, BuilderError> {
58    if !value.is_finite() || value < 0.0 {
59        return Err(BuilderErrorKind::InvalidScalar {
60            spell_id,
61            field,
62            value,
63        }
64        .into());
65    }
66
67    Ok(value)
68}
69
70fn validated_positive_u8(
71    value: f64,
72    spell_id: u32,
73    field: &'static str,
74) -> Result<u8, BuilderError> {
75    if !value.is_finite()
76        || value < 1.0
77        || value > f64::from(u8::MAX)
78        || value.fract().abs() > f64::EPSILON
79    {
80        return Err(BuilderErrorKind::InvalidPositiveU8 {
81            spell_id,
82            field,
83            value,
84        }
85        .into());
86    }
87
88    Ok(wowlab_types::numeric::f64_to_u8_saturating_trunc(value))
89}
90
91/// Validate an aura stack cap.
92///
93/// # Errors
94///
95/// Returns [`BuilderError`] unless `value` is an integral value in `1..=u8::MAX`.
96pub fn validated_max_stacks(
97    value: f64,
98    aura_id: u32,
99    field: &'static str,
100) -> Result<u8, BuilderError> {
101    validated_positive_u8(value, aura_id, field)
102}
103
104/// Validate a channel's tick count, accepting the empty introspection map's neutral zero.
105///
106/// # Errors
107///
108/// Returns [`BuilderError`] unless `value` is an integral value in `0..=u8::MAX`.
109pub fn validated_channel_tick_count(
110    value: f64,
111    spell_id: u32,
112    field: &'static str,
113) -> Result<u8, BuilderError> {
114    if !value.is_finite()
115        || value < 0.0
116        || value > f64::from(u8::MAX)
117        || value.fract().abs() > f64::EPSILON
118    {
119        return Err(BuilderErrorKind::InvalidChannelTickCount {
120            spell_id,
121            field,
122            value,
123        }
124        .into());
125    }
126
127    Ok(wowlab_types::numeric::f64_to_u8_saturating_trunc(value))
128}
129
130/// Validate a spell charge count.
131///
132/// # Errors
133///
134/// Returns [`BuilderError`] unless `value` is an integral value in `1..=u8::MAX`.
135#[cfg(test)]
136pub(crate) fn validated_charges(
137    value: f64,
138    spell_id: u32,
139    field: &'static str,
140) -> Result<u8, BuilderError> {
141    validated_positive_u8(value, spell_id, field)
142}
143
144wowlab_engine_macros::define_error! {
145/// Error raised by the fluent builder API at construction time.
146#[derive(Debug)]
147#[non_exhaustive]
148pub struct BuilderError {
149    #[source]
150    kind: BuilderErrorKind,
151}
152
153#[derive(Debug, thiserror::Error)]
154enum BuilderErrorKind {
155    #[error(
156        "aura {aura_name:?} (id {aura_id}) already has {slot_count} buff effects; \
157         split the aura or raise MAX_AURA_BUFF_EFFECTS"
158    )]
159    AuraEffectsFull {
160        aura_name: String,
161        aura_id: u32,
162        slot_count: usize,
163    },
164
165    #[error("unsupported ResolvedDamageKind variant in damage_auto")]
166    UnsupportedDamageKind,
167
168    #[error("missing game data for spell {spell_id} (field: {field})")]
169    MissingSpellData { spell_id: u32, field: &'static str },
170
171    #[error("missing RPPM game data for equipped item {item_id} (driver spell {driver_spell_id})")]
172    MissingItemRppmData { item_id: u32, driver_spell_id: u32 },
173
174    #[error("missing RPPM game data for system driver spell {driver_spell_id}")]
175    MissingSystemRppmData { driver_spell_id: u32 },
176
177    #[error("invalid RPPM rate for system driver spell {driver_spell_id}: {rppm}")]
178    InvalidSystemRppmData { driver_spell_id: u32, rppm: f64 },
179
180    #[error(
181        "system RPPM tracker registry exceeds local-index capacity while registering driver spell \
182         {driver_spell_id} ({tracker_count} trackers)"
183    )]
184    SystemRppmRegistryFull {
185        driver_spell_id: u32,
186        tracker_count: usize,
187        #[source]
188        source: std::num::TryFromIntError,
189    },
190
191    #[error(
192        "missing item-scaling game data for spell {spell_id} effect {effect_index} at item level {item_level}"
193    )]
194    MissingItemScalingData {
195        spell_id: u32,
196        effect_index: u8,
197        item_level: i32,
198    },
199
200    #[error("invalid millisecond value for spell {spell_id} (field: {field}): {value}")]
201    InvalidMilliseconds {
202        spell_id: u32,
203        field: &'static str,
204        value: f64,
205    },
206
207    #[error("invalid non-negative scalar for spell {spell_id} (field: {field}): {value}")]
208    InvalidScalar {
209        spell_id: u32,
210        field: &'static str,
211        value: f64,
212    },
213
214    #[error("invalid positive u8 for spell {spell_id} (field: {field}): {value}")]
215    InvalidPositiveU8 {
216        spell_id: u32,
217        field: &'static str,
218        value: f64,
219    },
220
221    #[error("invalid channel tick count for spell {spell_id} (field: {field}): {value}")]
222    InvalidChannelTickCount {
223        spell_id: u32,
224        field: &'static str,
225        value: f64,
226    },
227
228    #[error("spell {spell_id} has {rank_count} empower ranks; at most 255 are supported")]
229    InvalidEmpowerRankCount {
230        spell_id: u32,
231        rank_count: usize,
232        #[source]
233        source: std::num::TryFromIntError,
234    },
235
236    #[error(transparent)]
237    GameData(#[from] wowlab_engine_ports::EngineError),
238
239    #[error(transparent)]
240    TriggeredDamage(#[from] wowlab_engine_domain::dbc::TriggerResolveError),
241
242    #[error(transparent)]
243    TargetPlan(#[from] wowlab_engine_domain::targeting::TargetPlanError),
244}
245}
246
247impl BuilderError {
248    #[must_use]
249    pub fn missing_spell_data(spell_id: u32, field: &'static str) -> Self {
250        BuilderErrorKind::MissingSpellData { spell_id, field }.into()
251    }
252
253    #[must_use]
254    pub fn missing_item_rppm_data(item_id: u32, driver_spell_id: u32) -> Self {
255        BuilderErrorKind::MissingItemRppmData {
256            item_id,
257            driver_spell_id,
258        }
259        .into()
260    }
261
262    #[must_use]
263    pub fn missing_system_rppm_data(driver_spell_id: u32) -> Self {
264        BuilderErrorKind::MissingSystemRppmData { driver_spell_id }.into()
265    }
266
267    #[must_use]
268    pub fn invalid_system_rppm_data(driver_spell_id: u32, rppm: f64) -> Self {
269        BuilderErrorKind::InvalidSystemRppmData {
270            driver_spell_id,
271            rppm,
272        }
273        .into()
274    }
275
276    #[must_use]
277    pub fn system_rppm_registry_full(
278        driver_spell_id: u32,
279        tracker_count: usize,
280        source: std::num::TryFromIntError,
281    ) -> Self {
282        BuilderErrorKind::SystemRppmRegistryFull {
283            driver_spell_id,
284            tracker_count,
285            source,
286        }
287        .into()
288    }
289
290    #[must_use]
291    pub fn missing_item_scaling_data(spell_id: u32, effect_index: u8, item_level: i32) -> Self {
292        BuilderErrorKind::MissingItemScalingData {
293            spell_id,
294            effect_index,
295            item_level,
296        }
297        .into()
298    }
299
300    #[cfg(test)]
301    pub(crate) fn is_missing_spell_data(&self, spell_id: u32, field: &str) -> bool {
302        matches!(
303            &self.kind,
304            BuilderErrorKind::MissingSpellData {
305                spell_id: actual_spell_id,
306                field: actual_field,
307            } if *actual_spell_id == spell_id && *actual_field == field
308        )
309    }
310
311    #[cfg(test)]
312    pub(crate) const fn is_invalid_milliseconds(&self) -> bool {
313        matches!(self.kind, BuilderErrorKind::InvalidMilliseconds { .. })
314    }
315
316    #[cfg(test)]
317    pub(crate) const fn is_invalid_scalar(&self) -> bool {
318        matches!(self.kind, BuilderErrorKind::InvalidScalar { .. })
319    }
320
321    #[cfg(test)]
322    pub(crate) const fn is_invalid_positive_u8(&self) -> bool {
323        matches!(self.kind, BuilderErrorKind::InvalidPositiveU8 { .. })
324    }
325}
326
327impl From<BuilderErrorKind> for BuilderError {
328    fn from(kind: BuilderErrorKind) -> Self {
329        Self { kind }
330    }
331}
332
333impl From<wowlab_engine_ports::EngineError> for BuilderError {
334    fn from(source: wowlab_engine_ports::EngineError) -> Self {
335        BuilderErrorKind::GameData(source).into()
336    }
337}
338
339impl From<wowlab_engine_gamedata::GameDataError> for BuilderError {
340    fn from(source: wowlab_engine_gamedata::GameDataError) -> Self {
341        wowlab_engine_ports::EngineError::from(source).into()
342    }
343}
344
345impl From<wowlab_engine_domain::dbc::TriggerResolveError> for BuilderError {
346    fn from(source: wowlab_engine_domain::dbc::TriggerResolveError) -> Self {
347        BuilderErrorKind::TriggeredDamage(source).into()
348    }
349}
350
351impl From<wowlab_engine_domain::targeting::TargetPlanError> for BuilderError {
352    fn from(source: wowlab_engine_domain::targeting::TargetPlanError) -> Self {
353        BuilderErrorKind::TargetPlan(source).into()
354    }
355}
356
357wowlab_engine_macros::define_error! {
358/// Typed failure while compiling and assembling a combat system.
359#[derive(Debug)]
360#[non_exhaustive]
361pub struct CombatBuildError {
362    #[source]
363    kind: CombatBuildErrorKind,
364}
365
366#[derive(Debug, thiserror::Error)]
367enum CombatBuildErrorKind {
368    #[error("rotation compile error: {0}")]
369    Rotation(#[from] wowlab_engine_domain::rotation::Error),
370    #[error("spec construction error: {0}")]
371    Builder(#[from] BuilderError),
372    #[error("{0}")]
373    Engine(#[source] wowlab_engine_ports::EngineError),
374}
375}
376
377impl From<wowlab_engine_domain::rotation::Error> for CombatBuildError {
378    fn from(source: wowlab_engine_domain::rotation::Error) -> Self {
379        Self {
380            kind: CombatBuildErrorKind::Rotation(source),
381        }
382    }
383}
384
385impl From<BuilderError> for CombatBuildError {
386    fn from(source: BuilderError) -> Self {
387        Self {
388            kind: CombatBuildErrorKind::Builder(source),
389        }
390    }
391}
392
393impl From<wowlab_engine_ports::EngineError> for CombatBuildError {
394    fn from(source: wowlab_engine_ports::EngineError) -> Self {
395        Self {
396            kind: CombatBuildErrorKind::Engine(source),
397        }
398    }
399}
400
401#[cfg(test)]
402mod error_tests {
403    use googletest::prelude::*;
404
405    use super::BuilderError;
406
407    fn builder() -> super::CombatSystemBuilder {
408        super::BuiltCombatSystem::builder(wowlab_engine_ports::CombatStats::default())
409            .encounter(crate::test_support::default_encounter())
410    }
411
412    fn has_source<T>(error: &(dyn std::error::Error + 'static)) -> bool
413    where
414        T: std::error::Error + 'static,
415    {
416        let mut source = error.source();
417
418        while let Some(current) = source {
419            if current.is::<T>() {
420                return true;
421            }
422
423            source = current.source();
424        }
425
426        false
427    }
428
429    #[gtest]
430    fn wrapped_resolver_failure_retains_every_typed_source() -> Result<()> {
431        let io = std::io::Error::new(std::io::ErrorKind::InvalidData, "fixture decode failed");
432        let resolver = wowlab_engine_ports::ResolverError::decode("loading fixture", io);
433        let error = BuilderError::from(wowlab_engine_ports::EngineError::from(resolver));
434
435        verify_true!(has_source::<wowlab_engine_ports::ResolverError>(&error))?;
436        verify_true!(has_source::<std::io::Error>(&error))?;
437
438        Ok(())
439    }
440
441    #[gtest]
442    fn invalid_rotation_retains_domain_source_with_redacted_debug() -> Result<()> {
443        let rotation = wowlab_types::sim::Rotation {
444            version: 2,
445            ..wowlab_types::sim::Rotation::empty()
446        };
447        let error = builder()
448            .build(rotation)
449            .expect_err("invalid rotation must fail construction");
450
451        verify_that!(
452            error.to_string(),
453            eq(
454                "rotation compile error: validation failed: unsupported rotation format version 2 (this build understands version 1)"
455            )
456        )?;
457        verify_true!(has_source::<wowlab_engine_domain::rotation::Error>(&error))?;
458        let debug = format!("{error:?}");
459
460        verify_true!(debug.contains("Rotation"))?;
461
462        Ok(())
463    }
464
465    #[gtest]
466    fn builder_failure_retains_builder_source_and_exact_outer_message() -> Result<()> {
467        let error = builder()
468            .spell("broken", 42, |_| {
469                Err(BuilderError::missing_spell_data(42, "fixture"))
470            })
471            .build(wowlab_types::sim::Rotation::empty())
472            .expect_err("pending builder failure must stop construction");
473
474        verify_that!(
475            error.to_string(),
476            eq("spec construction error: missing game data for spell 42 (field: fixture)")
477        )?;
478        verify_true!(has_source::<BuilderError>(&error))?;
479        let debug = format!("{error:?}");
480
481        verify_true!(debug.contains("Builder"))?;
482
483        Ok(())
484    }
485
486    #[gtest]
487    fn populated_game_data_without_item_driver_rppm_fails_construction() -> Result<()> {
488        const DRIVER_SPELL_ID: u32 = 123;
489        const ITEM_ID: u32 = 42;
490
491        let mut data = wowlab_engine_gamedata::ResolvedGameData::builder();
492
493        data.insert_coefficient(
494            wowlab_types::sim::SpellIdx::from_raw(DRIVER_SPELL_ID),
495            1,
496            1.0,
497        );
498        let error = builder()
499            .game_data(data.build())
500            .register_item_rppm_from_data(ITEM_ID, DRIVER_SPELL_ID)
501            .build(wowlab_types::sim::Rotation::empty())
502            .expect_err("missing required item RPPM must stop construction");
503
504        verify_that!(
505            error.to_string(),
506            eq(
507                "spec construction error: missing RPPM game data for equipped item 42 \
508                 (driver spell 123)"
509            )
510        )?;
511
512        verify_true!(has_source::<BuilderError>(&error))
513    }
514
515    #[gtest]
516    fn empty_introspection_data_allows_unresolved_item_driver_rppm() -> Result<()> {
517        let result = builder()
518            .register_item_rppm_from_data(42, 123)
519            .build(wowlab_types::sim::Rotation::empty());
520
521        verify_true!(result.is_ok())
522    }
523}