1#![expect(
5 clippy::cast_possible_wrap,
6 clippy::cast_sign_loss,
7 reason = "fixture spell IDs are validated against the signed DBC identifier domain"
8)]
9
10use std::collections::BTreeMap;
13
14use anyhow::{Context, Result};
15use serde::Serialize;
16use wowlab_common::sim::intent::{IntentInput, build_sim_config};
17use wowlab_engine_adapter_data::{LocalCsvResolver, OverlayResolver};
18use wowlab_engine_application::resolve_paperdoll;
19use wowlab_engine_ports::{DataResolver, DynDataResolver, SpellId, content_catalog};
20use wowlab_fs::{artifact::GeneratedTextFile, directory, file, path::Path};
21use wowlab_parsers::{
22 GameDataResolver, Item, analyze_spell_desc_dependencies, parse_simc, parse_spell_desc,
23 render_spell_desc,
24};
25use wowlab_types::{
26 data::SpellDataFlat,
27 game::{GearEntry, SpecId},
28 spell_desc::SpellDescFragment,
29 spell_render::{ResolvedPaperdoll, SpellRenderInput, SpellRenderSpell},
30};
31
32use crate::constants::{default_data_dir, engine_dir};
33
34#[rustfmt::skip]
36const FIXTURES: &[(u32, u32)] = &[
37 (30451 , 62) , (42955 , 62) , (43265 , 62) , (302_500, 62) , (108_359, 62) , (202_782, 62) , (703 , 260), (315_341, 260), ];
47const SPELL_FIXTURE_DURATION_S: f64 = 300.0;
48
49#[derive(Debug, clap::Args)]
50pub(crate) struct GenSpellFixturesArgs;
51
52#[derive(Serialize)]
53struct SpellRenderFixture {
54 spell_id: u32,
55 spec_id: u32,
56 input: SpellRenderInput,
57 expected_fragments: Vec<SpellDescFragment>,
58 expected_text: String,
59}
60
61pub(crate) fn run(_args: &GenSpellFixturesArgs) -> Result<()> {
62 let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
63 let crates_dir = engine_dir()
64 .parent()
65 .context("engine crate has no parent")?
66 .to_path_buf();
67 let repo_root = crates_dir
68 .parent()
69 .context("crates dir has no parent")?
70 .to_path_buf();
71 let common_dir = crates_dir.join("common/tests/fixtures/spell_render");
72 let studio_dir =
73 repo_root.join("apps/studio/src/components/shared/game/__fixtures__/spell_render");
74
75 let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
76
77 let mut specs: Vec<u32> = FIXTURES.iter().map(|&(_, spec)| spec).collect();
78
79 specs.sort_unstable();
80 specs.dedup();
81
82 for spec_wow_id in specs {
83 generate_spec_fixtures(&rt, &data_dir, &common_dir, &studio_dir, spec_wow_id)?;
84 }
85
86 Ok(())
87}
88
89fn generate_spec_fixtures(
90 runtime: &tokio::runtime::Runtime,
91 data_dir: &str,
92 common_dir: &Path,
93 studio_dir: &Path,
94 spec_wow_id: u32,
95) -> Result<()> {
96 let spec = SpecId::from_wow_spec_id(spec_wow_id)
97 .with_context(|| format!("unknown spec id {spec_wow_id}"))?;
98 let slug = spec.slug();
99 let rotation_id = format!("{slug}_assisted");
100 let simc_path = common_dir.join(format!("characters/{spec_wow_id}.simc"));
101 let simc = file::read_text(&simc_path)
102 .with_context(|| format!("failed to read base character {}", simc_path.display()))?;
103 let profile = parse_simc(&simc)
104 .map_err(|error| anyhow::anyhow!("failed to parse {}: {error}", simc_path.display()))?;
105 let level = profile.character.level;
106 let player_level = u16::try_from(level)
107 .with_context(|| format!("profile level {level} does not fit the v2 intent"))?;
108 let gear: Vec<GearEntry> = profile.equipment.iter().map(to_gear_entry).collect();
109 let loadout = (!profile.talents.encoded.is_empty()).then(|| profile.talents.encoded.clone());
110 let sim_config = build_sim_config(IntentInput {
111 spec_id: spec_wow_id,
112 rotation_id: rotation_id.clone(),
113 loadout,
114 expansion_talents: BTreeMap::new(),
115 settings: BTreeMap::new(),
116 equipment: gear,
117 player_level,
118 player_expansion_id: crate::intent::PLAYER_EXPANSION_ID,
119 encounter: wowlab_types::sim::EncounterDefinition::patchwerk(
120 SPELL_FIXTURE_DURATION_S,
121 crate::intent::CANONICAL_PATCHWERK_ENEMY_LEVEL,
122 crate::intent::CANONICAL_PATCHWERK_EXPANSION_ID,
123 )
124 .context("failed to build current Patchwerk encounter")?,
125 })
126 .with_context(|| format!("failed to build sim config for {slug}"))?;
127 let rotation_path = engine_dir()
128 .join("examples/rotations")
129 .join(format!("{rotation_id}.json"));
130 let rotation_script = file::read_text(&rotation_path)
131 .with_context(|| format!("failed to read rotation {}", rotation_path.display()))?;
132 let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir))
133 .with_rotation_script(&rotation_id, rotation_script);
134 let resolver_dyn = DynDataResolver::from_ref(&resolver);
135 let catalog = content_catalog().context("engine content catalog is unavailable")?;
136 let paperdoll = runtime
137 .block_on(resolve_paperdoll(
138 catalog,
139 &sim_config,
140 level,
141 true,
142 resolver_dyn,
143 ))
144 .with_context(|| format!("failed to resolve paperdoll for {slug}"))?;
145
146 for &(spell_id, _) in FIXTURES.iter().filter(|&&(_, id)| id == spec_wow_id) {
147 let fixture = build_fixture(
148 runtime,
149 resolver_dyn,
150 spell_id,
151 spec_wow_id,
152 paperdoll.clone(),
153 )
154 .with_context(|| format!("failed to build fixture for spell {spell_id}"))?;
155 let json = serde_json::to_string_pretty(&fixture).context("failed to serialize fixture")?;
156
157 write_fixture(common_dir, studio_dir, spell_id, &json)?;
158 wowlab_common::output::info(&format!(
159 "wrote fixture {spell_id} (spec {spec_wow_id}): {}",
160 fixture.expected_text.replace('\n', " ⏎ ")
161 ));
162 }
163
164 Ok(())
165}
166
167fn build_fixture(
168 rt: &tokio::runtime::Runtime,
169 resolver: &DynDataResolver<'_>,
170 spell_id: u32,
171 spec_id: u32,
172 paperdoll: ResolvedPaperdoll,
173) -> Result<SpellRenderFixture> {
174 let self_data = rt
175 .block_on(resolver.get_spell(SpellId::new(spell_id as i32)))
176 .with_context(|| format!("failed to load self spell {spell_id}"))?;
177 let self_spell = to_render_spell(&self_data);
178
179 let parsed = parse_spell_desc(&self_spell.description);
180 let deps = analyze_spell_desc_dependencies(&parsed.ast, spell_id);
181
182 let mut cross_spells = Vec::new();
183
184 for cross_id in deps.spell_ids.iter().copied().filter(|&id| id != spell_id) {
185 match rt.block_on(resolver.get_spell(SpellId::new(cross_id as i32))) {
186 Ok(data) => cross_spells.push(to_render_spell(&data)),
187 Err(e) => wowlab_common::output::warning(&format!(
188 "spell {spell_id}: skipping cross-spell {cross_id}: {e}"
189 )),
190 }
191 }
192
193 let input = SpellRenderInput {
194 self_spell,
195 cross_spells,
196 paperdoll,
197 };
198
199 let game_resolver = GameDataResolver::new(&input);
200 let parse_errors: Vec<String> = parsed.errors.iter().map(ToString::to_string).collect();
201 let result = render_spell_desc(&parsed.ast, spell_id, &game_resolver, parse_errors);
202 let expected_text = flatten(&result.fragments);
203
204 Ok(SpellRenderFixture {
205 spell_id,
206 spec_id,
207 input,
208 expected_fragments: result.fragments,
209 expected_text,
210 })
211}
212
213fn write_fixture(common_dir: &Path, studio_dir: &Path, spell_id: u32, json: &str) -> Result<()> {
214 for dir in [common_dir, studio_dir] {
215 directory::ensure(dir).with_context(|| format!("failed to create {}", dir.display()))?;
216 let path = dir.join(format!("{spell_id}.json"));
217
218 GeneratedTextFile::new(&path, json)
219 .persist()
220 .with_context(|| format!("failed to write {}", path.display()))?;
221 }
222
223 Ok(())
224}
225
226fn to_gear_entry(item: &Item) -> GearEntry {
227 item.gear.clone()
228}
229
230fn to_render_spell(s: &SpellDataFlat) -> SpellRenderSpell {
231 SpellRenderSpell {
232 id: s.id as u32,
233 description: s.description.clone(),
234 description_variables: s.description_variables.clone(),
235 cast_time: s.cast_time,
236 recovery_time: s.recovery_time,
237 duration: s.duration,
238 max_charges: s.max_charges,
239 max_stacks: s.max_stacks,
240 range_max_0: s.range_max_0,
241 proc_rppm: s.rppm_base_rate,
242 internal_cooldown: s.recovery_time.max(s.category_recovery_time),
244 file_name: s.file_name.to_string(),
245 name: s.name.to_string(),
246 effects: s.effects.clone(),
247 }
248}
249
250fn flatten(frags: &[SpellDescFragment]) -> String {
253 let mut s = String::new();
254
255 for f in frags {
256 match f {
257 SpellDescFragment::Text { value }
258 | SpellDescFragment::Value { value, .. }
259 | SpellDescFragment::Duration { value, .. } => s.push_str(value),
260 SpellDescFragment::SpellName { name, .. } => s.push_str(name),
261 SpellDescFragment::Embedded { fragments, .. } => s.push_str(&flatten(fragments)),
262 SpellDescFragment::Unresolved { token } => {
263 s.push('[');
264 s.push_str(token);
265 s.push(']');
266 }
267 _ => {}
268 }
269 }
270
271 s
272}
273
274#[cfg(test)]
275mod tests {
276 use googletest::prelude::*;
277 use wowlab_fs::{file, temporary::Directory};
278
279 use super::write_fixture;
280
281 #[gtest]
282 fn fixture_persistence_updates_both_consumers_with_exact_json() -> Result<()> {
283 let temporary = Directory::new().or_fail()?;
284 let common = temporary.path().join("common/nested");
285 let studio = temporary.path().join("studio/nested");
286
287 write_fixture(&common, &studio, 123, r#"{"fixture":true}"#).or_fail()?;
288
289 verify_that!(
290 file::read_text(&common.join("123.json")).or_fail()?,
291 eq(r#"{"fixture":true}"#)
292 )?;
293
294 verify_that!(
295 file::read_text(&studio.join("123.json")).or_fail()?,
296 eq(r#"{"fixture":true}"#)
297 )
298 }
299}