Skip to main content

wowlab_parsers/parsers/transform/
rotation_overlay.rs

1//! Tracked RFC 6902 patches applied to generated assisted rotations.
2
3use include_dir::{Dir, include_dir};
4use serde_json::Value;
5use wowlab_types::sim::Rotation;
6
7/// Adding a correction is dropping a file here: no code change, no registry to keep in sync.
8static OVERLAYS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../engine/examples/overlays");
9
10wowlab_engine_macros::define_error! {
11/// Failure applying a rotation overlay patch.
12#[derive(Debug)]
13pub struct RotationOverlayError(RotationOverlayErrorKind);
14
15#[derive(Debug, thiserror::Error)]
16enum RotationOverlayErrorKind {
17    #[error("invalid RFC 6902 rotation overlay for {slug}")]
18    OverlayJson {
19        slug: String,
20        #[source]
21        source: serde_json::Error,
22    },
23    #[error("rotation overlay patch failed for {slug}")]
24    Patch {
25        slug: String,
26        #[source]
27        source: json_patch::PatchError,
28    },
29    #[error("patched rotation for {slug} is invalid")]
30    Invalid {
31        slug: String,
32        #[source]
33        source: serde_json::Error,
34    },
35}
36}
37
38impl RotationOverlayError {
39    fn overlay_json(slug: &str, source: serde_json::Error) -> Self {
40        Self(RotationOverlayErrorKind::OverlayJson {
41            slug: slug.to_owned(),
42            source,
43        })
44    }
45
46    fn patch(slug: &str, source: json_patch::PatchError) -> Self {
47        Self(RotationOverlayErrorKind::Patch {
48            slug: slug.to_owned(),
49            source,
50        })
51    }
52
53    fn invalid(slug: &str, source: serde_json::Error) -> Self {
54        Self(RotationOverlayErrorKind::Invalid {
55            slug: slug.to_owned(),
56            source,
57        })
58    }
59}
60
61fn overlay_source(spec_slug: &str) -> Option<&'static str> {
62    OVERLAYS
63        .get_file(format!("{spec_slug}.json"))
64        .and_then(|file| file.contents_utf8())
65}
66
67/// Apply the tracked overlay for `spec_slug` to a generated rotation.
68///
69/// # Errors
70///
71/// Returns [`RotationOverlayError`] for an invalid patch, application failure, or invalid result.
72pub fn apply_rotation_overlay(
73    spec_slug: &str,
74    rotation: &Value,
75) -> Result<Value, RotationOverlayError> {
76    let Some(overlay_json) = overlay_source(spec_slug) else {
77        return Ok(rotation.clone());
78    };
79
80    let patch: json_patch::Patch = serde_json::from_str(overlay_json)
81        .map_err(|source| RotationOverlayError::overlay_json(spec_slug, source))?;
82
83    let mut document = rotation.clone();
84
85    json_patch::patch(&mut document, &patch)
86        .map_err(|source| RotationOverlayError::patch(spec_slug, source))?;
87
88    let _validated: Rotation = serde_json::from_value(document.clone())
89        .map_err(|source| RotationOverlayError::invalid(spec_slug, source))?;
90
91    Ok(document)
92}
93
94#[cfg(test)]
95mod tests {
96    use googletest::prelude::*;
97    use wowlab_types::sim::RotationAction;
98
99    use super::*;
100
101    const SIMPLE_ROTATION: &str = r#"{
102        "version":1,"name":"generated","variables":{},
103        "actions":[{"type":"call","list":"main"}],
104        "lists":{"main":[{"type":"cast","spell":"steady_shot","condition":{
105            "type":"not","operand":{"type":"read","domain":"talent",
106            "key":"pack_tactics","name":"is_enabled"}
107        }}]}
108    }"#;
109
110    fn apply_raw(rotation_json: &str, overlay_json: &str) -> Result<Value, RotationOverlayError> {
111        let patch: json_patch::Patch = serde_json::from_str(overlay_json)
112            .map_err(|source| RotationOverlayError::overlay_json("test", source))?;
113        let mut document: Value = serde_json::from_str(rotation_json)
114            .map_err(|source| RotationOverlayError::invalid("test", source))?;
115
116        json_patch::patch(&mut document, &patch)
117            .map_err(|source| RotationOverlayError::patch("test", source))?;
118
119        Ok(document)
120    }
121
122    #[gtest]
123    fn standard_patch_replaces_nested_read() -> Result<()> {
124        let overlay = r#"[
125            {"op":"test","path":"/lists/main/0/spell","value":"steady_shot"},
126            {"op":"replace","path":"/lists/main/0/condition/operand","value":{
127                "type":"read","domain":"aura","key":"pack_tactics","name":"is_active"
128            }}
129        ]"#;
130
131        let value = apply_raw(SIMPLE_ROTATION, overlay).or_fail()?;
132        let read = &value["lists"]["main"][0]["condition"]["operand"];
133
134        verify_that!(read["domain"].as_str(), some(eq("aura")))?;
135        verify_that!(read["name"].as_str(), some(eq("is_active")))?;
136
137        Ok(())
138    }
139
140    #[gtest]
141    fn standard_patch_rejects_failed_test() -> Result<()> {
142        let overlay = r#"[
143            {"op":"test","path":"/lists/main/0/spell","value":"wrong_spell"},
144            {"op":"replace","path":"/lists/main/0/spell","value":"arcane_shot"}
145        ]"#;
146
147        let error = apply_raw(SIMPLE_ROTATION, overlay).expect_err("test must fail");
148
149        verify_that!(error.to_string(), contains_substring("patch failed"))?;
150
151        Ok(())
152    }
153
154    #[gtest]
155    fn missing_overlay_returns_rotation_unchanged() -> Result<()> {
156        let rotation: Value = serde_json::from_str(SIMPLE_ROTATION).or_fail()?;
157        let out = apply_rotation_overlay("no_such_spec", &rotation).or_fail()?;
158
159        verify_that!(out, eq(&rotation))?;
160
161        Ok(())
162    }
163
164    #[gtest]
165    fn tracked_elemental_overlay_prepends_ascendance() -> Result<()> {
166        let rotation: Value = serde_json::from_str(include_str!(
167            "../../../../engine/examples/rotations/elemental_shaman_assisted.json"
168        ))
169        .or_fail()?;
170        let overlaid = apply_rotation_overlay("elemental_shaman", &rotation).or_fail()?;
171        let patched: Rotation = serde_json::from_value(overlaid).or_fail()?;
172
173        verify_that!(patched.actions, len(eq(2)))?;
174
175        verify_that!(
176            matches!(
177                patched.actions.first(),
178                Some(RotationAction::Cast { spell, .. }) if spell == "ascendance"
179            ),
180            eq(true)
181        )?;
182
183        Ok(())
184    }
185
186    #[gtest]
187    fn tracked_enhancement_overlay_prepends_ascendance() -> Result<()> {
188        let rotation: Value = serde_json::from_str(include_str!(
189            "../../../../engine/examples/rotations/enhancement_shaman_assisted.json"
190        ))
191        .or_fail()?;
192        let overlaid = apply_rotation_overlay("enhancement_shaman", &rotation).or_fail()?;
193        let patched: Rotation = serde_json::from_value(overlaid).or_fail()?;
194
195        verify_that!(patched.actions, len(eq(2)))?;
196        verify_that!(
197            matches!(
198                patched.actions.first(),
199                Some(RotationAction::Cast { spell, .. }) if spell == "ascendance"
200            ),
201            eq(true)
202        )?;
203
204        Ok(())
205    }
206
207    #[gtest]
208    fn tracked_feral_overlay_turns_waiting_for_energy_into_pool_actions() -> Result<()> {
209        let rotation: Value = serde_json::from_str(include_str!(
210            "../../../../engine/examples/rotations/feral_druid_assisted.json"
211        ))
212        .or_fail()?;
213        let value = apply_rotation_overlay("feral_druid", &rotation).or_fail()?;
214        let patched: Rotation = serde_json::from_value(value).or_fail()?;
215        let main = patched.lists.get("main").or_fail()?;
216
217        verify_that!(&main[8], matches_pattern!(RotationAction::Pool { .. }))?;
218        verify_that!(&main[9], matches_pattern!(RotationAction::Pool { .. }))?;
219
220        Ok(())
221    }
222
223    #[gtest]
224    fn tracked_assassination_overlay_disables_slice_and_dice() -> Result<()> {
225        let rotation: Value = serde_json::from_str(include_str!(
226            "../../../../engine/examples/rotations/assassination_rogue_assisted.json"
227        ))
228        .or_fail()?;
229        let value = apply_rotation_overlay("assassination_rogue", &rotation).or_fail()?;
230        let action = &value["lists"]["main"][17];
231
232        verify_that!(value["actions"].as_array(), some(len(eq(2))))?;
233        verify_that!(value["actions"][0]["spell"].as_str(), some(eq("vanish")))?;
234        verify_that!(action["spell"].as_str(), some(eq("slice_and_dice")))?;
235        verify_that!(action["condition"]["type"].as_str(), some(eq("bool")))?;
236        verify_that!(action["condition"]["value"].as_bool(), some(eq(false)))?;
237
238        Ok(())
239    }
240
241    #[gtest]
242    fn tracked_unholy_overlay_corrects_ambiguous_aura_reads() -> Result<()> {
243        let rotation: Value = serde_json::from_str(include_str!(
244            "../../../../engine/examples/rotations/unholy_death_knight_assisted.json"
245        ))
246        .or_fail()?;
247        let value = apply_rotation_overlay("unholy_death_knight", &rotation).or_fail()?;
248        let read = &value["lists"]["main"][10]["condition"]["operands"][3];
249
250        verify_that!(read["domain"].as_str(), some(eq("aura")))?;
251        verify_that!(read["key"].as_str(), some(eq("lesser_ghoul_counter")))?;
252        verify_that!(read["name"].as_str(), some(eq("is_active")))?;
253        let scythe_throttle = &value["lists"]["main"][9]["condition"]["operands"][2];
254
255        verify_that!(scythe_throttle["domain"].as_str(), some(eq("aura")))?;
256        verify_that!(
257            scythe_throttle["key"].as_str(),
258            some(eq("festering_scythe_tt"))
259        )?;
260        verify_that!(scythe_throttle["name"].as_str(), some(eq("is_inactive")))?;
261        verify_that!(scythe_throttle.get("on"), none())?;
262        let retired_festering_wound = &value["lists"]["main"][15]["condition"]["operands"][3];
263
264        verify_that!(retired_festering_wound["type"].as_str(), some(eq("bool")))?;
265        verify_that!(retired_festering_wound["value"].as_bool(), some(eq(false)))?;
266        verify_that!(
267            value["lists"]["main"]
268                .as_array()
269                .or_fail()?
270                .iter()
271                .all(|action| action["spell"].as_str() != Some("raise_dead")),
272            eq(true)
273        )?;
274
275        Ok(())
276    }
277}