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(
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
391pub(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
445fn 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
591fn 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 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}