wowlab_engine_combat/builder/
mod.rs1pub(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
22pub 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
48pub 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
91pub 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
104pub 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#[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#[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#[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}