1use 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;
14pub 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 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)]
78pub 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 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 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 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 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;