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_actions() -> 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(3)))?;
174
175        verify_that!(
176            matches!(
177                patched.actions.first(),
178                Some(RotationAction::UseTrinket { slot: 2, .. })
179            ),
180            eq(true)
181        )
182    }
183
184    #[gtest]
185    fn tracked_feral_overlay_turns_waiting_for_energy_into_pool_actions() -> Result<()> {
186        let rotation: Value = serde_json::from_str(include_str!(
187            "../../../../engine/examples/rotations/feral_druid_assisted.json"
188        ))
189        .or_fail()?;
190        let value = apply_rotation_overlay("feral_druid", &rotation).or_fail()?;
191        let patched: Rotation = serde_json::from_value(value).or_fail()?;
192        let main = patched.lists.get("main").or_fail()?;
193
194        verify_that!(&main[8], matches_pattern!(RotationAction::Pool { .. }))?;
195        verify_that!(&main[9], matches_pattern!(RotationAction::Pool { .. }))?;
196
197        Ok(())
198    }
199
200    #[gtest]
201    fn tracked_frost_death_knight_overlay_uses_second_trinket_after_pillar() -> Result<()> {
202        let rotation: Value = serde_json::from_str(include_str!(
203            "../../../../engine/examples/rotations/frost_death_knight_assisted.json"
204        ))
205        .or_fail()?;
206        let value = apply_rotation_overlay("frost_death_knight", &rotation).or_fail()?;
207        let main = value["lists"]["main"].as_array().or_fail()?;
208
209        verify_that!(main[0]["spell"].as_str(), some(eq("pillar_of_frost")))?;
210        verify_that!(main[1]["type"].as_str(), some(eq("use_trinket")))?;
211        verify_that!(main[1]["slot"].as_u64(), some(eq(2)))?;
212        verify_that!(
213            main[1]["condition"]["key"].as_str(),
214            some(eq("trinket_2_use"))
215        )?;
216
217        Ok(())
218    }
219
220    #[gtest]
221    fn tracked_assassination_overlay_disables_slice_and_dice() -> Result<()> {
222        let rotation: Value = serde_json::from_str(include_str!(
223            "../../../../engine/examples/rotations/assassination_rogue_assisted.json"
224        ))
225        .or_fail()?;
226        let value = apply_rotation_overlay("assassination_rogue", &rotation).or_fail()?;
227        let action = &value["lists"]["main"][17];
228
229        verify_that!(value["actions"].as_array(), some(len(eq(2))))?;
230        verify_that!(value["actions"][0]["spell"].as_str(), some(eq("vanish")))?;
231        verify_that!(action["spell"].as_str(), some(eq("slice_and_dice")))?;
232        verify_that!(action["condition"]["type"].as_str(), some(eq("bool")))?;
233        verify_that!(action["condition"]["value"].as_bool(), some(eq(false)))?;
234
235        Ok(())
236    }
237
238    #[gtest]
239    fn tracked_unholy_overlay_corrects_ambiguous_aura_reads() -> Result<()> {
240        let rotation: Value = serde_json::from_str(include_str!(
241            "../../../../engine/examples/rotations/unholy_death_knight_assisted.json"
242        ))
243        .or_fail()?;
244        let value = apply_rotation_overlay("unholy_death_knight", &rotation).or_fail()?;
245        let read = &value["lists"]["main"][11]["condition"]["operands"][3];
246
247        verify_that!(read["domain"].as_str(), some(eq("aura")))?;
248        verify_that!(read["key"].as_str(), some(eq("lesser_ghoul_counter")))?;
249        verify_that!(read["name"].as_str(), some(eq("is_active")))?;
250        let scythe_throttle = &value["lists"]["main"][10]["condition"]["operands"][2];
251
252        verify_that!(scythe_throttle["domain"].as_str(), some(eq("aura")))?;
253        verify_that!(
254            scythe_throttle["key"].as_str(),
255            some(eq("festering_scythe_tt"))
256        )?;
257        verify_that!(scythe_throttle["name"].as_str(), some(eq("is_inactive")))?;
258        verify_that!(scythe_throttle.get("on"), none())?;
259
260        Ok(())
261    }
262}