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