1#![cfg_attr(
2 all(feature = "test-support", not(test)),
3 expect(
4 dead_code,
5 reason = "the feature exports shared helpers while encounter-only fixtures remain unit-test scoped"
6 )
7)]
8
9use wowlab_engine_gamedata::{ResolvedGameData, SpellProps};
10use wowlab_engine_ports::{
11 CombatStats, ResolvedEncounter, ResolvedEnemyCombatStats, ResolvedEnemyEncounter,
12 ResolvedEnemyIdentityMetadata,
13};
14use wowlab_types::{
15 constants::{HUNDRED, THOUSAND},
16 sim::{
17 BlockedLosPair, DifficultyContext, EncounterDefinition, EnemyDefinition,
18 EnemyGroupDefinition, EnemyHealthInput, EnemyIdentityInput, EnemyIdx, EnemyRole, GroupId,
19 PlayerResetFlags, Position2, PositionedActorRef, PullDefinition, PullId, Rotation,
20 RotationAction, SpatialLayerDefinition, SpatialLayerId, SpatialTransform, SpellIdx,
21 StaticObstacle, StaticSpatialScene, TerminationPolicy, WaveDefinition, WaveId,
22 },
23};
24
25use crate::{CombatBuildError, CombatHandler, CombatSystemBuilder};
26
27#[must_use]
29pub fn minimal_rotation() -> Rotation {
30 Rotation {
31 name: "test".to_string(),
32 actions: vec![RotationAction::Wait {
33 seconds: 1.0,
34 enabled: true,
35 condition: None,
36 }],
37 ..Rotation::empty()
38 }
39}
40
41#[must_use]
47pub fn rotation_from_json(json: &str) -> Rotation {
48 wowlab_engine_domain::rotation::parse_and_validate(json)
49 .expect("test rotation fixture must be valid")
50}
51
52#[must_use]
54pub fn rotation_casting(spells: &[&str]) -> Rotation {
55 let actions = spells
56 .iter()
57 .map(|spell| RotationAction::Cast {
58 spell: (*spell).to_string(),
59 empower_rank: None,
60 enabled: true,
61 condition: None,
62 target_if: None,
63 })
64 .collect();
65
66 Rotation {
67 name: "test".to_string(),
68 actions,
69 ..Rotation::empty()
70 }
71}
72
73pub fn handler_with_spells(
79 rotation: &Rotation,
80 spells: &[(&str, u32)],
81) -> Result<CombatHandler, CombatBuildError> {
82 let mut builder =
83 CombatSystemBuilder::new(CombatStats::default()).encounter(default_encounter());
84
85 for &(name, spell_id) in spells {
86 builder = builder.spell(name, spell_id, Ok);
87 }
88
89 builder.build(rotation).map(CombatHandler::from_built)
90}
91
92#[must_use]
94pub fn game_data_with(
95 spell_id: u32,
96 props: SpellProps,
97 base_points: &[(u8, f64)],
98 sp_coefs: &[(u8, f64)],
99) -> ResolvedGameData {
100 let spell = SpellIdx::from_raw(spell_id);
101 let mut data = ResolvedGameData::builder();
102
103 data.insert_spell_props(spell, props);
104
105 for &(effect_index, value) in base_points {
106 data.insert_base_points(spell, effect_index, value);
107 }
108
109 for &(effect_index, value) in sp_coefs {
110 data.insert_sp_coef(spell, effect_index, value);
111 }
112
113 data.build()
114}
115
116#[rstest::fixture]
117pub fn combat_stats_fixture() -> CombatStats {
118 CombatStats::default()
119}
120
121#[rstest::fixture]
122pub fn combat_builder_fixture(
123 #[default(CombatStats::default())] stats: CombatStats,
124) -> CombatSystemBuilder {
125 CombatSystemBuilder::new(stats).encounter(default_encounter())
126}
127
128const TEST_DURATION_S: f64 = 300.0;
129const TEST_LEVEL: u16 = 80;
130const TEST_EXPANSION_ID: u32 = 11;
131const TEST_ENCOUNTER_VERSION: u16 = 2;
132const TEST_ARMOR: f64 = 377.0;
133const TEST_ARMOR_CONSTANT_BITS: u32 = 0x4475_22c1;
134const SPATIAL_NEAR_X: f64 = 2.0;
135const SPATIAL_FAR_X: f64 = 4.0;
136const SPATIAL_SIDE_Y: f64 = 4.0;
137const EXPLICIT_PAIR_SECOND_X: f64 = 3.0;
138const BLOCKED_SECONDARY_WALL_X: f64 = 3.0;
139const CROSS_LAYER_NEAR_X: f64 = 0.25;
140const SPATIAL_FAR_ENEMY: EnemyIdx = EnemyIdx(2);
141const SPATIAL_SIDE_ENEMY: EnemyIdx = EnemyIdx(3);
142
143pub(crate) fn default_encounter() -> ResolvedEncounter {
144 wowlab_engine_ports::test_support::introspection_fixture(TEST_DURATION_S)
145 .expect("engine-combat's default test encounter must resolve")
146}
147
148fn transform(x: f64) -> SpatialTransform {
149 SpatialTransform {
150 layer: SpatialLayerId(0),
151 position: Position2::new(x, 0.0),
152 heading: 0.0,
153 }
154}
155
156fn staged_enemy(
157 id: EnemyIdx,
158 slug: &str,
159 group_id: GroupId,
160 spawn_at_s: f64,
161 role: EnemyRole,
162) -> EnemyDefinition {
163 EnemyDefinition {
164 id,
165 slug: slug.to_string(),
166 identity: EnemyIdentityInput::Anonymous {
167 display_name: slug.to_string(),
168 },
169 level: TEST_LEVEL,
170 difficulty: DifficultyContext::Generic {
171 expansion_id: TEST_EXPANSION_ID,
172 },
173 health: EnemyHealthInput::Fixed {
174 max_health: HUNDRED,
175 },
176 armor_override: Some(TEST_ARMOR),
177 auto_attack_dps_override: None,
178 spell_damage_override: None,
179 creature_aoe_avoidance_pct: 0.0,
180 initial_transform: transform(f64::from(id.0)),
181 group_id,
182 tags: Vec::new(),
183 role,
184 spawn_at_s,
185 }
186}
187
188fn spatial_enemy(
189 id: EnemyIdx,
190 position: Position2,
191 group_id: GroupId,
192 tags: Vec<Box<str>>,
193) -> EnemyDefinition {
194 let slug = format!("spatial_enemy_{}", id.0);
195 let mut enemy = staged_enemy(id, &slug, group_id, 0.0, EnemyRole::Normal);
196
197 enemy.initial_transform = SpatialTransform {
198 layer: SpatialLayerId(0),
199 position,
200 heading: 0.0,
201 };
202 enemy.tags = tags.into_iter().map(Into::into).collect();
203
204 enemy
205}
206
207pub(crate) fn two_enemy_encounter() -> ResolvedEncounter {
208 two_enemy_encounter_with_healths(None, HUNDRED)
209}
210
211pub(crate) fn two_damage_driven_enemy_encounter() -> ResolvedEncounter {
212 two_enemy_encounter_with_healths(Some(HUNDRED), HUNDRED)
213}
214
215pub(crate) fn timed_add_encounter() -> ResolvedEncounter {
216 let mut definition =
217 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
218 .expect("timed-add test starts from valid Patchwerk");
219 let mut add = definition.enemies[0].clone();
220
221 add.id = EnemyIdx(1);
222 add.slug = "timed_add".to_string();
223 add.identity = EnemyIdentityInput::Anonymous {
224 display_name: "Timed Add".to_string(),
225 };
226 add.health = EnemyHealthInput::Fixed {
227 max_health: HUNDRED,
228 };
229 add.role = EnemyRole::Normal;
230 add.spawn_at_s = TEST_DURATION_S;
231 definition.groups[0].enemy_ids.push(add.id);
232 definition.enemies.push(add);
233 definition
234 .validate()
235 .expect("timed-add authored encounter is valid");
236
237 resolve_definition(definition)
238}
239
240pub(crate) fn four_enemy_spatial_encounter() -> ResolvedEncounter {
241 let mut definition =
242 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
243 .expect("spatial test starts from valid Patchwerk");
244
245 definition.initial_player_transform.heading = 0.0;
246 definition.enemies[0].health = EnemyHealthInput::Fixed {
247 max_health: HUNDRED,
248 };
249 definition.enemies[0].initial_transform = SpatialTransform {
250 layer: SpatialLayerId(0),
251 position: Position2::new(SPATIAL_NEAR_X, 0.0),
252 heading: 0.0,
253 };
254 definition.enemies[0].tags = vec!["priority".into()];
255 let group_id = definition.groups[0].id;
256
257 for enemy in [
258 spatial_enemy(
259 EnemyIdx(1),
260 Position2::new(SPATIAL_NEAR_X, 0.0),
261 group_id,
262 vec!["priority".into(), "caster".into()],
263 ),
264 spatial_enemy(
265 SPATIAL_FAR_ENEMY,
266 Position2::new(SPATIAL_FAR_X, 0.0),
267 group_id,
268 Vec::new(),
269 ),
270 spatial_enemy(
271 SPATIAL_SIDE_ENEMY,
272 Position2::new(0.0, SPATIAL_SIDE_Y),
273 group_id,
274 Vec::new(),
275 ),
276 ] {
277 definition.groups[0].enemy_ids.push(enemy.id);
278 definition.enemies.push(enemy);
279 }
280
281 definition.groups[0].tags = vec!["pack".into()];
282 definition.validate().expect("spatial encounter is valid");
283
284 resolve_definition(definition)
285}
286
287pub(crate) fn many_enemy_spatial_encounter(enemy_count: u16) -> ResolvedEncounter {
288 assert!(enemy_count > 0, "spatial test encounter must not be empty");
289 let mut definition =
290 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
291 .expect("many-enemy test starts from valid Patchwerk");
292
293 definition.enemies[0].health = EnemyHealthInput::Fixed {
294 max_health: HUNDRED,
295 };
296 definition.enemies[0].initial_transform = SpatialTransform {
297 layer: SpatialLayerId(0),
298 position: Position2::new(1.0, 0.0),
299 heading: 0.0,
300 };
301 definition.enemies[0].tags = vec!["selected".into()];
302 let group_id = definition.groups[0].id;
303
304 for raw_id in 1..enemy_count {
305 let enemy = spatial_enemy(
306 EnemyIdx(raw_id),
307 Position2::new(THOUSAND + f64::from(raw_id), 0.0),
308 group_id,
309 Vec::new(),
310 );
311
312 definition.groups[0].enemy_ids.push(enemy.id);
313 definition.enemies.push(enemy);
314 }
315
316 definition
317 .validate()
318 .expect("many-enemy spatial encounter is valid");
319
320 resolve_definition(definition)
321}
322
323pub(crate) fn linear_nearest_spatial_encounter(
324 enemy_count: u16,
325 tagged_enemy: Option<EnemyIdx>,
326 blocked_prefix: u16,
327) -> ResolvedEncounter {
328 assert!(
329 enemy_count > 0,
330 "nearest-selector encounter must not be empty"
331 );
332 assert!(blocked_prefix <= enemy_count, "blocked prefix must exist");
333
334 if let Some(enemy) = tagged_enemy {
335 assert!(
336 enemy.as_usize() < usize::from(enemy_count),
337 "tagged nearest-selector enemy must exist"
338 );
339 }
340
341 let mut definition =
342 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
343 .expect("nearest-selector test starts from valid Patchwerk");
344
345 definition.enemies[0].health = EnemyHealthInput::Fixed {
346 max_health: HUNDRED,
347 };
348 definition.enemies[0].initial_transform = transform(1.0);
349 definition.enemies[0].tags = if tagged_enemy == Some(EnemyIdx::PRIMARY) {
350 vec!["selected".into()]
351 } else {
352 Vec::default()
353 };
354 let group_id = definition.groups[0].id;
355
356 for raw_id in 1..enemy_count {
357 let id = EnemyIdx(raw_id);
358 let tags = if tagged_enemy == Some(id) {
359 vec!["selected".into()]
360 } else {
361 Vec::default()
362 };
363 let enemy = spatial_enemy(
364 id,
365 Position2::new(f64::from(raw_id) + 1.0, 0.0),
366 group_id,
367 tags,
368 );
369
370 definition.groups[0].enemy_ids.push(id);
371 definition.enemies.push(enemy);
372 }
373
374 definition.blocked_los_pairs = (0..blocked_prefix)
375 .map(|raw_id| BlockedLosPair {
376 a: PositionedActorRef::Player,
377 b: PositionedActorRef::Enemy(EnemyIdx(raw_id)),
378 })
379 .collect();
380 definition
381 .validate()
382 .expect("linear nearest-selector encounter is valid");
383
384 resolve_definition(definition)
385}
386
387pub(crate) fn equal_distance_blocked_nearest_encounter() -> ResolvedEncounter {
388 let mut definition =
389 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
390 .expect("equal-distance test starts from valid Patchwerk");
391
392 definition.enemies[0].health = EnemyHealthInput::Fixed {
393 max_health: HUNDRED,
394 };
395 definition.enemies[0].initial_transform = transform(SPATIAL_NEAR_X);
396 let group_id = definition.groups[0].id;
397 let second = spatial_enemy(
398 EnemyIdx(1),
399 Position2::new(SPATIAL_NEAR_X, 0.0),
400 group_id,
401 Vec::new(),
402 );
403
404 definition.groups[0].enemy_ids.push(second.id);
405 definition.enemies.push(second);
406 definition.blocked_los_pairs.push(BlockedLosPair {
407 a: PositionedActorRef::Player,
408 b: PositionedActorRef::Enemy(EnemyIdx::PRIMARY),
409 });
410 definition
411 .validate()
412 .expect("equal-distance blocked encounter is valid");
413
414 resolve_definition(definition)
415}
416
417pub(crate) fn cross_layer_nearest_fallback_encounter() -> ResolvedEncounter {
418 let mut definition =
419 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
420 .expect("cross-layer nearest test starts from valid Patchwerk");
421
422 definition.enemies[0].health = EnemyHealthInput::Fixed {
423 max_health: HUNDRED,
424 };
425 definition
426 .spatial_scene
427 .layers
428 .push(SpatialLayerDefinition {
429 id: SpatialLayerId(1),
430 slug: "upper".to_string(),
431 });
432 definition.enemies[0].initial_transform = SpatialTransform {
433 layer: SpatialLayerId(1),
434 position: Position2::new(CROSS_LAYER_NEAR_X, 0.0),
435 heading: 0.0,
436 };
437 let group_id = definition.groups[0].id;
438 let second = spatial_enemy(
439 EnemyIdx(1),
440 Position2::new(SPATIAL_FAR_X, 0.0),
441 group_id,
442 Vec::new(),
443 );
444
445 definition.groups[0].enemy_ids.push(second.id);
446 definition.enemies.push(second);
447 definition
448 .validate()
449 .expect("cross-layer nearest fallback encounter is valid");
450
451 resolve_definition(definition)
452}
453
454pub(crate) fn blocked_los_encounter(explicit_pair: bool) -> ResolvedEncounter {
455 let mut definition =
456 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
457 .expect("LOS test starts from valid Patchwerk");
458
459 definition.enemies[0].health = EnemyHealthInput::Fixed {
460 max_health: HUNDRED,
461 };
462 definition.enemies[0].initial_transform = transform(SPATIAL_NEAR_X);
463
464 if explicit_pair {
465 definition.blocked_los_pairs.push(BlockedLosPair {
466 a: PositionedActorRef::Player,
467 b: PositionedActorRef::Enemy(EnemyIdx::PRIMARY),
468 });
469 } else {
470 definition
471 .spatial_scene
472 .obstacles
473 .push(StaticObstacle::Segment {
474 layer: SpatialLayerId(0),
475 start: Position2::new(1.0, -1.0),
476 end: Position2::new(1.0, 1.0),
477 });
478 }
479
480 definition.validate().expect("LOS encounter is valid");
481
482 resolve_definition(definition)
483}
484
485pub(crate) fn blocked_secondary_los_encounter() -> ResolvedEncounter {
486 let mut definition =
487 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
488 .expect("secondary LOS test starts from valid Patchwerk");
489
490 definition.enemies[0].health = EnemyHealthInput::Fixed {
491 max_health: HUNDRED,
492 };
493 definition.enemies[0].initial_transform = transform(SPATIAL_NEAR_X);
494 let group_id = definition.groups[0].id;
495 let second = spatial_enemy(
496 EnemyIdx(1),
497 Position2::new(SPATIAL_FAR_X, 0.0),
498 group_id,
499 Vec::new(),
500 );
501
502 definition.groups[0].enemy_ids.push(second.id);
503 definition.enemies.push(second);
504 definition
505 .spatial_scene
506 .obstacles
507 .push(StaticObstacle::Segment {
508 layer: SpatialLayerId(0),
509 start: Position2::new(BLOCKED_SECONDARY_WALL_X, -1.0),
510 end: Position2::new(BLOCKED_SECONDARY_WALL_X, 1.0),
511 });
512 definition
513 .validate()
514 .expect("secondary LOS encounter is valid");
515
516 resolve_definition(definition)
517}
518
519pub(crate) fn cross_layer_los_encounter() -> ResolvedEncounter {
520 let mut definition =
521 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
522 .expect("cross-layer test starts from valid Patchwerk");
523
524 definition.enemies[0].health = EnemyHealthInput::Fixed {
525 max_health: HUNDRED,
526 };
527 definition
528 .spatial_scene
529 .layers
530 .push(SpatialLayerDefinition {
531 id: SpatialLayerId(1),
532 slug: "upper".to_string(),
533 });
534 definition.enemies[0].initial_transform.layer = SpatialLayerId(1);
535 definition
536 .validate()
537 .expect("cross-layer authored encounter is valid");
538
539 resolve_definition(definition)
540}
541
542pub(crate) fn explicit_enemy_pair_los_encounter() -> ResolvedEncounter {
543 let mut definition =
544 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
545 .expect("explicit-pair test starts from valid Patchwerk");
546
547 definition.enemies[0].health = EnemyHealthInput::Fixed {
548 max_health: HUNDRED,
549 };
550 definition.enemies[0].initial_transform = transform(1.0);
551 let group_id = definition.groups[0].id;
552 let second = spatial_enemy(
553 EnemyIdx(1),
554 Position2::new(EXPLICIT_PAIR_SECOND_X, 0.0),
555 group_id,
556 Vec::new(),
557 );
558
559 definition.groups[0].enemy_ids.push(second.id);
560 definition.enemies.push(second);
561 definition.blocked_los_pairs.push(BlockedLosPair {
562 a: PositionedActorRef::Enemy(EnemyIdx(0)),
563 b: PositionedActorRef::Enemy(EnemyIdx(1)),
564 });
565 definition
566 .validate()
567 .expect("explicit enemy-pair authored encounter is valid");
568
569 resolve_definition(definition)
570}
571
572pub(crate) fn two_damage_driven_enemy_encounter_with_healths(
573 primary_health: f64,
574 secondary_health: f64,
575) -> ResolvedEncounter {
576 two_enemy_encounter_with_healths(Some(primary_health), secondary_health)
577}
578
579fn two_enemy_encounter_with_healths(
580 primary_health: Option<f64>,
581 secondary_health: f64,
582) -> ResolvedEncounter {
583 let mut definition =
584 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
585 .expect("two-enemy test starts from valid Patchwerk");
586
587 if let Some(max_health) = primary_health {
588 definition.enemies[0].health = EnemyHealthInput::Fixed { max_health };
589 }
590
591 let mut second = definition.enemies[0].clone();
592
593 second.id = EnemyIdx(1);
594 second.slug = "second_enemy".to_string();
595 second.identity = EnemyIdentityInput::Anonymous {
596 display_name: "Second Enemy".to_string(),
597 };
598 second.health = EnemyHealthInput::Fixed {
599 max_health: secondary_health,
600 };
601 second.role = EnemyRole::Normal;
602 definition.groups[0].enemy_ids.push(second.id);
603 definition.enemies.push(second);
604 definition
605 .validate()
606 .expect("dense two-enemy authored encounter is valid");
607
608 resolve_definition(definition)
609}
610
611pub(crate) fn damage_driven_primary_encounter(max_health: f64) -> ResolvedEncounter {
612 let mut definition =
613 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
614 .expect("damage-driven test starts from valid Patchwerk");
615
616 definition.enemies[0].health = EnemyHealthInput::Fixed { max_health };
617 definition
618 .validate()
619 .expect("damage-driven authored encounter is valid");
620
621 resolve_definition(definition)
622}
623
624pub(crate) fn damage_driven_primary_encounter_with_armor(
625 max_health: f64,
626 armor: f64,
627) -> ResolvedEncounter {
628 let mut definition =
629 EncounterDefinition::patchwerk(TEST_DURATION_S, TEST_LEVEL, TEST_EXPANSION_ID)
630 .expect("damage-driven test starts from valid Patchwerk");
631
632 definition.enemies[0].health = EnemyHealthInput::Fixed { max_health };
633 definition.enemies[0].armor_override = Some(armor);
634 definition
635 .validate()
636 .expect("damage-driven authored encounter is valid");
637
638 resolve_definition(definition)
639}
640
641pub(crate) fn staged_activation_encounter() -> ResolvedEncounter {
642 let definition = EncounterDefinition {
643 version: TEST_ENCOUNTER_VERSION,
644 initial_player_transform: transform(0.0),
645 enemies: vec![
646 staged_enemy(EnemyIdx(0), "primary", GroupId(0), 0.0, EnemyRole::Boss),
647 staged_enemy(
648 EnemyIdx(1),
649 "positive_spawn",
650 GroupId(0),
651 5.0,
652 EnemyRole::Normal,
653 ),
654 staged_enemy(
655 EnemyIdx(2),
656 "delayed_wave",
657 GroupId(1),
658 0.0,
659 EnemyRole::Normal,
660 ),
661 staged_enemy(
662 EnemyIdx(3),
663 "dependency_wave",
664 GroupId(2),
665 0.0,
666 EnemyRole::Add,
667 ),
668 staged_enemy(
669 EnemyIdx(4),
670 "later_pull",
671 GroupId(3),
672 0.0,
673 EnemyRole::Normal,
674 ),
675 ],
676 groups: vec![
677 EnemyGroupDefinition {
678 id: GroupId(0),
679 slug: "initial_group".to_string(),
680 tags: Vec::new(),
681 required: true,
682 wave_id: WaveId(0),
683 enemy_ids: vec![EnemyIdx(0), EnemyIdx(1)],
684 },
685 EnemyGroupDefinition {
686 id: GroupId(1),
687 slug: "delayed_group".to_string(),
688 tags: Vec::new(),
689 required: true,
690 wave_id: WaveId(1),
691 enemy_ids: vec![EnemyIdx(2)],
692 },
693 EnemyGroupDefinition {
694 id: GroupId(2),
695 slug: "dependency_group".to_string(),
696 tags: Vec::new(),
697 required: true,
698 wave_id: WaveId(2),
699 enemy_ids: vec![EnemyIdx(3)],
700 },
701 EnemyGroupDefinition {
702 id: GroupId(3),
703 slug: "later_pull_group".to_string(),
704 tags: Vec::new(),
705 required: true,
706 wave_id: WaveId(3),
707 enemy_ids: vec![EnemyIdx(4)],
708 },
709 ],
710 waves: vec![
711 WaveDefinition {
712 id: WaveId(0),
713 pull_id: PullId(0),
714 minimum_activation_s: 0.0,
715 depends_on_groups: Vec::new(),
716 group_ids: vec![GroupId(0)],
717 },
718 WaveDefinition {
719 id: WaveId(1),
720 pull_id: PullId(0),
721 minimum_activation_s: 5.0,
722 depends_on_groups: Vec::new(),
723 group_ids: vec![GroupId(1)],
724 },
725 WaveDefinition {
726 id: WaveId(2),
727 pull_id: PullId(0),
728 minimum_activation_s: 0.0,
729 depends_on_groups: vec![GroupId(0)],
730 group_ids: vec![GroupId(2)],
731 },
732 WaveDefinition {
733 id: WaveId(3),
734 pull_id: PullId(1),
735 minimum_activation_s: 0.0,
736 depends_on_groups: Vec::new(),
737 group_ids: vec![GroupId(3)],
738 },
739 ],
740 pulls: vec![
741 PullDefinition {
742 id: PullId(0),
743 preferred_target: EnemyIdx(0),
744 player_start_transform: None,
745 wave_ids: vec![WaveId(0), WaveId(1), WaveId(2)],
746 events: Vec::new(),
747 reset_after_completion: PlayerResetFlags::empty(),
748 },
749 PullDefinition {
750 id: PullId(1),
751 preferred_target: EnemyIdx(4),
752 player_start_transform: None,
753 wave_ids: vec![WaveId(3)],
754 events: Vec::new(),
755 reset_after_completion: PlayerResetFlags::empty(),
756 },
757 ],
758 termination: TerminationPolicy::FixedDuration,
759 fixed_duration_s: Some(TEST_DURATION_S),
760 blocked_los_pairs: Vec::new(),
761 spatial_scene: StaticSpatialScene {
762 layers: vec![SpatialLayerDefinition {
763 id: SpatialLayerId(0),
764 slug: "ground".to_string(),
765 }],
766 obstacles: Vec::new(),
767 },
768 };
769
770 definition
771 .validate()
772 .expect("staged activation authored encounter is valid");
773
774 resolve_definition(definition)
775}
776
777fn resolve_definition(definition: EncounterDefinition) -> ResolvedEncounter {
778 let mut enemies = Vec::with_capacity(definition.enemies.len());
779
780 for authored in &definition.enemies {
781 let display_name = match &authored.identity {
782 EnemyIdentityInput::Anonymous { display_name } => display_name.clone(),
784 EnemyIdentityInput::Npc { .. } => unreachable!("test encounter is anonymous"),
785 };
786 let group = definition
787 .groups
788 .get(authored.group_id.0 as usize)
789 .expect("validated authored group exists");
790 let max_health = match authored.health {
791 EnemyHealthInput::Fixed { max_health } => max_health,
792 EnemyHealthInput::ScriptedLinear {
793 display_max_health, ..
794 } => display_max_health,
795 EnemyHealthInput::Auto => unreachable!("test encounter health is explicit"),
796 };
797 let identity =
798 ResolvedEnemyIdentityMetadata::new(authored.id, display_name, None, None, None)
799 .expect("resolved test identity is valid");
800 let stats = ResolvedEnemyCombatStats::new(
801 authored.id,
802 max_health,
803 authored.armor_override.unwrap_or(TEST_ARMOR),
804 f64::from(f32::from_bits(TEST_ARMOR_CONSTANT_BITS)),
805 None,
806 None,
807 )
808 .expect("resolved test stats are valid");
809
810 enemies.push(
811 ResolvedEnemyEncounter::new(authored, &group.tags, authored.level, identity, stats)
812 .expect("resolved test enemy corresponds to authored enemy"),
813 );
814 }
815
816 ResolvedEncounter::new(definition, enemies).expect("resolved test encounter is valid")
817}
818
819#[cfg(test)]
820mod helper_tests {
821 use googletest::prelude::*;
822
823 use super::*;
824
825 #[gtest]
826 fn minimal_rotation_builds() -> Result<()> {
827 let rotation = minimal_rotation();
828 let handler = handler_with_spells(&rotation, &[]);
829
830 verify_true!(handler.is_ok())
831 }
832
833 #[gtest]
834 fn casting_rotation_preserves_spell_order() -> Result<()> {
835 let rotation = rotation_casting(&["one", "two"]);
836
837 verify_that!(
838 rotation.actions,
839 elements_are![
840 matches_pattern!(RotationAction::Cast {
841 spell: eq("one"),
842 ..
843 }),
844 matches_pattern!(RotationAction::Cast {
845 spell: eq("two"),
846 ..
847 })
848 ]
849 )
850 }
851
852 #[gtest]
853 fn game_data_seed_round_trips() -> Result<()> {
854 let data = game_data_with(17, SpellProps::default(), &[(1, 23.0)], &[(1, 2.0)]);
855
856 verify_that!(
857 data.base_points(SpellIdx::from_raw(17), 1),
858 near(23.0, 1e-9)
859 )?;
860
861 verify_that!(data.sp_coef(SpellIdx::from_raw(17), 1), near(2.0, 1e-9))
862 }
863
864 #[gtest]
865 fn builder_fixture_uses_supplied_stats() -> Result<()> {
866 let built = combat_builder_fixture(CombatStats::default())
867 .build(minimal_rotation())
868 .or_fail()?;
869
870 verify_true!(built.names.is_empty())
871 }
872}