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::{CombatHandler, CombatSystemBuilder};
26
27/// Finalize a synthetic fixture after declaring every raw damage identity it executes.
28///
29/// The declarations still pass through the production finalizer.
30/// They become opaque, program-bound actions. This helper is unavailable without test support.
31///
32/// # Errors
33///
34/// Returns an error when the synthetic draft cannot be finalized.
35pub 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
47/// Add one concrete absorb effect so a synthetic fixture receives finalized storage.
48///
49/// # Errors
50///
51/// Returns an error when the aura draft cannot accept the absorb effect.
52pub 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/// Construct an explicitly cold, instrumented raw damage token for synthetic fixtures.
71///
72/// Production content must use generated, program-branded damage identities.
73#[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
80/// Executes the raw cast pipeline for fixture construction.
81///
82/// This capability intentionally lives under `test_support`.
83/// Production callers schedule sealed local spell bindings through the combat handler.
84pub 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
88/// Construct a fixture-only local spell coordinate.
89pub const fn local_spell(index: u8) -> crate::LocalSpellIdx {
90    crate::LocalSpellIdx::new(index)
91}
92
93/// Construct a fixture-only local aura coordinate.
94pub const fn local_aura(index: u8) -> crate::LocalAuraIdx {
95    crate::LocalAuraIdx::new(index)
96}
97
98/// Small valid rotation used by tests that only need a compiled combat system.
99#[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/// Parse a JSON rotation fixture through the canonical domain parser.
113///
114/// # Panics
115///
116/// Panics when the fixture is not a structurally valid rotation.
117#[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/// Build a minimal rotation that casts the supplied spell names in order.
124#[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
144/// Build a handler with default spell definitions for a compact spell table.
145///
146/// # Errors
147///
148/// Returns [`EngineError`](wowlab_engine_ports::EngineError) when the supplied rotation or spell table cannot be built.
149pub 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/// Seed the game-data fields most damage and mastery tests need.
167#[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            // #t(rust_clone_in_loop) each resolved actor owns its immutable display name
857            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}