1#![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
11use 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 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
382pub(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
436fn 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
582fn 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 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}