1use 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 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}