wowlab_parsers/parsers/transform/
rotation_overlay.rs1use include_dir::{Dir, include_dir};
4use serde_json::Value;
5use wowlab_types::sim::Rotation;
6
7static OVERLAYS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../engine/examples/overlays");
9
10wowlab_engine_macros::define_error! {
11#[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
67pub 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}