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 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
23pub 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
49pub 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
92pub 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
105pub 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#[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#[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}