Skip to main content

wowlab_engine_combat/
test_support.rs

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/// Small valid rotation used by tests that only need a compiled combat system.
28#[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/// Parse a JSON rotation fixture through the canonical domain parser.
42///
43/// # Panics
44///
45/// Panics when the fixture is not a structurally valid rotation.
46#[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/// Build a minimal rotation that casts the supplied spell names in order.
53#[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
73/// Build a handler with default spell definitions for a compact spell table.
74///
75/// # Errors
76///
77/// Returns [`CombatBuildError`] when the supplied rotation or spell table cannot be built.
78pub 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/// Seed the game-data fields most damage and mastery tests need.
93#[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            // #t(rust_clone_in_loop) each resolved actor owns its immutable display name
783            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}