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 profile intent with an optional canonical Phase 8 encounter fixture.
383pub(crate) async fn wowlab_profile_config_with_fixture(
384    config: &ComparisonConfig,
385    rotation_id: &str,
386    encounter_fixture: Option<EncounterFixture>,
387    resolver: &DynDataResolver<'_>,
388) -> Result<String> {
389    let profile = simc_profile_intent_data(config, resolver).await?;
390
391    serialize_profile_config(config, rotation_id, encounter_fixture, profile)
392}
393
394fn serialize_profile_config(
395    config: &ComparisonConfig,
396    rotation_id: &str,
397    encounter_fixture: Option<EncounterFixture>,
398    profile: ProfileIntentData,
399) -> Result<String> {
400    ensure!(
401        profile.player_level > 0,
402        "SimC profile player level must be positive"
403    );
404    let race = config.race.as_deref().unwrap_or(&profile.race);
405    let mut intent = crate::intent::canonical_patchwerk_intent(
406        config.spec,
407        rotation_id,
408        f64::from(config.parameters.fight_duration_secs()),
409    )
410    .map_err(|message| anyhow::anyhow!("failed to build comparison intent: {message}"))?;
411
412    intent.loadout = profile.loadout;
413    intent.expansion_talents = profile.expansion_talents;
414    intent.gear = profile.gear;
415    intent.player_level = profile.player_level;
416    intent.player_expansion_id = profile.player_expansion_id;
417    intent.settings = profile.settings;
418
419    if let Some(fixture) = encounter_fixture {
420        intent.encounter = fixture.definition()?;
421    }
422
423    intent
424        .settings
425        .insert("race".to_string(), toml::Value::String(race.to_string()));
426
427    if !config.bugs {
428        intent
429            .settings
430            .insert("bugs".to_string(), toml::Value::Boolean(false));
431    }
432
433    serialize_sim_config(&intent).context("failed to serialize comparison intent")
434}
435
436// #t(rust_cyclomatic_complexity) sequential decode/resolve/timeline steps.
437fn run_engine(
438    config: &ComparisonConfig,
439    encounter_fixture: Option<EncounterFixture>,
440) -> Result<SimOutput> {
441    let slug = config.spec.slug();
442    let rotation_id = format!("{slug}_assisted");
443    let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
444    let rotation_path = engine_dir()
445        .join("examples/rotations")
446        .join(format!("{rotation_id}.json"));
447    let rotation_script = file::read_text(&rotation_path)
448        .with_context(|| format!("failed to read rotation {}", rotation_path.display()))?;
449    let rotation_value: serde_json::Value = serde_json::from_str(&rotation_script)
450        .with_context(|| format!("invalid rotation JSON {}", rotation_path.display()))?;
451    let mut rotation_value = wowlab_parsers::apply_rotation_overlay(slug, &rotation_value)?;
452    let profile_path = crate::simc::simc_profile_path(config.spec)
453        .ok_or_else(|| anyhow::anyhow!("no SimC profile for {}", config.spec.slug()))?;
454    let profile_text = file::read_text(&profile_path)
455        .with_context(|| format!("failed to read SimC profile {}", profile_path.display()))?;
456    let profile = parse_simc(&profile_text).context("failed to parse SimC profile")?;
457
458    add_registered_item_uses(
459        &mut rotation_value,
460        profile
461            .equipment
462            .iter()
463            .map(|item| (item.gear.slot, item.gear.id)),
464    )?;
465    let rotation_script = serde_json::to_string(&rotation_value)?;
466
467    let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir.as_str()))
468        .with_rotation_script(&rotation_id, rotation_script);
469    let resolver_dyn = DynDataResolver::from_ref(&resolver);
470    let catalog = content_catalog().context("engine content catalog is unavailable")?;
471    let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
472    let sim_config = rt.block_on(wowlab_profile_config_with_fixture(
473        config,
474        &rotation_id,
475        encounter_fixture,
476        resolver_dyn,
477    ))?;
478    let chunk = sim::make_chunk("compare", config.parameters.iterations(), None);
479
480    output::detail(&format!(
481        "Running wowlab ({} iter, {}s)...",
482        config.parameters.iterations(),
483        config.parameters.fight_duration_secs(),
484    ));
485
486    let report = rt
487        .block_on(simulate_intent(
488            catalog,
489            &sim_config,
490            &chunk,
491            DEFAULT_SEED,
492            resolver_dyn,
493            &NoopProgress,
494        ))
495        .with_context(|| format!("simulation failed for {slug}"))?;
496
497    let telemetry = sim::decode_telemetry(&report)?;
498
499    trace_telemetry_summary(&telemetry);
500
501    let mut name_map = build_spell_name_map(config.spec)?;
502
503    rt.block_on(enrich_spell_name_map(
504        &mut name_map,
505        telemetry.actions.iter().map(|action| action.spell_id),
506        resolver_dyn,
507    ));
508    let n = telemetry.iterations;
509    let fight_time_s = telemetry.total_fight_time_ms as f64 / MS_PER_SECOND / f64::from(n);
510    let total_dps = sim::mean_dps(&telemetry);
511
512    let spells = collect_spell_results(&telemetry, &name_map, fight_time_s, total_dps);
513
514    let mut timeline = Vec::new();
515
516    if let Some(rep) = &telemetry.representative {
517        let mut abs_ms: u32 = 0;
518
519        for m in &rep.markers {
520            abs_ms = abs_ms.saturating_add(m.delta_time_ms);
521
522            if m.kind == MARKER_KIND_CAST {
523                let spell_name = name_map
524                    .get(&m.spell_or_aura_id)
525                    .cloned()
526                    .unwrap_or_else(|| format!("unk#{}", m.spell_or_aura_id));
527
528                timeline.push(CastEntry {
529                    time_secs: f64::from(abs_ms) / MS_PER_SECOND,
530                    spell_name,
531                });
532            }
533        }
534    }
535
536    let mut output = SimOutput::new(total_dps, spells, timeline);
537
538    for row in &telemetry.resources {
539        for source in &row.by_source {
540            let name = match source.source_spell_id {
541                wowlab_engine_telemetry::RESOURCE_SOURCE_UNATTRIBUTED => "unattributed".to_string(),
542                wowlab_engine_telemetry::RESOURCE_SOURCE_AUTO_ATTACK => "auto_attack".to_string(),
543                wowlab_engine_telemetry::RESOURCE_SOURCE_PASSIVE_REGEN => {
544                    "passive_regen".to_string()
545                }
546                id => name_map
547                    .get(&id)
548                    .cloned()
549                    .unwrap_or_else(|| format!("unk#{id}")),
550            };
551            let entry = output.resource_gains.entry(name).or_default();
552            let iterations = f64::from(n.max(1));
553
554            entry.gained += source.gained_x100 as f64
555                / wowlab_types::constants::PROTO_RESOURCE_SCALE
556                / iterations;
557            entry.wasted += source.wasted_x100 as f64
558                / wowlab_types::constants::PROTO_RESOURCE_SCALE
559                / iterations;
560        }
561    }
562
563    if let Some(fixture) = encounter_fixture {
564        let encounter_debug = rt.block_on(crate::encounter_debug::build_report(
565            fixture,
566            &telemetry,
567            resolver_dyn,
568        ))?;
569
570        Ok(output.with_encounter_debug(encounter_debug))
571    } else {
572        Ok(output)
573    }
574}
575
576fn trace_telemetry_summary(telemetry: &wowlab_types::proto::ChunkTelemetry) {
577    tracing::trace!(actions = ?telemetry.actions, "action telemetry summaries");
578    tracing::trace!(auras = ?telemetry.auras, "aura telemetry summaries");
579    tracing::trace!(cooldowns = ?telemetry.cooldowns, "cooldown telemetry summaries");
580}
581
582/// Per-spell rows from action telemetry, keyed by resolved spell name.
583// #t(fn: rust_alloc_in_loop) CLI reporting builds one owned row per action; readability wins here
584fn collect_spell_results(
585    telemetry: &wowlab_types::proto::ChunkTelemetry,
586    name_map: &FastMap<u32, String>,
587    fight_time_s: f64,
588    total_dps: f64,
589) -> Vec<(String, SpellResult)> {
590    let n = telemetry.iterations;
591    let mut spells = Vec::with_capacity(telemetry.actions.len());
592
593    for a in &telemetry.actions {
594        let dmg = a.total_damage_x10 as f64 / DPS_SCALE;
595        let dps = if fight_time_s > 0.0 && n > 0 {
596            dmg / fight_time_s / f64::from(n)
597        } else {
598            0.0
599        };
600        let per_iteration = |total: u64| {
601            if n > 0 {
602                (total / u64::from(n)) as u32
603            } else {
604                0
605            }
606        };
607        let avg_casts = per_iteration(a.casts);
608        // Guardian, pet, and proc rows carry no casts; their damage still lands as hits and ticks.
609        let avg_hits = per_iteration(a.direct_hits.saturating_add(a.ticks));
610
611        if dps <= 0.0 && avg_casts == 0 {
612            continue;
613        }
614
615        let pct = if total_dps > 0.0 {
616            dps / total_dps
617        } else {
618            0.0
619        };
620        let name = name_map
621            .get(&a.spell_id)
622            .cloned()
623            .unwrap_or_else(|| format!("unk#{}", a.spell_id));
624
625        spells.push((
626            name,
627            SpellResult {
628                dps,
629                casts: avg_casts,
630                hits: avg_hits,
631                pct,
632            },
633        ));
634    }
635
636    spells
637}
638
639#[cfg(test)]
640mod tests {
641    use wowlab_common::sim::intent::{SimConfigIntent, parse_sim_config};
642    use wowlab_engine_adapter_data::InMemoryResolver;
643    use wowlab_types::{
644        data::{ItemDataFlat, PermanentEnchantFlat},
645        sim::{DifficultyContext, EnemyDefinition},
646    };
647
648    use super::*;
649    use crate::run::{FightDurationSeconds, IterationCount, RunParameters};
650
651    const FERAL_TEST_PROFILE: &str = "druid=\"Test\"\n\
652        spec=feral\n\
653        level=90\n\
654        race=night_elf\n";
655
656    fn test_parameters() -> RunParameters {
657        RunParameters::new(
658            IterationCount::ONE,
659            FightDurationSeconds::from_nonzero_const(30),
660        )
661    }
662
663    fn test_runtime() -> Result<tokio::runtime::Runtime> {
664        tokio::runtime::Runtime::new().context("failed to create test runtime")
665    }
666
667    async fn profile_config_from_simc(
668        config: &ComparisonConfig,
669        rotation_id: &str,
670        encounter_fixture: Option<EncounterFixture>,
671        profile_text: &str,
672        resolver: &DynDataResolver<'_>,
673    ) -> Result<String> {
674        let profile = parse_simc(profile_text).context("failed to parse test SimC profile")?;
675        let profile = profile_intent_data(profile, resolver).await?;
676
677        serialize_profile_config(config, rotation_id, encounter_fixture, profile)
678    }
679
680    #[gtest]
681    fn canonical_action_names_use_dbc_spell_identity_for_hunter_actions() -> GtestResult<()> {
682        verify_that!(
683            canonical_action_name(SpecId::BeastMastery, AUTO_SHOT_SPELL_ID, "auto_attack"),
684            eq("auto_shot")
685        )?;
686        verify_that!(
687            canonical_action_name(SpecId::BeastMastery, COBRA_SHOT_SPELL_ID, "arcane_shot"),
688            eq("cobra_shot")
689        )?;
690
691        verify_that!(
692            canonical_action_name(SpecId::BeastMastery, 1, "declared_name"),
693            eq("declared_name")
694        )
695    }
696
697    #[gtest]
698    fn canonical_action_names_match_feral_simc_attribution() -> GtestResult<()> {
699        verify_that!(
700            canonical_action_name(SpecId::Feral, CAT_MELEE_SPELL_ID, "auto_attack"),
701            eq("cat_melee")
702        )?;
703        verify_that!(
704            canonical_action_name(SpecId::Feral, FERAL_MOONFIRE_SPELL_ID, "moonfire"),
705            eq("lunar_inspiration")
706        )?;
707
708        verify_that!(
709            canonical_action_name(SpecId::Guardian, CAT_MELEE_SPELL_ID, "auto_attack"),
710            eq("auto_attack")
711        )
712    }
713
714    #[gtest]
715    fn feral_aura_names_do_not_overwrite_canonical_action_attribution() -> GtestResult<()> {
716        let names =
717            build_spell_name_map(SpecId::Feral).expect("Feral content introspection should build");
718
719        verify_that!(
720            names.get(&FERAL_MOONFIRE_SPELL_ID),
721            some(eq("lunar_inspiration"))
722        )?;
723
724        verify_that!(names.get(&CAT_MELEE_SPELL_ID), some(eq("cat_melee")))
725    }
726
727    #[gtest]
728    fn assisted_rotation_adds_each_registered_item_use_once() -> GtestResult<()> {
729        let mut rotation = serde_json::json!({
730            "actions": [{"type": "call", "list": "main"}],
731            "lists": {"main": []}
732        });
733        let equipment = [(GearSlot::Trinket1, 1), (GearSlot::Trinket2, 249_346)];
734
735        add_registered_item_uses(&mut rotation, equipment).or_fail()?;
736        add_registered_item_uses(&mut rotation, equipment).or_fail()?;
737
738        let actions = rotation["actions"].as_array().or_fail()?;
739
740        verify_that!(actions, len(eq(2)))?;
741        verify_that!(actions[0]["type"].as_str(), some(eq("use_trinket")))?;
742        verify_that!(actions[0]["slot"].as_u64(), some(eq(2)))?;
743
744        verify_that!(
745            actions[0]["condition"]["key"].as_str(),
746            some(eq("trinket_2_use"))
747        )
748    }
749
750    #[gtest]
751    fn wowlab_profile_config_uses_canonical_target_for_level_90_profile() -> GtestResult<()> {
752        let config = ComparisonConfig {
753            spec: SpecId::Feral,
754            parameters: test_parameters(),
755            race: None,
756            bugs: true,
757            simc_debug: false,
758        };
759        let resolver = InMemoryResolver::new();
760        let resolver_dyn = DynDataResolver::from_ref(&resolver);
761        let runtime = test_runtime().or_fail()?;
762        let profile = parse_simc(FERAL_TEST_PROFILE).or_fail()?;
763        let profile = runtime
764            .block_on(profile_intent_data(profile, resolver_dyn))
765            .or_fail()?;
766
767        verify_that!(profile.player_level, eq(90))?;
768        verify_that!(profile.player_expansion_id, eq(11))?;
769
770        let serialized = runtime
771            .block_on(profile_config_from_simc(
772                &config,
773                "feral_druid_assisted",
774                None,
775                FERAL_TEST_PROFILE,
776                resolver_dyn,
777            ))
778            .or_fail()?;
779        let parsed = parse_sim_config(&serialized).or_fail()?;
780
781        verify_that!(parsed.player_level, eq(90))?;
782        verify_that!(
783            parsed.player_expansion_id,
784            eq(crate::intent::PLAYER_EXPANSION_ID)
785        )?;
786        verify_that!(
787            parsed.encounter.enemies.as_slice(),
788            elements_are![matches_pattern!(EnemyDefinition {
789                level: eq(&crate::intent::CANONICAL_PATCHWERK_ENEMY_LEVEL),
790                difficulty: eq(&DifficultyContext::Generic {
791                    expansion_id: crate::intent::CANONICAL_PATCHWERK_EXPANSION_ID,
792                }),
793                ..
794            })]
795        )?;
796
797        for key in ["duration_s", "enemy_count", "expansion_id", "level"] {
798            verify_true!(!parsed.settings.contains_key(key))?;
799        }
800
801        Ok(())
802    }
803
804    #[gtest]
805    fn numeric_simc_expansion_talents_reach_the_engine_intent() -> GtestResult<()> {
806        let config = ComparisonConfig {
807            spec: SpecId::Fire,
808            parameters: test_parameters(),
809            race: None,
810            bugs: true,
811            simc_debug: false,
812        };
813        let resolver = InMemoryResolver::new();
814        let resolver_dyn = DynDataResolver::from_ref(&resolver);
815        let serialized = test_runtime()
816            .or_fail()?
817            .block_on(profile_config_from_simc(
818                &config,
819                "fire_mage_assisted",
820                None,
821                "mage=\"Test\"\n\
822                 spec=fire\n\
823                 level=90\n\
824                 race=orc\n\
825                 omnium_talents=136822:1/136816:1/136817:1/136820:1/136814:1\n",
826                resolver_dyn,
827            ))
828            .or_fail()?;
829        let parsed = parse_sim_config(&serialized).or_fail()?;
830
831        verify_that!(
832            parsed.expansion_talents.get("omnium"),
833            eq(Some(&vec![
834                "136822:1".to_string(),
835                "136816:1".to_string(),
836                "136817:1".to_string(),
837                "136820:1".to_string(),
838                "136814:1".to_string(),
839            ]))
840        )?;
841
842        Ok(())
843    }
844
845    #[gtest]
846    fn named_enchants_resolve_by_alias_rank_and_equipped_item_masks() -> GtestResult<()> {
847        let parsed = parse_simc(
848            "death_knight=\"Test\"\n\
849             level=90\n\
850             finger1=first,id=193708,enchant=eyes_of_the_eagle_2\n\
851             finger2=second,id=249919,enchant=enchant_ring__eyes_of_the_eagle_2\n\
852             legs=third,id=249969,enchant=forest_hunters_armor_kit_2\n",
853        )
854        .or_fail()?;
855        let resolver = InMemoryResolver::new()
856            .with_item(ItemDataFlat {
857                id: 193_708,
858                class_id: 4,
859                subclass_id: 0,
860                inventory_type: 11,
861                ..ItemDataFlat::default()
862            })
863            .with_item(ItemDataFlat {
864                id: 249_919,
865                class_id: 4,
866                subclass_id: 0,
867                inventory_type: 11,
868                ..ItemDataFlat::default()
869            })
870            .with_item(ItemDataFlat {
871                id: 249_969,
872                class_id: 4,
873                subclass_id: 3,
874                inventory_type: 7,
875                ..ItemDataFlat::default()
876            })
877            .with_permanent_enchant(PermanentEnchantFlat {
878                enchant_id: 7967,
879                rank: 2,
880                item_class: 4,
881                inventory_type_mask: 1 << 11,
882                subclass_mask: 0x1f,
883                tokenized_name: "enchant_ring__eyes_of_the_eagle".to_string(),
884            })
885            .with_permanent_enchant(PermanentEnchantFlat {
886                enchant_id: 8159,
887                rank: 2,
888                item_class: 4,
889                inventory_type_mask: 1 << 7,
890                subclass_mask: 0x1e,
891                tokenized_name: "forest_hunters_armor_kit".to_string(),
892            });
893        let resolver_dyn = DynDataResolver::from_ref(&resolver);
894        let profile = test_runtime()
895            .or_fail()?
896            .block_on(profile_intent_data(parsed, resolver_dyn))
897            .or_fail()?;
898
899        verify_that!(
900            profile
901                .gear
902                .iter()
903                .filter_map(|entry| entry.enchant_id)
904                .collect::<Vec<_>>(),
905            elements_are![eq(&7967), eq(&7967), eq(&8159)]
906        )
907    }
908
909    #[gtest]
910    fn wowlab_profile_config_substitutes_each_exact_phase_eight_fixture() -> GtestResult<()> {
911        let config = ComparisonConfig {
912            spec: SpecId::Feral,
913            parameters: test_parameters(),
914            race: None,
915            bugs: true,
916            simc_debug: false,
917        };
918
919        for fixture in [
920            EncounterFixture::TwoTargetPack,
921            EncounterFixture::BossAddWave,
922            EncounterFixture::TwoPullDungeon,
923        ] {
924            let resolver = InMemoryResolver::new();
925            let resolver_dyn = DynDataResolver::from_ref(&resolver);
926            let serialized = test_runtime()
927                .or_fail()?
928                .block_on(profile_config_from_simc(
929                    &config,
930                    "feral_druid_assisted",
931                    Some(fixture),
932                    FERAL_TEST_PROFILE,
933                    resolver_dyn,
934                ))
935                .or_fail()?;
936            let parsed = parse_sim_config(&serialized).or_fail()?;
937            let definition = fixture.definition().or_fail()?;
938
939            verify_that!(
940                &parsed,
941                matches_pattern!(SimConfigIntent {
942                    encounter: eq(&definition),
943                    player_level: eq(&90),
944                    ..
945                })
946            )?;
947            verify_that!(parsed.encounter.fixed_duration_s, eq(None))?;
948            verify_that!(
949                parsed.settings.get("race").and_then(toml::Value::as_str),
950                eq(Some("Night Elf"))
951            )?;
952        }
953
954        Ok(())
955    }
956}