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