Skip to main content

forge/
wowlab.rs

1// #t(file: rust_alloc_in_loop) CLI binary, allocations are fine for readability.
2// #t(file: rust_inline_test_module_size) private profile-to-intent tests exercise resolver-backed conversion helpers beside the implementation
3
4#![expect(
5    clippy::cast_possible_truncation,
6    clippy::cast_possible_wrap,
7    clippy::cast_precision_loss,
8    reason = "protobuf telemetry counters and registered IDs are bounded by engine contracts and converted to comparison display fields"
9)]
10
11//! `WowlabProvider`: runs the engine in-process via `simulate_intent`.
12
13use std::collections::BTreeMap;
14
15use anyhow::{Context, Result, ensure};
16#[cfg(test)]
17use googletest::{Result as GtestResult, prelude::*};
18use wowlab_common::{output, sim::intent::serialize_sim_config};
19use wowlab_engine_adapter_data::{LocalCsvResolver, OverlayResolver};
20use wowlab_engine_application::simulate_intent;
21use wowlab_engine_domain::rotation::to_simc_key;
22use wowlab_engine_ports::{
23    DataResolver, DynDataResolver, NoopProgress, PermanentEnchantQuery, SpellId, content_catalog,
24};
25use wowlab_fs::file;
26use wowlab_parsers::{Item, Profile, parse_simc};
27use wowlab_types::{
28    constants::MS_PER_SECOND,
29    game::{GearEntry, GearSlot, SpecId},
30    sim::FastMap,
31};
32
33use crate::{
34    constants::{DEFAULT_SEED, DPS_SCALE, default_data_dir, engine_dir},
35    encounter_fixture::EncounterFixture,
36    provider::{CastEntry, ComparisonConfig, SimOutput, SimProvider, SpellResult},
37    sim,
38};
39
40const MARKER_KIND_CAST: i32 = 2;
41const TRINKET_ONE_ACTION_SLOT: u8 = 1;
42const TRINKET_TWO_ACTION_SLOT: u8 = 2;
43const AUTO_SHOT_SPELL_ID: u32 = 75;
44const COBRA_SHOT_SPELL_ID: u32 = 193_455;
45const CAT_MELEE_SPELL_ID: u32 = 6_603;
46const FERAL_MOONFIRE_SPELL_ID: u32 = 155_625;
47
48fn canonical_action_name(spec: SpecId, spell_id: u32, declared_name: &str) -> String {
49    match (spec, spell_id) {
50        (SpecId::Feral, CAT_MELEE_SPELL_ID) => "cat_melee".to_string(),
51        (SpecId::Feral, FERAL_MOONFIRE_SPELL_ID) => "lunar_inspiration".to_string(),
52        (_, AUTO_SHOT_SPELL_ID) => "auto_shot".to_string(),
53        (_, COBRA_SHOT_SPELL_ID) => "cobra_shot".to_string(),
54        _ => declared_name.to_string(),
55    }
56}
57
58#[derive(Debug)]
59pub(crate) struct WowlabProvider {
60    encounter_fixture: Option<EncounterFixture>,
61}
62
63impl WowlabProvider {
64    #[must_use]
65    pub(crate) const fn new(encounter_fixture: Option<EncounterFixture>) -> Self {
66        Self { encounter_fixture }
67    }
68}
69
70impl SimProvider for WowlabProvider {
71    fn name(&self) -> &'static str {
72        "wowlab"
73    }
74
75    fn run(&self, config: &ComparisonConfig) -> Result<SimOutput> {
76        run_engine(config, self.encounter_fixture)
77    }
78}
79
80pub(crate) fn build_spell_name_map(spec: SpecId) -> Result<FastMap<u32, String>> {
81    let catalog = content_catalog().context("engine content catalog is unavailable")?;
82    let introspection = wowlab_engine_application::introspect_spec(catalog, spec)
83        .context("failed to introspect spec")?;
84    let capacity =
85        introspection.spells.len() + introspection.auras.len() + introspection.auto_attacks.len();
86    let mut map = FastMap::default();
87
88    map.reserve(capacity);
89
90    for spell in &introspection.spells {
91        map.insert(
92            spell.spell_id,
93            canonical_action_name(spec, spell.spell_id, &spell.name),
94        );
95    }
96
97    for aura in &introspection.auras {
98        map.insert(
99            aura.aura_id,
100            canonical_action_name(spec, aura.aura_id, &aura.name),
101        );
102    }
103
104    for aa in &introspection.auto_attacks {
105        let name = if aa.is_pet {
106            "pet_attack"
107        } else {
108            "auto_attack"
109        };
110
111        map.insert(aa.spell_id, canonical_action_name(spec, aa.spell_id, name));
112    }
113
114    // Hero-tree spells deal damage without being castable; their const names lowercase into SimC's action naming.
115
116    if let Ok(descriptor) = content_catalog().and_then(|catalog| catalog.descriptor(spec)) {
117        for tree in descriptor.metadata.hero_talent_trees {
118            for &(name, id) in tree.spells {
119                map.entry(id).or_insert_with(|| name.to_lowercase());
120            }
121
122            for &(name, id) in tree.auras {
123                map.entry(id).or_insert_with(|| name.to_lowercase());
124            }
125        }
126
127        for &(name, id) in descriptor.metadata.reported_spells {
128            map.insert(id, name.to_string());
129        }
130    }
131
132    Ok(map)
133}
134
135pub(crate) async fn enrich_spell_name_map(
136    map: &mut FastMap<u32, String>,
137    action_ids: impl IntoIterator<Item = u32>,
138    resolver: &DynDataResolver<'_>,
139) {
140    for spell_id in action_ids {
141        if map.contains_key(&spell_id) {
142            continue;
143        }
144
145        let Ok(spell) = resolver.get_spell(SpellId::new(spell_id as i32)).await else {
146            continue;
147        };
148
149        map.insert(spell_id, to_simc_key(spell.name.as_str()));
150    }
151}
152
153fn simc_slot_slug(slot: GearSlot) -> Option<&'static str> {
154    match slot {
155        GearSlot::Shirt | GearSlot::Tabard => None,
156        other => Some(other.slug()),
157    }
158}
159
160struct ProfileIntentData {
161    loadout: Option<String>,
162    expansion_talents: BTreeMap<String, Vec<String>>,
163    gear: Vec<GearEntry>,
164    race: String,
165    player_level: u16,
166    player_expansion_id: u32,
167    settings: BTreeMap<String, toml::Value>,
168}
169
170fn consumable_settings(extra: &FastMap<String, String>) -> BTreeMap<String, toml::Value> {
171    let mut settings = BTreeMap::new();
172
173    if let Some(potion) = extra.get("potion") {
174        settings.insert("potion".to_string(), toml::Value::String(potion.clone()));
175        settings.insert("pre_pot".to_string(), toml::Value::Boolean(true));
176    }
177
178    if let Some(flask) = extra.get("flask") {
179        settings.insert("flask".to_string(), toml::Value::String(flask.clone()));
180    }
181
182    if let Some(food) = extra.get("food") {
183        settings.insert("food".to_string(), toml::Value::String(food.clone()));
184    }
185
186    if let Some(augment) = extra.get("augmentation") {
187        settings.insert(
188            "augment_rune".to_string(),
189            toml::Value::String(augment.clone()),
190        );
191    }
192
193    settings
194}
195
196fn permanent_enchant_name_and_rank(enchant: &str) -> (&str, i32) {
197    enchant
198        .rsplit_once('_')
199        .and_then(|(name, rank)| rank.parse().ok().map(|rank| (name, rank)))
200        .unwrap_or((enchant, 0))
201}
202
203async fn resolve_named_enchant(
204    enchant: &str,
205    item_id: u32,
206    resolver: &DynDataResolver<'_>,
207) -> Result<Option<u32>> {
208    if enchant == "disabled" {
209        return Ok(None);
210    }
211
212    let dbc_item_id = i32::try_from(item_id).context("equipped item ID does not fit i32")?;
213    let item = resolver
214        .get_item(dbc_item_id)
215        .await
216        .with_context(|| format!("failed to resolve equipped item {item_id} for `{enchant}`"))?;
217    let (name, rank) = permanent_enchant_name_and_rank(enchant);
218    let query = PermanentEnchantQuery {
219        tokenized_name: name.to_string(),
220        rank,
221        item_class: item.class_id,
222        inventory_type: item.inventory_type,
223        item_subclass: item.subclass_id,
224    };
225    let entry = resolver
226        .find_permanent_enchant(&query)
227        .await
228        .with_context(|| format!("failed to resolve permanent enchant `{enchant}`"))?
229        .with_context(|| {
230            format!(
231                "named SimC enchant `{enchant}` does not apply to item {item_id} \
232                 (class {}, subclass {}, inventory type {})",
233                item.class_id, item.subclass_id, item.inventory_type
234            )
235        })?;
236
237    u32::try_from(entry.enchant_id)
238        .context("resolved enchantment ID does not fit u32")
239        .map(Some)
240}
241
242async fn profile_gear(
243    equipment: Vec<Item>,
244    resolver: &DynDataResolver<'_>,
245) -> Result<Vec<GearEntry>> {
246    let mut gear = Vec::with_capacity(equipment.len());
247
248    for item in equipment {
249        if simc_slot_slug(item.gear.slot).is_none() {
250            continue;
251        }
252
253        let enchant_id = if let Some(enchant_id) = item.gear.enchant_id {
254            Some(enchant_id)
255        } else if let Some(enchant) = item.enchant.as_deref() {
256            resolve_named_enchant(enchant, item.gear.id, resolver).await?
257        } else {
258            None
259        };
260
261        gear.push(GearEntry {
262            slot: item.gear.slot,
263            id: item.gear.id,
264            bonus_ids: item.gear.bonus_ids,
265            enchant_id,
266            gem_ids: item.gear.gem_ids,
267            crafted_stats: item.gear.crafted_stats,
268            crafting_quality: item.gear.crafting_quality,
269            drop_level: item.gear.drop_level,
270            ilevel: item.gear.ilevel,
271        });
272    }
273
274    Ok(gear)
275}
276
277async fn profile_intent_data(
278    profile: Profile,
279    resolver: &DynDataResolver<'_>,
280) -> Result<ProfileIntentData> {
281    let loadout = (!profile.talents.encoded.is_empty()).then_some(profile.talents.encoded);
282    let gear = profile_gear(profile.equipment, resolver).await?;
283    let player_level = u16::try_from(profile.character.level)
284        .context("SimC profile player level does not fit u16")?;
285
286    Ok(ProfileIntentData {
287        loadout,
288        expansion_talents: profile.expansion_talents,
289        gear,
290        race: profile.character.race,
291        player_level,
292        player_expansion_id: crate::intent::PLAYER_EXPANSION_ID,
293        settings: consumable_settings(&profile.extra),
294    })
295}
296
297async fn simc_profile_intent_data(
298    config: &ComparisonConfig,
299    resolver: &DynDataResolver<'_>,
300) -> Result<ProfileIntentData> {
301    let profile_path = crate::simc::simc_profile_path(config.spec)
302        .ok_or_else(|| anyhow::anyhow!("no SimC profile for {}", config.spec.slug()))?;
303    let profile_text = file::read_text(&profile_path)
304        .with_context(|| format!("failed to read SimC profile {}", profile_path.display()))?;
305    let profile = parse_simc(&profile_text).context("failed to parse SimC profile")?;
306
307    profile_intent_data(profile, resolver).await
308}
309
310fn rotation_uses_trinket_slot(value: &serde_json::Value, slot: u8) -> bool {
311    let mut pending = vec![value];
312
313    while let Some(value) = pending.pop() {
314        match value {
315            serde_json::Value::Array(entries) => pending.extend(entries),
316            serde_json::Value::Object(object) => {
317                let is_match = object.get("type").and_then(serde_json::Value::as_str)
318                    == Some("use_trinket")
319                    && object.get("slot").and_then(serde_json::Value::as_u64)
320                        == Some(u64::from(slot));
321
322                if is_match {
323                    return true;
324                }
325
326                pending.extend(object.values());
327            }
328            _ => {}
329        }
330    }
331
332    false
333}
334
335fn add_registered_item_uses(
336    rotation: &mut serde_json::Value,
337    equipment: impl IntoIterator<Item = (GearSlot, u32)>,
338) -> Result<()> {
339    let mut slots = equipment
340        .into_iter()
341        .filter_map(|(slot, item_id)| {
342            let slot_number = match slot {
343                GearSlot::Trinket1 => TRINKET_ONE_ACTION_SLOT,
344                GearSlot::Trinket2 => TRINKET_TWO_ACTION_SLOT,
345                _ => return None,
346            };
347
348            wowlab_engine_content::registered_item_use_spell(item_id).map(|_| (slot, slot_number))
349        })
350        .filter(|&(_, slot_number)| !rotation_uses_trinket_slot(rotation, slot_number))
351        .collect::<Vec<_>>();
352
353    slots.sort_unstable_by_key(|&(_, slot_number)| slot_number);
354    let actions = rotation
355        .get_mut("actions")
356        .and_then(serde_json::Value::as_array_mut)
357        .context("assisted rotation has no top-level actions array")?;
358
359    for (slot, slot_number) in slots.into_iter().rev() {
360        let cooldown_key = slot
361            .use_alias()
362            .context("registered on-use item is not in an actionable gear slot")?;
363
364        actions.insert(
365            0,
366            serde_json::json!({
367                "type": "use_trinket",
368                "slot": slot_number,
369                "condition": {
370                    "type": "read",
371                    "domain": "cooldown",
372                    "key": cooldown_key,
373                    "name": "is_ready"
374                }
375            }),
376        );
377    }
378
379    Ok(())
380}
381
382/// Full sim-intent TOML for `config`'s spec: the MID1 `SimC` profile's loadout and gear, plus fight settings.
383pub(crate) async fn wowlab_profile_config(
384    config: &ComparisonConfig,
385    rotation_id: &str,
386    resolver: &DynDataResolver<'_>,
387) -> Result<String> {
388    wowlab_profile_config_with_fixture(config, rotation_id, None, resolver).await
389}
390
391/// Full profile intent with an optional canonical Phase 8 encounter fixture.
392pub(crate) async fn wowlab_profile_config_with_fixture(
393    config: &ComparisonConfig,
394    rotation_id: &str,
395    encounter_fixture: Option<EncounterFixture>,
396    resolver: &DynDataResolver<'_>,
397) -> Result<String> {
398    let profile = simc_profile_intent_data(config, resolver).await?;
399
400    serialize_profile_config(config, rotation_id, encounter_fixture, profile)
401}
402
403fn serialize_profile_config(
404    config: &ComparisonConfig,
405    rotation_id: &str,
406    encounter_fixture: Option<EncounterFixture>,
407    profile: ProfileIntentData,
408) -> Result<String> {
409    ensure!(
410        profile.player_level > 0,
411        "SimC profile player level must be positive"
412    );
413    let race = config.race.as_deref().unwrap_or(&profile.race);
414    let mut intent = crate::intent::canonical_patchwerk_intent(
415        config.spec,
416        rotation_id,
417        f64::from(config.parameters.fight_duration_secs()),
418    )
419    .map_err(|message| anyhow::anyhow!("failed to build comparison intent: {message}"))?;
420
421    intent.loadout = profile.loadout;
422    intent.expansion_talents = profile.expansion_talents;
423    intent.gear = profile.gear;
424    intent.player_level = profile.player_level;
425    intent.player_expansion_id = profile.player_expansion_id;
426    intent.settings = profile.settings;
427
428    if let Some(fixture) = encounter_fixture {
429        intent.encounter = fixture.definition()?;
430    }
431
432    intent
433        .settings
434        .insert("race".to_string(), toml::Value::String(race.to_string()));
435
436    if !config.bugs {
437        intent
438            .settings
439            .insert("bugs".to_string(), toml::Value::Boolean(false));
440    }
441
442    serialize_sim_config(&intent).context("failed to serialize comparison intent")
443}
444
445// #t(rust_cyclomatic_complexity) sequential decode/resolve/timeline steps.
446fn run_engine(
447    config: &ComparisonConfig,
448    encounter_fixture: Option<EncounterFixture>,
449) -> Result<SimOutput> {
450    let slug = config.spec.slug();
451    let rotation_id = format!("{slug}_assisted");
452    let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
453    let rotation_path = engine_dir()
454        .join("examples/rotations")
455        .join(format!("{rotation_id}.json"));
456    let rotation_script = file::read_text(&rotation_path)
457        .with_context(|| format!("failed to read rotation {}", rotation_path.display()))?;
458    let rotation_value: serde_json::Value = serde_json::from_str(&rotation_script)
459        .with_context(|| format!("invalid rotation JSON {}", rotation_path.display()))?;
460    let mut rotation_value = wowlab_parsers::apply_rotation_overlay(slug, &rotation_value)?;
461    let profile_path = crate::simc::simc_profile_path(config.spec)
462        .ok_or_else(|| anyhow::anyhow!("no SimC profile for {}", config.spec.slug()))?;
463    let profile_text = file::read_text(&profile_path)
464        .with_context(|| format!("failed to read SimC profile {}", profile_path.display()))?;
465    let profile = parse_simc(&profile_text).context("failed to parse SimC profile")?;
466
467    add_registered_item_uses(
468        &mut rotation_value,
469        profile
470            .equipment
471            .iter()
472            .map(|item| (item.gear.slot, item.gear.id)),
473    )?;
474    let rotation_script = serde_json::to_string(&rotation_value)?;
475
476    let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir.as_str()))
477        .with_rotation_script(&rotation_id, rotation_script);
478    let resolver_dyn = DynDataResolver::from_ref(&resolver);
479    let catalog = content_catalog().context("engine content catalog is unavailable")?;
480    let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
481    let sim_config = rt.block_on(wowlab_profile_config_with_fixture(
482        config,
483        &rotation_id,
484        encounter_fixture,
485        resolver_dyn,
486    ))?;
487    let chunk = sim::make_chunk("compare", config.parameters.iterations(), None);
488
489    output::detail(&format!(
490        "Running wowlab ({} iter, {}s)...",
491        config.parameters.iterations(),
492        config.parameters.fight_duration_secs(),
493    ));
494
495    let report = rt
496        .block_on(simulate_intent(
497            catalog,
498            &sim_config,
499            &chunk,
500            DEFAULT_SEED,
501            resolver_dyn,
502            &NoopProgress,
503        ))
504        .with_context(|| format!("simulation failed for {slug}"))?;
505
506    let telemetry = sim::decode_telemetry(&report)?;
507
508    trace_telemetry_summary(&telemetry);
509
510    let mut name_map = build_spell_name_map(config.spec)?;
511
512    rt.block_on(enrich_spell_name_map(
513        &mut name_map,
514        telemetry.actions.iter().map(|action| action.spell_id),
515        resolver_dyn,
516    ));
517    let n = telemetry.iterations;
518    let fight_time_s = telemetry.total_fight_time_ms as f64 / MS_PER_SECOND / f64::from(n);
519    let total_dps = sim::mean_dps(&telemetry);
520
521    let spells = collect_spell_results(&telemetry, &name_map, fight_time_s, total_dps);
522
523    let mut timeline = Vec::new();
524
525    if let Some(rep) = &telemetry.representative {
526        let mut abs_ms: u32 = 0;
527
528        for m in &rep.markers {
529            abs_ms = abs_ms.saturating_add(m.delta_time_ms);
530
531            if m.kind == MARKER_KIND_CAST {
532                let spell_name = name_map
533                    .get(&m.spell_or_aura_id)
534                    .cloned()
535                    .unwrap_or_else(|| format!("unk#{}", m.spell_or_aura_id));
536
537                timeline.push(CastEntry {
538                    time_secs: f64::from(abs_ms) / MS_PER_SECOND,
539                    spell_name,
540                });
541            }
542        }
543    }
544
545    let mut output = SimOutput::new(total_dps, spells, timeline);
546
547    for row in &telemetry.resources {
548        for source in &row.by_source {
549            let name = match source.source_spell_id {
550                wowlab_engine_telemetry::RESOURCE_SOURCE_UNATTRIBUTED => "unattributed".to_string(),
551                wowlab_engine_telemetry::RESOURCE_SOURCE_AUTO_ATTACK => "auto_attack".to_string(),
552                wowlab_engine_telemetry::RESOURCE_SOURCE_PASSIVE_REGEN => {
553                    "passive_regen".to_string()
554                }
555                id => name_map
556                    .get(&id)
557                    .cloned()
558                    .unwrap_or_else(|| format!("unk#{id}")),
559            };
560            let entry = output.resource_gains.entry(name).or_default();
561            let iterations = f64::from(n.max(1));
562
563            entry.gained += source.gained_x100 as f64
564                / wowlab_types::constants::PROTO_RESOURCE_SCALE
565                / iterations;
566            entry.wasted += source.wasted_x100 as f64
567                / wowlab_types::constants::PROTO_RESOURCE_SCALE
568                / iterations;
569        }
570    }
571
572    if let Some(fixture) = encounter_fixture {
573        let encounter_debug = rt.block_on(crate::encounter_debug::build_report(
574            fixture,
575            &telemetry,
576            resolver_dyn,
577        ))?;
578
579        Ok(output.with_encounter_debug(encounter_debug))
580    } else {
581        Ok(output)
582    }
583}
584
585fn trace_telemetry_summary(telemetry: &wowlab_types::proto::ChunkTelemetry) {
586    tracing::trace!(actions = ?telemetry.actions, "action telemetry summaries");
587    tracing::trace!(auras = ?telemetry.auras, "aura telemetry summaries");
588    tracing::trace!(cooldowns = ?telemetry.cooldowns, "cooldown telemetry summaries");
589}
590
591/// Per-spell rows from action telemetry, keyed by resolved spell name.
592// #t(fn: rust_alloc_in_loop) CLI reporting builds one owned row per action; readability wins here
593fn collect_spell_results(
594    telemetry: &wowlab_types::proto::ChunkTelemetry,
595    name_map: &FastMap<u32, String>,
596    fight_time_s: f64,
597    total_dps: f64,
598) -> Vec<(String, SpellResult)> {
599    let n = telemetry.iterations;
600    let mut spells = Vec::with_capacity(telemetry.actions.len());
601
602    for a in &telemetry.actions {
603        let dmg = a.total_damage_x10 as f64 / DPS_SCALE;
604        let dps = if fight_time_s > 0.0 && n > 0 {
605            dmg / fight_time_s / f64::from(n)
606        } else {
607            0.0
608        };
609        let per_iteration = |total: u64| {
610            if n > 0 {
611                (total / u64::from(n)) as u32
612            } else {
613                0
614            }
615        };
616        let avg_casts = per_iteration(a.casts);
617        // Guardian, pet, and proc rows carry no casts; their damage still lands as hits and ticks.
618        let avg_hits = per_iteration(a.direct_hits.saturating_add(a.ticks));
619
620        if dps <= 0.0 && avg_casts == 0 {
621            continue;
622        }
623
624        let pct = if total_dps > 0.0 {
625            dps / total_dps
626        } else {
627            0.0
628        };
629        let name = name_map
630            .get(&a.spell_id)
631            .cloned()
632            .unwrap_or_else(|| format!("unk#{}", a.spell_id));
633
634        spells.push((
635            name,
636            SpellResult {
637                dps,
638                casts: avg_casts,
639                hits: avg_hits,
640                pct,
641            },
642        ));
643    }
644
645    spells
646}
647
648#[cfg(test)]
649mod tests {
650    use wowlab_common::sim::intent::{SimConfigIntent, parse_sim_config};
651    use wowlab_engine_adapter_data::InMemoryResolver;
652    use wowlab_types::{
653        data::{ItemDataFlat, PermanentEnchantFlat},
654        sim::{DifficultyContext, EnemyDefinition},
655    };
656
657    use super::*;
658    use crate::run::{FightDurationSeconds, IterationCount, RunParameters};
659
660    const FERAL_TEST_PROFILE: &str = "druid=\"Test\"\n\
661        spec=feral\n\
662        level=90\n\
663        race=night_elf\n";
664
665    fn test_parameters() -> RunParameters {
666        RunParameters::new(
667            IterationCount::ONE,
668            FightDurationSeconds::from_nonzero_const(30),
669        )
670    }
671
672    fn test_runtime() -> Result<tokio::runtime::Runtime> {
673        tokio::runtime::Runtime::new().context("failed to create test runtime")
674    }
675
676    async fn profile_config_from_simc(
677        config: &ComparisonConfig,
678        rotation_id: &str,
679        encounter_fixture: Option<EncounterFixture>,
680        profile_text: &str,
681        resolver: &DynDataResolver<'_>,
682    ) -> Result<String> {
683        let profile = parse_simc(profile_text).context("failed to parse test SimC profile")?;
684        let profile = profile_intent_data(profile, resolver).await?;
685
686        serialize_profile_config(config, rotation_id, encounter_fixture, profile)
687    }
688
689    #[gtest]
690    fn canonical_action_names_use_dbc_spell_identity_for_hunter_actions() -> GtestResult<()> {
691        verify_that!(
692            canonical_action_name(SpecId::BeastMastery, AUTO_SHOT_SPELL_ID, "auto_attack"),
693            eq("auto_shot")
694        )?;
695        verify_that!(
696            canonical_action_name(SpecId::BeastMastery, COBRA_SHOT_SPELL_ID, "arcane_shot"),
697            eq("cobra_shot")
698        )?;
699
700        verify_that!(
701            canonical_action_name(SpecId::BeastMastery, 1, "declared_name"),
702            eq("declared_name")
703        )
704    }
705
706    #[gtest]
707    fn canonical_action_names_match_feral_simc_attribution() -> GtestResult<()> {
708        verify_that!(
709            canonical_action_name(SpecId::Feral, CAT_MELEE_SPELL_ID, "auto_attack"),
710            eq("cat_melee")
711        )?;
712        verify_that!(
713            canonical_action_name(SpecId::Feral, FERAL_MOONFIRE_SPELL_ID, "moonfire"),
714            eq("lunar_inspiration")
715        )?;
716
717        verify_that!(
718            canonical_action_name(SpecId::Guardian, CAT_MELEE_SPELL_ID, "auto_attack"),
719            eq("auto_attack")
720        )
721    }
722
723    #[gtest]
724    fn feral_aura_names_do_not_overwrite_canonical_action_attribution() -> GtestResult<()> {
725        let names =
726            build_spell_name_map(SpecId::Feral).expect("Feral content introspection should build");
727
728        verify_that!(
729            names.get(&FERAL_MOONFIRE_SPELL_ID),
730            some(eq("lunar_inspiration"))
731        )?;
732
733        verify_that!(names.get(&CAT_MELEE_SPELL_ID), some(eq("cat_melee")))
734    }
735
736    #[gtest]
737    fn assisted_rotation_adds_each_registered_item_use_once() -> GtestResult<()> {
738        let mut rotation = serde_json::json!({
739            "actions": [{"type": "call", "list": "main"}],
740            "lists": {"main": []}
741        });
742        let equipment = [(GearSlot::Trinket1, 1), (GearSlot::Trinket2, 249_346)];
743
744        add_registered_item_uses(&mut rotation, equipment).or_fail()?;
745        add_registered_item_uses(&mut rotation, equipment).or_fail()?;
746
747        let actions = rotation["actions"].as_array().or_fail()?;
748
749        verify_that!(actions, len(eq(2)))?;
750        verify_that!(actions[0]["type"].as_str(), some(eq("use_trinket")))?;
751        verify_that!(actions[0]["slot"].as_u64(), some(eq(2)))?;
752
753        verify_that!(
754            actions[0]["condition"]["key"].as_str(),
755            some(eq("trinket_2_use"))
756        )
757    }
758
759    #[gtest]
760    fn wowlab_profile_config_uses_canonical_target_for_level_90_profile() -> GtestResult<()> {
761        let config = ComparisonConfig {
762            spec: SpecId::Feral,
763            parameters: test_parameters(),
764            race: None,
765            bugs: true,
766            simc_debug: false,
767        };
768        let resolver = InMemoryResolver::new();
769        let resolver_dyn = DynDataResolver::from_ref(&resolver);
770        let runtime = test_runtime().or_fail()?;
771        let profile = parse_simc(FERAL_TEST_PROFILE).or_fail()?;
772        let profile = runtime
773            .block_on(profile_intent_data(profile, resolver_dyn))
774            .or_fail()?;
775
776        verify_that!(profile.player_level, eq(90))?;
777        verify_that!(profile.player_expansion_id, eq(11))?;
778
779        let serialized = runtime
780            .block_on(profile_config_from_simc(
781                &config,
782                "feral_druid_assisted",
783                None,
784                FERAL_TEST_PROFILE,
785                resolver_dyn,
786            ))
787            .or_fail()?;
788        let parsed = parse_sim_config(&serialized).or_fail()?;
789
790        verify_that!(parsed.player_level, eq(90))?;
791        verify_that!(
792            parsed.player_expansion_id,
793            eq(crate::intent::PLAYER_EXPANSION_ID)
794        )?;
795        verify_that!(
796            parsed.encounter.enemies.as_slice(),
797            elements_are![matches_pattern!(EnemyDefinition {
798                level: eq(&crate::intent::CANONICAL_PATCHWERK_ENEMY_LEVEL),
799                difficulty: eq(&DifficultyContext::Generic {
800                    expansion_id: crate::intent::CANONICAL_PATCHWERK_EXPANSION_ID,
801                }),
802                ..
803            })]
804        )?;
805
806        for key in ["duration_s", "enemy_count", "expansion_id", "level"] {
807            verify_true!(!parsed.settings.contains_key(key))?;
808        }
809
810        Ok(())
811    }
812
813    #[gtest]
814    fn numeric_simc_expansion_talents_reach_the_engine_intent() -> GtestResult<()> {
815        let config = ComparisonConfig {
816            spec: SpecId::Fire,
817            parameters: test_parameters(),
818            race: None,
819            bugs: true,
820            simc_debug: false,
821        };
822        let resolver = InMemoryResolver::new();
823        let resolver_dyn = DynDataResolver::from_ref(&resolver);
824        let serialized = test_runtime()
825            .or_fail()?
826            .block_on(profile_config_from_simc(
827                &config,
828                "fire_mage_assisted",
829                None,
830                "mage=\"Test\"\n\
831                 spec=fire\n\
832                 level=90\n\
833                 race=orc\n\
834                 omnium_talents=136822:1/136816:1/136817:1/136820:1/136814:1\n",
835                resolver_dyn,
836            ))
837            .or_fail()?;
838        let parsed = parse_sim_config(&serialized).or_fail()?;
839
840        verify_that!(
841            parsed.expansion_talents.get("omnium"),
842            eq(Some(&vec![
843                "136822:1".to_string(),
844                "136816:1".to_string(),
845                "136817:1".to_string(),
846                "136820:1".to_string(),
847                "136814:1".to_string(),
848            ]))
849        )?;
850
851        Ok(())
852    }
853
854    #[gtest]
855    fn named_enchants_resolve_by_alias_rank_and_equipped_item_masks() -> GtestResult<()> {
856        let parsed = parse_simc(
857            "death_knight=\"Test\"\n\
858             level=90\n\
859             finger1=first,id=193708,enchant=eyes_of_the_eagle_2\n\
860             finger2=second,id=249919,enchant=enchant_ring__eyes_of_the_eagle_2\n\
861             legs=third,id=249969,enchant=forest_hunters_armor_kit_2\n",
862        )
863        .or_fail()?;
864        let resolver = InMemoryResolver::new()
865            .with_item(ItemDataFlat {
866                id: 193_708,
867                class_id: 4,
868                subclass_id: 0,
869                inventory_type: 11,
870                ..ItemDataFlat::default()
871            })
872            .with_item(ItemDataFlat {
873                id: 249_919,
874                class_id: 4,
875                subclass_id: 0,
876                inventory_type: 11,
877                ..ItemDataFlat::default()
878            })
879            .with_item(ItemDataFlat {
880                id: 249_969,
881                class_id: 4,
882                subclass_id: 3,
883                inventory_type: 7,
884                ..ItemDataFlat::default()
885            })
886            .with_permanent_enchant(PermanentEnchantFlat {
887                enchant_id: 7967,
888                rank: 2,
889                item_class: 4,
890                inventory_type_mask: 1 << 11,
891                subclass_mask: 0x1f,
892                tokenized_name: "enchant_ring__eyes_of_the_eagle".to_string(),
893            })
894            .with_permanent_enchant(PermanentEnchantFlat {
895                enchant_id: 8159,
896                rank: 2,
897                item_class: 4,
898                inventory_type_mask: 1 << 7,
899                subclass_mask: 0x1e,
900                tokenized_name: "forest_hunters_armor_kit".to_string(),
901            });
902        let resolver_dyn = DynDataResolver::from_ref(&resolver);
903        let profile = test_runtime()
904            .or_fail()?
905            .block_on(profile_intent_data(parsed, resolver_dyn))
906            .or_fail()?;
907
908        verify_that!(
909            profile
910                .gear
911                .iter()
912                .filter_map(|entry| entry.enchant_id)
913                .collect::<Vec<_>>(),
914            elements_are![eq(&7967), eq(&7967), eq(&8159)]
915        )
916    }
917
918    #[gtest]
919    fn wowlab_profile_config_substitutes_each_exact_phase_eight_fixture() -> GtestResult<()> {
920        let config = ComparisonConfig {
921            spec: SpecId::Feral,
922            parameters: test_parameters(),
923            race: None,
924            bugs: true,
925            simc_debug: false,
926        };
927
928        for fixture in [
929            EncounterFixture::TwoTargetPack,
930            EncounterFixture::BossAddWave,
931            EncounterFixture::TwoPullDungeon,
932        ] {
933            let resolver = InMemoryResolver::new();
934            let resolver_dyn = DynDataResolver::from_ref(&resolver);
935            let serialized = test_runtime()
936                .or_fail()?
937                .block_on(profile_config_from_simc(
938                    &config,
939                    "feral_druid_assisted",
940                    Some(fixture),
941                    FERAL_TEST_PROFILE,
942                    resolver_dyn,
943                ))
944                .or_fail()?;
945            let parsed = parse_sim_config(&serialized).or_fail()?;
946            let definition = fixture.definition().or_fail()?;
947
948            verify_that!(
949                &parsed,
950                matches_pattern!(SimConfigIntent {
951                    encounter: eq(&definition),
952                    player_level: eq(&90),
953                    ..
954                })
955            )?;
956            verify_that!(parsed.encounter.fixed_duration_s, eq(None))?;
957            verify_that!(
958                parsed.settings.get("race").and_then(toml::Value::as_str),
959                eq(Some("Night Elf"))
960            )?;
961        }
962
963        Ok(())
964    }
965}