Skip to main content

forge/
spell_fixtures.rs

1// #t(file: rust_alloc_in_loop) CLI fixture generation, bounded by spell/effect count
2// #t(file: rust_clone_in_loop) each fixture embeds an owned paperdoll/spell snapshot
3
4#![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
10//! `gen-spell-fixtures` subcommand: render golden spell-description fixtures.
11
12use 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// `$a` (radius) has no real fixture: it maps to `radius_min`, but real AoE spells store radius in `radius_max`, so a fixture would render "0 yards".
35#[rustfmt::skip]
36const FIXTURES: &[(u32, u32)] = &[
37    // #t:aligned
38    (30451  , 62) , // Arcane Blast — $s SP-scaling, $l plural, color codes, cross-spell, paragraphs
39    (42955  , 62) , // Conjure Refreshment — $g gender
40    (43265  , 62) , // Death and Decay — ${} expression block, cross-spell
41    (302_500, 62) , // Lightwell Renew — $t tick interval, $s
42    (108_359, 62) , // Dark Regeneration — $o periodic total, $s
43    (202_782, 62) , // Armor Skills — $?c class conditional
44    (703    , 260), // Garrote — $?a aura conditional, $l plural, $d duration
45    (315_341, 260), // Between the Eyes — $?s spell-known conditional, ${}
46];
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        // Browser data omits category recovery time, so fixtures use the engine value.
243        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
250/// Flatten fragments to plain text — the contract the TS e2e suite mirrors.
251// #t(fn: rust_recursive_fn) recursion is bounded by the renderer's MAX_EMBED_DEPTH fragment nesting
252fn 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}