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_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}