Skip to main content

wowlab_types/types/sim/encounter/
validation.rs

1// #t(file: rust_alloc_in_loop) validation allocates precise indexed field paths only during construction, never on combat hot paths
2// #t(file: rust_unchecked_indexing) dense IDs and references are bounds-checked before ownership and DAG indexing
3// #t(file: rust_cyclomatic_complexity) contract validation intentionally enumerates independent schema invariants
4
5use std::{collections::VecDeque, fmt};
6
7use super::{
8    super::{EnemyIdx, FastSet, PullId},
9    ENCOUNTER_VERSION, EncounterDefinition, EncounterScriptEvent, EncounterValidationError,
10    EnemyHealthInput, EnemyIdentityInput, EnemyRole, PositionedActorRef, SpatialTransform,
11    StaticSpatialScene, TerminationPolicy, invalid,
12    spatial::validate_not_in_obstacle,
13};
14
15impl EncounterDefinition {
16    /// Validate all structural, geometric, and reference invariants.
17    ///
18    /// # Errors
19    ///
20    /// Returns the first invalid field or unsupported version encountered.
21    pub fn validate(&self) -> Result<(), EncounterValidationError> {
22        if self.version != ENCOUNTER_VERSION {
23            return Err(EncounterValidationError::UnsupportedVersion {
24                found: self.version,
25                supported: ENCOUNTER_VERSION,
26            });
27        }
28
29        self.spatial_scene.validate()?;
30        validate_transform(
31            self.initial_player_transform,
32            &self.spatial_scene,
33            "initial_player_transform",
34        )?;
35        self.validate_enemies()?;
36        self.validate_groups()?;
37        self.validate_waves()?;
38        self.validate_pulls()?;
39        self.validate_ownership()?;
40        self.validate_dependencies()?;
41        self.validate_blocked_pairs()?;
42        self.validate_termination()?;
43        self.validate_actor_placement()?;
44
45        Ok(())
46    }
47
48    pub(super) fn validate_ownership(&self) -> Result<(), EncounterValidationError> {
49        let mut enemy_owners = vec![0_usize; self.enemies.len()];
50
51        for group in &self.groups {
52            for enemy_id in &group.enemy_ids {
53                let owner_count = &mut enemy_owners[enemy_id.as_usize()];
54
55                *owner_count = owner_count.checked_add(1).ok_or_else(|| {
56                    invalid(
57                        "ownership",
58                        format!("enemy {enemy_id} group owner count overflowed"),
59                    )
60                })?;
61
62                if self.enemies[enemy_id.as_usize()].group_id != group.id {
63                    return Err(invalid(
64                        "ownership",
65                        format!("enemy {enemy_id} group ownership disagrees"),
66                    ));
67                }
68            }
69        }
70
71        if let Some((index, count)) = enemy_owners
72            .iter()
73            .enumerate()
74            .find(|(_, count)| **count != 1)
75        {
76            return Err(invalid(
77                "ownership",
78                format!("enemy {index} has {count} group owners"),
79            ));
80        }
81
82        let mut group_owners = vec![0_usize; self.groups.len()];
83
84        for wave in &self.waves {
85            for group_id in &wave.group_ids {
86                let owner_count = &mut group_owners[group_id.as_usize()];
87
88                *owner_count = owner_count.checked_add(1).ok_or_else(|| {
89                    invalid(
90                        "ownership",
91                        format!("group {group_id} wave owner count overflowed"),
92                    )
93                })?;
94
95                if self.groups[group_id.as_usize()].wave_id != wave.id {
96                    return Err(invalid(
97                        "ownership",
98                        format!("group {group_id} wave ownership disagrees"),
99                    ));
100                }
101            }
102        }
103
104        if let Some((index, count)) = group_owners
105            .iter()
106            .enumerate()
107            .find(|(_, count)| **count != 1)
108        {
109            return Err(invalid(
110                "ownership",
111                format!("group {index} has {count} wave owners"),
112            ));
113        }
114
115        let mut wave_owners = vec![0_usize; self.waves.len()];
116
117        for pull in &self.pulls {
118            for wave_id in &pull.wave_ids {
119                let owner_count = &mut wave_owners[wave_id.as_usize()];
120
121                *owner_count = owner_count.checked_add(1).ok_or_else(|| {
122                    invalid(
123                        "ownership",
124                        format!("wave {wave_id} pull owner count overflowed"),
125                    )
126                })?;
127
128                if self.waves[wave_id.as_usize()].pull_id != pull.id {
129                    return Err(invalid(
130                        "ownership",
131                        format!("wave {wave_id} pull ownership disagrees"),
132                    ));
133                }
134            }
135        }
136
137        if let Some((index, count)) = wave_owners
138            .iter()
139            .enumerate()
140            .find(|(_, count)| **count != 1)
141        {
142            return Err(invalid(
143                "ownership",
144                format!("wave {index} has {count} pull owners"),
145            ));
146        }
147
148        let primary_group = &self.groups[self.enemies[EnemyIdx::PRIMARY.as_usize()]
149            .group_id
150            .as_usize()];
151        let primary_pull = self.waves[primary_group.wave_id.as_usize()].pull_id;
152
153        if primary_pull != PullId(0) {
154            return Err(invalid(
155                "enemies[0]",
156                "primary enemy must belong to the first pull",
157            ));
158        }
159
160        Ok(())
161    }
162
163    pub(super) fn validate_dependencies(&self) -> Result<(), EncounterValidationError> {
164        let mut dependent_waves = vec![Vec::<usize>::new(); self.groups.len()];
165        let mut remaining_dependencies = Vec::with_capacity(self.waves.len());
166
167        for wave in &self.waves {
168            remaining_dependencies.push(wave.depends_on_groups.len());
169
170            for dependency in &wave.depends_on_groups {
171                let dependency_pull =
172                    self.waves[self.groups[dependency.as_usize()].wave_id.as_usize()].pull_id;
173
174                if dependency_pull != wave.pull_id {
175                    return Err(invalid(
176                        format!("waves[{}].depends_on_groups", wave.id),
177                        "dependencies must remain within one pull",
178                    ));
179                }
180
181                dependent_waves[dependency.as_usize()].push(wave.id.as_usize());
182            }
183        }
184
185        let mut ready_waves = remaining_dependencies
186            .iter()
187            .enumerate()
188            .filter_map(|(wave, remaining)| (*remaining == 0).then_some(wave))
189            .collect::<VecDeque<_>>();
190        let mut reachable_groups = vec![false; self.groups.len()];
191        let mut visited_waves = 0_usize;
192
193        while let Some(wave_index) = ready_waves.pop_front() {
194            visited_waves = visited_waves.checked_add(1).ok_or_else(|| {
195                invalid("waves.depends_on_groups", "visited wave count overflowed")
196            })?;
197
198            for group in &self.waves[wave_index].group_ids {
199                let group_index = group.as_usize();
200
201                if reachable_groups[group_index] {
202                    continue;
203                }
204
205                reachable_groups[group_index] = true;
206
207                for dependent_wave in &dependent_waves[group_index] {
208                    let remaining = &mut remaining_dependencies[*dependent_wave];
209
210                    *remaining = remaining.checked_sub(1).ok_or_else(|| {
211                        invalid("waves.depends_on_groups", "dependency count underflowed")
212                    })?;
213
214                    if *remaining == 0 {
215                        ready_waves.push_back(*dependent_wave);
216                    }
217                }
218            }
219        }
220
221        if visited_waves != self.waves.len() {
222            return Err(invalid(
223                "waves.depends_on_groups",
224                "dependency cycle detected",
225            ));
226        }
227
228        for pull in &self.pulls {
229            for wave_id in &pull.wave_ids {
230                for group_id in &self.waves[wave_id.as_usize()].group_ids {
231                    if self.groups[group_id.as_usize()].required
232                        && !reachable_groups[group_id.as_usize()]
233                    {
234                        return Err(invalid(
235                            format!("pulls[{}]", pull.id),
236                            format!("required group {group_id} is unreachable"),
237                        ));
238                    }
239                }
240            }
241        }
242
243        Ok(())
244    }
245
246    fn validate_enemies(&self) -> Result<(), EncounterValidationError> {
247        if self.enemies.is_empty() {
248            return Err(invalid(
249                "enemies",
250                "encounter must contain at least one enemy",
251            ));
252        }
253
254        let mut slugs = FastSet::default();
255
256        for (index, enemy) in self.enemies.iter().enumerate() {
257            if enemy.id.as_usize() != index {
258                return Err(invalid(
259                    format!("enemies[{index}].id"),
260                    "enemy ids must be dense, zero-based, and in array order",
261                ));
262            }
263
264            validate_slug(&enemy.slug, &format!("enemies[{index}].slug"))?;
265
266            if !slugs.insert(enemy.slug.as_str()) {
267                return Err(invalid(
268                    "enemies",
269                    format!("duplicate enemy slug {}", enemy.slug),
270                ));
271            }
272
273            validate_tags(&enemy.tags, &format!("enemies[{index}].tags"))?;
274
275            if enemy.level == 0 {
276                return Err(invalid(
277                    format!("enemies[{index}].level"),
278                    "level must be positive",
279                ));
280            }
281
282            enemy
283                .difficulty
284                .validate(&format!("enemies[{index}].difficulty"))?;
285
286            match &enemy.identity {
287                EnemyIdentityInput::Anonymous { display_name }
288                    if display_name.trim().is_empty() =>
289                {
290                    return Err(invalid(
291                        format!("enemies[{index}].identity.display_name"),
292                        "display name must be non-empty",
293                    ));
294                }
295                EnemyIdentityInput::Npc { npc_id } if *npc_id == 0 => {
296                    return Err(invalid(
297                        format!("enemies[{index}].identity.npc_id"),
298                        "npc id must be non-zero",
299                    ));
300                }
301                _ => {}
302            }
303
304            validate_health(&enemy.health, index)?;
305            validate_optional_non_negative(
306                enemy.armor_override,
307                &format!("enemies[{index}].armor_override"),
308            )?;
309            validate_optional_non_negative(
310                enemy.auto_attack_dps_override,
311                &format!("enemies[{index}].auto_attack_dps_override"),
312            )?;
313            validate_optional_non_negative(
314                enemy.spell_damage_override,
315                &format!("enemies[{index}].spell_damage_override"),
316            )?;
317            validate_non_negative_time(enemy.spawn_at_s, &format!("enemies[{index}].spawn_at_s"))?;
318
319            if enemy.group_id.as_usize() >= self.groups.len() {
320                return Err(invalid(
321                    format!("enemies[{index}].group_id"),
322                    "missing group",
323                ));
324            }
325
326            validate_transform(
327                enemy.initial_transform,
328                &self.spatial_scene,
329                &format!("enemies[{index}].initial_transform"),
330            )?;
331        }
332
333        Ok(())
334    }
335
336    fn validate_groups(&self) -> Result<(), EncounterValidationError> {
337        if self.groups.is_empty() {
338            return Err(invalid(
339                "groups",
340                "encounter must contain at least one group",
341            ));
342        }
343
344        let mut slugs = FastSet::default();
345
346        for (index, group) in self.groups.iter().enumerate() {
347            if group.id.as_usize() != index {
348                return Err(invalid(
349                    format!("groups[{index}].id"),
350                    "group ids must be dense and ordered",
351                ));
352            }
353
354            validate_slug(&group.slug, &format!("groups[{index}].slug"))?;
355
356            if !slugs.insert(group.slug.as_str()) {
357                return Err(invalid(
358                    "groups",
359                    format!("duplicate group slug {}", group.slug),
360                ));
361            }
362
363            validate_tags(&group.tags, &format!("groups[{index}].tags"))?;
364            validate_nonempty_unique_refs(&group.enemy_ids, &format!("groups[{index}].enemy_ids"))?;
365
366            for enemy in &group.enemy_ids {
367                if enemy.as_usize() >= self.enemies.len() {
368                    return Err(invalid(
369                        format!("groups[{index}].enemy_ids"),
370                        format!("missing enemy {enemy}"),
371                    ));
372                }
373            }
374
375            if group.wave_id.as_usize() >= self.waves.len() {
376                return Err(invalid(format!("groups[{index}].wave_id"), "missing wave"));
377            }
378        }
379
380        Ok(())
381    }
382
383    fn validate_waves(&self) -> Result<(), EncounterValidationError> {
384        if self.waves.is_empty() {
385            return Err(invalid("waves", "encounter must contain at least one wave"));
386        }
387
388        for (index, wave) in self.waves.iter().enumerate() {
389            if wave.id.as_usize() != index {
390                return Err(invalid(
391                    format!("waves[{index}].id"),
392                    "wave ids must be dense and ordered",
393                ));
394            }
395
396            validate_non_negative_time(
397                wave.minimum_activation_s,
398                &format!("waves[{index}].minimum_activation_s"),
399            )?;
400            validate_nonempty_unique_refs(&wave.group_ids, &format!("waves[{index}].group_ids"))?;
401            validate_unique_refs(
402                &wave.depends_on_groups,
403                &format!("waves[{index}].depends_on_groups"),
404            )?;
405
406            if wave.pull_id.as_usize() >= self.pulls.len() {
407                return Err(invalid(format!("waves[{index}].pull_id"), "missing pull"));
408            }
409
410            for group in wave.group_ids.iter().chain(&wave.depends_on_groups) {
411                if group.as_usize() >= self.groups.len() {
412                    return Err(invalid(
413                        format!("waves[{index}]"),
414                        format!("missing group {group}"),
415                    ));
416                }
417            }
418
419            if wave
420                .depends_on_groups
421                .iter()
422                .any(|group| wave.group_ids.contains(group))
423            {
424                return Err(invalid(
425                    format!("waves[{index}].depends_on_groups"),
426                    "wave cannot depend on one of its own groups",
427                ));
428            }
429        }
430
431        Ok(())
432    }
433
434    fn validate_pulls(&self) -> Result<(), EncounterValidationError> {
435        if self.pulls.is_empty() {
436            return Err(invalid("pulls", "encounter must contain at least one pull"));
437        }
438
439        for (index, pull) in self.pulls.iter().enumerate() {
440            if pull.id.as_usize() != index {
441                return Err(invalid(
442                    format!("pulls[{index}].id"),
443                    "pull ids must be dense and ordered",
444                ));
445            }
446
447            validate_nonempty_unique_refs(&pull.wave_ids, &format!("pulls[{index}].wave_ids"))?;
448
449            for wave in &pull.wave_ids {
450                if wave.as_usize() >= self.waves.len() {
451                    return Err(invalid(
452                        format!("pulls[{index}].wave_ids"),
453                        format!("missing wave {wave}"),
454                    ));
455                }
456            }
457
458            if let Some(transform) = pull.player_start_transform {
459                validate_transform(
460                    transform,
461                    &self.spatial_scene,
462                    &format!("pulls[{index}].player_start_transform"),
463                )?;
464            }
465
466            let pull_enemy_ids = self.pull_enemy_ids(pull.id);
467
468            if !pull_enemy_ids.contains(&pull.preferred_target) {
469                return Err(invalid(
470                    format!("pulls[{index}].preferred_target"),
471                    "preferred target is not in this pull",
472                ));
473            }
474
475            let bosses: Vec<_> = pull_enemy_ids
476                .iter()
477                .copied()
478                .filter(|enemy| self.enemies[enemy.as_usize()].role == EnemyRole::Boss)
479                .collect();
480
481            if bosses.len() > 1 {
482                return Err(invalid(
483                    format!("pulls[{index}]"),
484                    "a pull may contain at most one boss",
485                ));
486            }
487
488            if bosses
489                .first()
490                .is_some_and(|boss| *boss != pull.preferred_target)
491            {
492                return Err(invalid(
493                    format!("pulls[{index}].preferred_target"),
494                    "the pull boss must be preferred",
495                ));
496            }
497
498            let has_required = pull.wave_ids.iter().any(|wave_id| {
499                self.waves[wave_id.as_usize()]
500                    .group_ids
501                    .iter()
502                    .any(|group_id| self.groups[group_id.as_usize()].required)
503            });
504
505            if !has_required {
506                return Err(invalid(
507                    format!("pulls[{index}]"),
508                    "pull must contain a required group",
509                ));
510            }
511
512            for (event_index, event) in pull.events.iter().enumerate() {
513                validate_non_negative_time(
514                    event.at_s(),
515                    &format!("pulls[{index}].events[{event_index}].at_s"),
516                )?;
517
518                match event {
519                    EncounterScriptEvent::Move {
520                        actor, transform, ..
521                    } => {
522                        validate_transform(
523                            *transform,
524                            &self.spatial_scene,
525                            &format!("pulls[{index}].events[{event_index}].transform"),
526                        )?;
527
528                        if let PositionedActorRef::Enemy(enemy) = actor {
529                            if !pull_enemy_ids.contains(enemy) {
530                                return Err(invalid(
531                                    format!("pulls[{index}].events[{event_index}].actor"),
532                                    "enemy is not in this pull",
533                                ));
534                            }
535                        }
536                    }
537                    EncounterScriptEvent::Despawn {
538                        at_s,
539                        enemy,
540                        counts_as_completion,
541                    } => {
542                        if !pull_enemy_ids.contains(enemy) {
543                            return Err(invalid(
544                                format!("pulls[{index}].events[{event_index}].enemy"),
545                                "enemy is not in this pull",
546                            ));
547                        }
548
549                        if *at_s <= self.enemies[enemy.as_usize()].spawn_at_s {
550                            return Err(invalid(
551                                format!("pulls[{index}].events[{event_index}].at_s"),
552                                "despawn must be strictly after spawn",
553                            ));
554                        }
555
556                        let group =
557                            &self.groups[self.enemies[enemy.as_usize()].group_id.as_usize()];
558
559                        if self.termination == TerminationPolicy::AllEnemiesInRequiredGroupsDead
560                            && group.required
561                            && *counts_as_completion
562                        {
563                            return Err(invalid(
564                                format!("pulls[{index}].events[{event_index}]"),
565                                "required-group completion-counting despawn is invalid for all-enemies-dead termination",
566                            ));
567                        }
568                    }
569                }
570            }
571        }
572
573        if self.pulls[0].preferred_target != EnemyIdx::PRIMARY {
574            return Err(invalid(
575                "pulls[0].preferred_target",
576                "first pull must prefer the primary enemy",
577            ));
578        }
579
580        Ok(())
581    }
582
583    fn validate_blocked_pairs(&self) -> Result<(), EncounterValidationError> {
584        let mut pairs = FastSet::default();
585
586        for (index, pair) in self.blocked_los_pairs.iter().enumerate() {
587            if pair.a >= pair.b {
588                return Err(invalid(
589                    format!("blocked_los_pairs[{index}]"),
590                    "endpoints must differ and use canonical order",
591                ));
592            }
593
594            for endpoint in [pair.a, pair.b] {
595                if let PositionedActorRef::Enemy(enemy) = endpoint {
596                    if enemy.as_usize() >= self.enemies.len() {
597                        return Err(invalid(
598                            format!("blocked_los_pairs[{index}]"),
599                            format!("missing enemy {enemy}"),
600                        ));
601                    }
602                }
603            }
604
605            if !pairs.insert((pair.a, pair.b)) {
606                return Err(invalid(
607                    format!("blocked_los_pairs[{index}]"),
608                    "duplicate symmetric pair",
609                ));
610            }
611        }
612
613        Ok(())
614    }
615
616    fn validate_termination(&self) -> Result<(), EncounterValidationError> {
617        match (self.termination, self.fixed_duration_s) {
618            (TerminationPolicy::FixedDuration, Some(duration))
619                if duration.is_finite() && duration > 0.0 => {}
620            (TerminationPolicy::FixedDuration, _) => {
621                return Err(invalid(
622                    "fixed_duration_s",
623                    "fixed-duration termination requires a finite positive duration",
624                ));
625            }
626            (_, None) => {}
627            (_, Some(_)) => {
628                return Err(invalid(
629                    "fixed_duration_s",
630                    "death/group termination must not set a fixed duration",
631                ));
632            }
633        }
634
635        if self.termination == TerminationPolicy::PrimaryRequiredEnemyDead {
636            let primary = &self.enemies[EnemyIdx::PRIMARY.as_usize()];
637
638            if !self.groups[primary.group_id.as_usize()].required {
639                return Err(invalid(
640                    "termination",
641                    "primary enemy and its group must be required",
642                ));
643            }
644        }
645
646        Ok(())
647    }
648
649    fn validate_actor_placement(&self) -> Result<(), EncounterValidationError> {
650        validate_not_in_obstacle(
651            self.initial_player_transform,
652            &self.spatial_scene,
653            "initial_player_transform",
654        )?;
655
656        for (index, enemy) in self.enemies.iter().enumerate() {
657            validate_not_in_obstacle(
658                enemy.initial_transform,
659                &self.spatial_scene,
660                &format!("enemies[{index}].initial_transform"),
661            )?;
662        }
663
664        for (pull_index, pull) in self.pulls.iter().enumerate() {
665            if let Some(transform) = pull.player_start_transform {
666                validate_not_in_obstacle(
667                    transform,
668                    &self.spatial_scene,
669                    &format!("pulls[{pull_index}].player_start_transform"),
670                )?;
671            }
672
673            for (event_index, event) in pull.events.iter().enumerate() {
674                if let EncounterScriptEvent::Move { transform, .. } = event {
675                    validate_not_in_obstacle(
676                        *transform,
677                        &self.spatial_scene,
678                        &format!("pulls[{pull_index}].events[{event_index}].transform"),
679                    )?;
680                }
681            }
682        }
683
684        Ok(())
685    }
686
687    fn pull_enemy_ids(&self, pull_id: PullId) -> FastSet<EnemyIdx> {
688        let mut result = FastSet::default();
689
690        for wave_id in &self.pulls[pull_id.as_usize()].wave_ids {
691            for group_id in &self.waves[wave_id.as_usize()].group_ids {
692                result.extend(self.groups[group_id.as_usize()].enemy_ids.iter().copied());
693            }
694        }
695
696        result
697    }
698}
699
700pub(super) fn validate_slug(slug: &str, path: &str) -> Result<(), EncounterValidationError> {
701    if slug.trim().is_empty() {
702        Err(invalid(path, "slug must be non-empty"))
703    } else {
704        Ok(())
705    }
706}
707
708fn validate_tags(tags: &[String], path: &str) -> Result<(), EncounterValidationError> {
709    let mut unique = FastSet::default();
710
711    for tag in tags {
712        if tag.trim().is_empty() {
713            return Err(invalid(path, "tags must be non-empty"));
714        }
715
716        if !unique.insert(tag.as_str()) {
717            return Err(invalid(path, format!("duplicate tag {tag}")));
718        }
719    }
720
721    Ok(())
722}
723
724fn validate_health(
725    health: &EnemyHealthInput,
726    index: usize,
727) -> Result<(), EncounterValidationError> {
728    match health {
729        EnemyHealthInput::Auto => Ok(()),
730        EnemyHealthInput::Fixed { max_health } if max_health.is_finite() && *max_health > 0.0 => {
731            Ok(())
732        }
733        EnemyHealthInput::ScriptedLinear {
734            display_max_health,
735            death_at_s,
736        } if display_max_health.is_finite()
737            && *display_max_health > 0.0
738            && death_at_s.is_finite()
739            && *death_at_s > 0.0 =>
740        {
741            Ok(())
742        }
743        _ => Err(invalid(
744            format!("enemies[{index}].health"),
745            "health and scripted death time must be finite and positive",
746        )),
747    }
748}
749
750fn validate_optional_non_negative(
751    value: Option<f64>,
752    path: &str,
753) -> Result<(), EncounterValidationError> {
754    if value.is_some_and(|value| !value.is_finite() || value < 0.0) {
755        Err(invalid(path, "value must be finite and non-negative"))
756    } else {
757        Ok(())
758    }
759}
760
761fn validate_non_negative_time(value: f64, path: &str) -> Result<(), EncounterValidationError> {
762    if value.is_finite() && value >= 0.0 {
763        Ok(())
764    } else {
765        Err(invalid(path, "time must be finite and non-negative"))
766    }
767}
768
769fn validate_transform(
770    transform: SpatialTransform,
771    scene: &StaticSpatialScene,
772    path: &str,
773) -> Result<(), EncounterValidationError> {
774    if usize::from(transform.layer.0) >= scene.layers.len() {
775        return Err(invalid(
776            path,
777            format!("missing spatial layer {}", transform.layer.0),
778        ));
779    }
780
781    if !transform.position.x.is_finite() || !transform.position.y.is_finite() {
782        return Err(invalid(path, "position coordinates must be finite"));
783    }
784
785    if !transform.heading.is_finite() {
786        return Err(invalid(path, "heading must be finite"));
787    }
788
789    Ok(())
790}
791
792fn validate_nonempty_unique_refs<T>(refs: &[T], path: &str) -> Result<(), EncounterValidationError>
793where
794    T: Copy + Eq + std::hash::Hash + fmt::Display,
795{
796    if refs.is_empty() {
797        return Err(invalid(path, "list must be non-empty"));
798    }
799
800    validate_unique_refs(refs, path)
801}
802
803fn validate_unique_refs<T>(refs: &[T], path: &str) -> Result<(), EncounterValidationError>
804where
805    T: Copy + Eq + std::hash::Hash + fmt::Display,
806{
807    let mut unique = FastSet::default();
808
809    for reference in refs {
810        if !unique.insert(*reference) {
811            return Err(invalid(path, format!("duplicate reference {reference}")));
812        }
813    }
814
815    Ok(())
816}