Skip to main content

wowlab_types/types/sim/
encounter.rs

1// #t(file: rust_non_exhaustive_on_public) public encounter enums are a versioned closed vocabulary whose exact variants are part of v2
2// #t(file: rust_magic_numbers) numeric literals are schema discriminants and fixed constructor values
3// #t(file: rust_public_error_enum) established validation variants are part of the public encounter contract
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeSeq};
6
7use super::{EnemyIdx, GroupId, PetIdx, PullId, WaveId};
8
9mod geometry;
10mod spatial;
11mod validation;
12
13pub(super) const ENCOUNTER_VERSION: u16 = 2;
14/// Shared tolerance for two-dimensional encounter geometry.
15pub const GEOMETRY_EPSILON: f64 = 1e-9;
16
17#[derive(Clone, Debug, PartialEq, thiserror::Error)]
18#[non_exhaustive]
19pub enum EncounterValidationError {
20    #[error("unsupported encounter version {found}; supported version is {supported}")]
21    UnsupportedVersion { found: u16, supported: u16 },
22    #[error("invalid encounter field {path}: {reason}")]
23    Invalid { path: String, reason: String },
24}
25
26fn invalid(path: impl Into<String>, reason: impl Into<String>) -> EncounterValidationError {
27    EncounterValidationError::Invalid {
28        path: path.into(),
29        reason: reason.into(),
30    }
31}
32
33#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
34#[serde(deny_unknown_fields)]
35pub struct Position2 {
36    pub x: f64,
37    pub y: f64,
38}
39
40impl Position2 {
41    #[must_use]
42    pub const fn new(x: f64, y: f64) -> Self {
43        Self { x, y }
44    }
45
46    /// Validate that both coordinates are finite.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`EncounterValidationError::Invalid`] when either coordinate is non-finite.
51    pub fn validate(self) -> Result<(), EncounterValidationError> {
52        if self.x.is_finite() && self.y.is_finite() {
53            Ok(())
54        } else {
55            Err(invalid("position", "coordinates must be finite"))
56        }
57    }
58
59    #[must_use]
60    pub fn distance_squared(self, other: Self) -> f64 {
61        let dx = self.x - other.x;
62        let dy = self.y - other.y;
63
64        dx.mul_add(dx, dy * dy)
65    }
66
67    #[must_use]
68    pub fn distance(self, other: Self) -> f64 {
69        self.distance_squared(other).sqrt()
70    }
71}
72
73#[derive(
74    Clone, Copy, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
75)]
76#[serde(transparent)]
77#[repr(transparent)]
78// #t(rust_newtype_pub_field) transparent encounter wire identifier with established tuple construction
79pub struct SpatialLayerId(pub u16);
80
81#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
82#[serde(deny_unknown_fields)]
83pub struct SpatialLayerDefinition {
84    pub id: SpatialLayerId,
85    pub slug: String,
86}
87
88#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
89#[serde(deny_unknown_fields)]
90pub struct SpatialTransform {
91    pub layer: SpatialLayerId,
92    pub position: Position2,
93    #[serde(default)]
94    pub heading: f64,
95}
96
97#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
98#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
99pub enum StaticObstacle {
100    Segment {
101        layer: SpatialLayerId,
102        start: Position2,
103        end: Position2,
104    },
105    Polygon {
106        layer: SpatialLayerId,
107        exterior: Vec<Position2>,
108        holes: Vec<Vec<Position2>>,
109    },
110}
111
112#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
113#[serde(deny_unknown_fields)]
114pub struct StaticSpatialScene {
115    pub layers: Vec<SpatialLayerDefinition>,
116    pub obstacles: Vec<StaticObstacle>,
117}
118
119#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
120pub enum ActorId {
121    Player,
122    External,
123    Pet(PetIdx),
124    Enemy(EnemyIdx),
125}
126
127#[derive(Deserialize, Serialize)]
128#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
129#[expect(
130    clippy::empty_enum_variants_with_brackets,
131    reason = "empty struct variants preserve the tagged JSON object shape"
132)]
133enum ActorIdSerde {
134    Player {},
135    External {},
136    Pet { pet: PetIdx },
137    Enemy { enemy: EnemyIdx },
138}
139
140impl Serialize for ActorId {
141    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
142    where
143        S: Serializer,
144    {
145        match *self {
146            Self::Player => ActorIdSerde::Player {},
147            Self::External => ActorIdSerde::External {},
148            Self::Pet(pet) => ActorIdSerde::Pet { pet },
149            Self::Enemy(enemy) => ActorIdSerde::Enemy { enemy },
150        }
151        .serialize(serializer)
152    }
153}
154
155impl<'de> Deserialize<'de> for ActorId {
156    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157    where
158        D: Deserializer<'de>,
159    {
160        Ok(match ActorIdSerde::deserialize(deserializer)? {
161            ActorIdSerde::Player {} => Self::Player,
162            ActorIdSerde::External {} => Self::External,
163            ActorIdSerde::Pet { pet } => Self::Pet(pet),
164            ActorIdSerde::Enemy { enemy } => Self::Enemy(enemy),
165        })
166    }
167}
168
169impl Ord for ActorId {
170    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
171        actor_order(*self).cmp(&actor_order(*other))
172    }
173}
174
175impl PartialOrd for ActorId {
176    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
177        Some(self.cmp(other))
178    }
179}
180
181const fn actor_order(actor: ActorId) -> (u8, u16) {
182    match actor {
183        ActorId::Player => (0, 0),
184        ActorId::External => (1, 0),
185        ActorId::Pet(pet) => (2, pet.0),
186        ActorId::Enemy(enemy) => (3, enemy.0),
187    }
188}
189
190#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
191pub enum PositionedActorRef {
192    Player,
193    Enemy(EnemyIdx),
194}
195
196#[derive(Deserialize, Serialize)]
197#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
198#[expect(
199    clippy::empty_enum_variants_with_brackets,
200    // #t(rust_duplicate_strings) Rust attributes require a literal reason at the declaration they govern.
201    reason = "empty struct variants preserve the tagged JSON object shape"
202)]
203enum PositionedActorRefSerde {
204    Player {},
205    Enemy { enemy: EnemyIdx },
206}
207
208impl Serialize for PositionedActorRef {
209    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
210    where
211        S: Serializer,
212    {
213        match *self {
214            Self::Player => PositionedActorRefSerde::Player {},
215            Self::Enemy(enemy) => PositionedActorRefSerde::Enemy { enemy },
216        }
217        .serialize(serializer)
218    }
219}
220
221impl<'de> Deserialize<'de> for PositionedActorRef {
222    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
223    where
224        D: Deserializer<'de>,
225    {
226        Ok(match PositionedActorRefSerde::deserialize(deserializer)? {
227            PositionedActorRefSerde::Player {} => Self::Player,
228            PositionedActorRefSerde::Enemy { enemy } => Self::Enemy(enemy),
229        })
230    }
231}
232
233impl Ord for PositionedActorRef {
234    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
235        positioned_actor_order(*self).cmp(&positioned_actor_order(*other))
236    }
237}
238
239impl PartialOrd for PositionedActorRef {
240    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
241        Some(self.cmp(other))
242    }
243}
244
245const fn positioned_actor_order(actor: PositionedActorRef) -> (u8, u16) {
246    match actor {
247        PositionedActorRef::Player => (0, 0),
248        PositionedActorRef::Enemy(enemy) => (1, enemy.0),
249    }
250}
251
252#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
253#[serde(deny_unknown_fields)]
254pub struct BlockedLosPair {
255    pub a: PositionedActorRef,
256    pub b: PositionedActorRef,
257}
258
259#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
260#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
261pub enum DifficultyContext {
262    Generic {
263        expansion_id: u32,
264    },
265    Dungeon {
266        expansion_id: u32,
267        difficulty_id: u32,
268    },
269    Raid {
270        expansion_id: u32,
271        difficulty_id: u32,
272    },
273    MythicPlus {
274        expansion_id: u32,
275        difficulty_id: u32,
276        season_id: u32,
277        keystone_level: u32,
278    },
279}
280
281impl DifficultyContext {
282    fn validate(&self, path: &str) -> Result<(), EncounterValidationError> {
283        match self {
284            Self::Dungeon { difficulty_id, .. } | Self::Raid { difficulty_id, .. }
285                if *difficulty_id == 0 =>
286            {
287                Err(invalid(path, "difficulty_id must be non-zero"))
288            }
289            Self::MythicPlus {
290                difficulty_id,
291                season_id,
292                keystone_level,
293                ..
294            } if *difficulty_id == 0 || *season_id == 0 || *keystone_level == 0 => Err(invalid(
295                path,
296                "difficulty_id, season_id, and keystone_level must be non-zero",
297            )),
298            _ => Ok(()),
299        }
300    }
301}
302
303#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
304#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
305pub enum EnemyIdentityInput {
306    Anonymous { display_name: String },
307    Npc { npc_id: u32 },
308}
309
310#[derive(Clone, Debug, PartialEq)]
311pub enum EnemyHealthInput {
312    Auto,
313    Fixed {
314        max_health: f64,
315    },
316    ScriptedLinear {
317        display_max_health: f64,
318        death_at_s: f64,
319    },
320}
321
322#[derive(Deserialize, Serialize)]
323#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
324#[expect(
325    clippy::empty_enum_variants_with_brackets,
326    // #t(rust_duplicate_strings) Rust attributes require a literal reason at the declaration they govern.
327    reason = "empty struct variants preserve the tagged JSON object shape"
328)]
329enum EnemyHealthInputSerde {
330    Auto {},
331    Fixed {
332        max_health: f64,
333    },
334    ScriptedLinear {
335        display_max_health: f64,
336        death_at_s: f64,
337    },
338}
339
340impl Serialize for EnemyHealthInput {
341    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
342    where
343        S: Serializer,
344    {
345        match *self {
346            Self::Auto => EnemyHealthInputSerde::Auto {},
347            Self::Fixed { max_health } => EnemyHealthInputSerde::Fixed { max_health },
348            Self::ScriptedLinear {
349                display_max_health,
350                death_at_s,
351            } => EnemyHealthInputSerde::ScriptedLinear {
352                display_max_health,
353                death_at_s,
354            },
355        }
356        .serialize(serializer)
357    }
358}
359
360impl<'de> Deserialize<'de> for EnemyHealthInput {
361    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
362    where
363        D: Deserializer<'de>,
364    {
365        Ok(match EnemyHealthInputSerde::deserialize(deserializer)? {
366            EnemyHealthInputSerde::Auto {} => Self::Auto,
367            EnemyHealthInputSerde::Fixed { max_health } => Self::Fixed { max_health },
368            EnemyHealthInputSerde::ScriptedLinear {
369                display_max_health,
370                death_at_s,
371            } => Self::ScriptedLinear {
372                display_max_health,
373                death_at_s,
374            },
375        })
376    }
377}
378
379#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
380#[serde(rename_all = "snake_case")]
381pub enum EnemyRole {
382    Normal,
383    Boss,
384    Add,
385}
386
387#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
388#[serde(deny_unknown_fields)]
389pub struct EnemyDefinition {
390    pub id: EnemyIdx,
391    pub slug: String,
392    pub identity: EnemyIdentityInput,
393    pub level: u16,
394    pub difficulty: DifficultyContext,
395    pub health: EnemyHealthInput,
396    pub armor_override: Option<f64>,
397    pub auto_attack_dps_override: Option<f64>,
398    pub spell_damage_override: Option<f64>,
399    #[serde(default, skip_serializing_if = "is_zero")]
400    pub creature_aoe_avoidance_pct: f64,
401    pub initial_transform: SpatialTransform,
402    pub group_id: GroupId,
403    #[serde(default)]
404    pub tags: Vec<String>,
405    pub role: EnemyRole,
406    pub spawn_at_s: f64,
407}
408
409#[expect(
410    clippy::trivially_copy_pass_by_ref,
411    reason = "serde skip_serializing_if predicates receive a reference to the field"
412)]
413fn is_zero(value: &f64) -> bool {
414    value.abs() <= f64::EPSILON
415}
416
417#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
418#[serde(deny_unknown_fields)]
419pub struct EnemyGroupDefinition {
420    pub id: GroupId,
421    pub slug: String,
422    #[serde(default)]
423    pub tags: Vec<String>,
424    pub required: bool,
425    pub wave_id: WaveId,
426    pub enemy_ids: Vec<EnemyIdx>,
427}
428
429#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
430#[serde(deny_unknown_fields)]
431pub struct WaveDefinition {
432    pub id: WaveId,
433    pub pull_id: PullId,
434    pub minimum_activation_s: f64,
435    pub depends_on_groups: Vec<GroupId>,
436    pub group_ids: Vec<GroupId>,
437}
438
439#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
440#[serde(deny_unknown_fields)]
441pub struct PullDefinition {
442    pub id: PullId,
443    pub preferred_target: EnemyIdx,
444    pub player_start_transform: Option<SpatialTransform>,
445    pub wave_ids: Vec<WaveId>,
446    pub events: Vec<EncounterScriptEvent>,
447    #[serde(default)]
448    pub reset_after_completion: PlayerResetFlags,
449}
450
451#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
452#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
453pub enum EncounterScriptEvent {
454    Move {
455        at_s: f64,
456        actor: PositionedActorRef,
457        transform: SpatialTransform,
458    },
459    Despawn {
460        at_s: f64,
461        enemy: EnemyIdx,
462        counts_as_completion: bool,
463    },
464}
465
466impl EncounterScriptEvent {
467    fn at_s(&self) -> f64 {
468        match self {
469            Self::Move { at_s, .. } | Self::Despawn { at_s, .. } => *at_s,
470        }
471    }
472}
473
474bitflags::bitflags! {
475    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
476    pub struct PlayerResetFlags: u8 {
477        const RESOURCES = 1 << 0;
478        const COOLDOWNS = 1 << 1;
479        const AURAS = 1 << 2;
480        const GUARDIANS = 1 << 3;
481        const POSITION = 1 << 4;
482    }
483}
484
485#[derive(Debug, thiserror::Error)]
486enum ResetFlagError<'a> {
487    #[error("unknown player reset flag {0}")]
488    Unknown(&'a str),
489    #[error("duplicate player reset flag {0}")]
490    Duplicate(&'a str),
491}
492
493impl Serialize for PlayerResetFlags {
494    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
495    where
496        S: Serializer,
497    {
498        let names = reset_flag_names(*self);
499        let mut sequence = serializer.serialize_seq(Some(names.len()))?;
500
501        for name in names {
502            sequence.serialize_element(name)?;
503        }
504
505        sequence.end()
506    }
507}
508
509impl<'de> Deserialize<'de> for PlayerResetFlags {
510    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
511    where
512        D: Deserializer<'de>,
513    {
514        let names = Vec::<String>::deserialize(deserializer)?;
515        let mut flags = Self::empty();
516
517        for name in names {
518            let flag = match name.as_str() {
519                "resources" => Self::RESOURCES,
520                "cooldowns" => Self::COOLDOWNS,
521                "auras" => Self::AURAS,
522                "guardians" => Self::GUARDIANS,
523                "position" => Self::POSITION,
524                _ => {
525                    return Err(de::Error::custom(ResetFlagError::Unknown(&name)));
526                }
527            };
528
529            if flags.contains(flag) {
530                return Err(de::Error::custom(ResetFlagError::Duplicate(&name)));
531            }
532
533            flags.insert(flag);
534        }
535
536        Ok(flags)
537    }
538}
539
540fn reset_flag_names(flags: PlayerResetFlags) -> Vec<&'static str> {
541    [
542        (PlayerResetFlags::RESOURCES, "resources"),
543        (PlayerResetFlags::COOLDOWNS, "cooldowns"),
544        (PlayerResetFlags::AURAS, "auras"),
545        (PlayerResetFlags::GUARDIANS, "guardians"),
546        (PlayerResetFlags::POSITION, "position"),
547    ]
548    .into_iter()
549    .filter_map(|(flag, name)| flags.contains(flag).then_some(name))
550    .collect()
551}
552
553#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
554#[serde(rename_all = "snake_case")]
555pub enum TerminationPolicy {
556    FixedDuration,
557    PrimaryRequiredEnemyDead,
558    RequiredGroupsComplete,
559    AllEnemiesInRequiredGroupsDead,
560}
561
562#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
563#[serde(deny_unknown_fields)]
564pub struct EncounterDefinition {
565    pub version: u16,
566    pub initial_player_transform: SpatialTransform,
567    pub enemies: Vec<EnemyDefinition>,
568    pub groups: Vec<EnemyGroupDefinition>,
569    pub waves: Vec<WaveDefinition>,
570    pub pulls: Vec<PullDefinition>,
571    pub termination: TerminationPolicy,
572    pub fixed_duration_s: Option<f64>,
573    pub blocked_los_pairs: Vec<BlockedLosPair>,
574    pub spatial_scene: StaticSpatialScene,
575}
576
577impl EncounterDefinition {
578    /// Construct and validate a single-target fixed-duration encounter.
579    ///
580    /// # Errors
581    ///
582    /// Returns an encounter validation error when the supplied duration or level is invalid.
583    pub fn patchwerk(
584        duration_s: f64,
585        level: u16,
586        expansion_id: u32,
587    ) -> Result<Self, EncounterValidationError> {
588        let origin = SpatialTransform {
589            layer: SpatialLayerId(0),
590            position: Position2::new(0.0, 0.0),
591            heading: 0.0,
592        };
593        let encounter = Self {
594            version: ENCOUNTER_VERSION,
595            initial_player_transform: origin,
596            enemies: vec![EnemyDefinition {
597                id: EnemyIdx::PRIMARY,
598                slug: "training_dummy".to_string(),
599                identity: EnemyIdentityInput::Anonymous {
600                    display_name: "Training Dummy".to_string(),
601                },
602                level,
603                difficulty: DifficultyContext::Generic { expansion_id },
604                health: EnemyHealthInput::ScriptedLinear {
605                    display_max_health: 1_000_000.0,
606                    death_at_s: duration_s,
607                },
608                armor_override: None,
609                // A training dummy never swings back: SimC's plain `enemy_t` gets the empty
610                // action list `"/"` and only `tank_dummy_enemy_t` adds `auto_attack`, paired with
611                // a `tank_heal` raid event. Inheriting the creature melee budget here would kill
612                // an unhealed player partway through a long fight.
613                auto_attack_dps_override: Some(0.0),
614                spell_damage_override: None,
615                creature_aoe_avoidance_pct: 0.0,
616                initial_transform: origin,
617                group_id: GroupId(0),
618                tags: Vec::new(),
619                role: EnemyRole::Boss,
620                spawn_at_s: 0.0,
621            }],
622            groups: vec![EnemyGroupDefinition {
623                id: GroupId(0),
624                slug: "patchwerk".to_string(),
625                tags: Vec::new(),
626                required: true,
627                wave_id: WaveId(0),
628                enemy_ids: vec![EnemyIdx::PRIMARY],
629            }],
630            waves: vec![WaveDefinition {
631                id: WaveId(0),
632                pull_id: PullId(0),
633                minimum_activation_s: 0.0,
634                depends_on_groups: Vec::new(),
635                group_ids: vec![GroupId(0)],
636            }],
637            pulls: vec![PullDefinition {
638                id: PullId(0),
639                preferred_target: EnemyIdx::PRIMARY,
640                player_start_transform: None,
641                wave_ids: vec![WaveId(0)],
642                events: Vec::new(),
643                reset_after_completion: PlayerResetFlags::empty(),
644            }],
645            termination: TerminationPolicy::FixedDuration,
646            fixed_duration_s: Some(duration_s),
647            blocked_los_pairs: Vec::new(),
648            spatial_scene: StaticSpatialScene {
649                layers: vec![SpatialLayerDefinition {
650                    id: SpatialLayerId(0),
651                    slug: "ground".to_string(),
652                }],
653                obstacles: Vec::new(),
654            },
655        };
656
657        encounter.validate()?;
658
659        Ok(encounter)
660    }
661}
662
663#[cfg(test)]
664mod tests;