Skip to main content

wowlab_parsers/parsers/parcel/
payload.rs

1//! JSON payload schema for shipping Best-in-Bag and Droptimizer results to a `WoW` addon.
2
3use serde::{Deserialize, Serialize};
4
5use super::{
6    codec::{Codec, CodecConfig},
7    errors::ParcelError,
8};
9
10/// Wire-format prefix for the result export envelope; trailing digit is the version.
11pub const RESULT_EXPORT_PREFIX: &str = "!WOWLAB:1!";
12
13/// Content-type token used inside the envelope.
14pub const RESULT_EXPORT_CONTENT_TYPE: &str = "json";
15
16/// Schema version embedded in the JSON payload, distinct from the wire prefix version.
17pub const RESULT_EXPORT_SCHEMA_VERSION: u32 = 1;
18
19/// Build a codec preconfigured for wowlab result exports.
20///
21/// # Panics
22///
23/// Panics only if the compile-time constants in this module form an invalid codec configuration.
24#[must_use]
25pub fn result_export_codec() -> Codec {
26    let config = CodecConfig {
27        prefix: RESULT_EXPORT_PREFIX.to_owned(),
28        content_types: vec![RESULT_EXPORT_CONTENT_TYPE.to_owned()],
29        default_content_type: Some(RESULT_EXPORT_CONTENT_TYPE.to_owned()),
30        max_encoded_len: None,
31        max_decoded_body: None,
32        max_payload_bytes: None,
33    };
34
35    Codec::new(config).expect("static result-export codec config is valid")
36}
37
38/// What kind of result is being exported.
39#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
40#[serde(rename_all = "snake_case")]
41#[non_exhaustive]
42pub enum ExportKind {
43    Bib,
44    Drops,
45}
46
47/// Top-level wowlab to addon export envelope (the inner JSON of a parcel).
48#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
49pub struct ResultExport {
50    pub v: u32,
51    pub kind: ExportKind,
52    pub spec: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub job_id: Option<String>,
55    pub generated_at_ms: u64,
56    pub baseline_dps: f64,
57    /// Equals `baseline_dps` when no improvement was found.
58    pub winner_dps: f64,
59    /// In render order.
60    pub items: Vec<ResultItem>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub note: Option<String>,
63}
64
65/// A single exported item row, carrying enough identity to reconstruct a `WoW` `item:` link.
66#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
67pub struct ResultItem {
68    pub slot: String,
69    pub item_id: u32,
70    #[serde(default, skip_serializing_if = "Vec::is_empty")]
71    pub bonus_ids: Vec<u32>,
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub gem_ids: Vec<u32>,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub gem_bonus_ids: Vec<u32>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub enchant_id: Option<u32>,
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub crafted_stats: Vec<u32>,
80    /// Crafting quality (1-5).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub crafting_quality: Option<u8>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub drop_level: Option<u32>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub suffix: Option<i32>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub item_level: Option<u32>,
89    pub mean_dps: f64,
90    pub dps_delta_vs_baseline: f64,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub ci95_half: Option<f64>,
93    /// 1-indexed; `1` is the recommended pick.
94    pub rank: u32,
95    /// Fraction (0..=1) of phases where this item won its slot.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub win_rate: Option<f64>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub source_label: Option<String>,
100}
101
102wowlab_engine_macros::define_error! {
103/// Errors raised by [`encode_result_export`].
104#[derive(Debug)]
105pub struct EncodeExportError(EncodeExportErrorKind);
106
107#[derive(Debug, thiserror::Error)]
108enum EncodeExportErrorKind {
109    #[error("failed to serialise export to JSON: {0}")]
110    Serialize(#[source] serde_json::Error),
111    #[error("parcel codec rejected export payload: {0}")]
112    Codec(#[source] ParcelError),
113}
114}
115
116impl EncodeExportError {
117    /// Returns the stable parcel error when envelope encoding failed.
118    #[must_use]
119    pub fn parcel_error(&self) -> Option<ParcelError> {
120        match &self.0 {
121            EncodeExportErrorKind::Codec(error) => Some(error.clone()),
122            EncodeExportErrorKind::Serialize(_) => None,
123        }
124    }
125}
126
127impl From<serde_json::Error> for EncodeExportError {
128    fn from(error: serde_json::Error) -> Self {
129        Self(EncodeExportErrorKind::Serialize(error))
130    }
131}
132
133impl From<ParcelError> for EncodeExportError {
134    fn from(error: ParcelError) -> Self {
135        Self(EncodeExportErrorKind::Codec(error))
136    }
137}
138
139wowlab_engine_macros::define_error! {
140/// Errors raised by [`decode_result_export`].
141#[derive(Debug)]
142pub struct DecodeExportError(DecodeExportErrorKind);
143
144#[derive(Debug, thiserror::Error)]
145enum DecodeExportErrorKind {
146    #[error("parcel codec rejected envelope: {0}")]
147    Codec(#[source] ParcelError),
148    #[error("expected content_type=\"{expected}\", got \"{actual}\"")]
149    WrongContentType {
150        expected: &'static str,
151        actual: String,
152    },
153    #[error("failed to deserialise export from JSON: {0}")]
154    Deserialize(#[source] serde_json::Error),
155    #[error("unsupported export schema version: {0}")]
156    UnsupportedVersion(u32),
157}
158}
159
160impl DecodeExportError {
161    fn wrong_content_type(actual: String) -> Self {
162        Self(DecodeExportErrorKind::WrongContentType {
163            expected: RESULT_EXPORT_CONTENT_TYPE,
164            actual,
165        })
166    }
167
168    const fn unsupported_version(version: u32) -> Self {
169        Self(DecodeExportErrorKind::UnsupportedVersion(version))
170    }
171
172    /// Returns the stable parcel error when envelope decoding failed.
173    #[must_use]
174    pub fn parcel_error(&self) -> Option<ParcelError> {
175        match &self.0 {
176            DecodeExportErrorKind::Codec(error) => Some(error.clone()),
177            _ => None,
178        }
179    }
180
181    /// Returns the unexpected content type when the envelope was not a result export.
182    #[must_use]
183    pub fn wrong_content_type_value(&self) -> Option<&str> {
184        match &self.0 {
185            DecodeExportErrorKind::WrongContentType { actual, .. } => Some(actual),
186            _ => None,
187        }
188    }
189
190    /// Returns the unsupported schema version, when present.
191    #[must_use]
192    pub const fn unsupported_schema_version(&self) -> Option<u32> {
193        match self.0 {
194            DecodeExportErrorKind::UnsupportedVersion(version) => Some(version),
195            _ => None,
196        }
197    }
198}
199
200impl From<ParcelError> for DecodeExportError {
201    fn from(error: ParcelError) -> Self {
202        Self(DecodeExportErrorKind::Codec(error))
203    }
204}
205
206impl From<serde_json::Error> for DecodeExportError {
207    fn from(error: serde_json::Error) -> Self {
208        Self(DecodeExportErrorKind::Deserialize(error))
209    }
210}
211
212/// Serialise `export` to JSON and wrap it in a parcel envelope as a single ASCII string.
213///
214/// # Errors
215///
216/// Returns [`EncodeExportError`] when JSON serialization or parcel encoding fails.
217pub fn encode_result_export(export: &ResultExport) -> Result<String, EncodeExportError> {
218    let json = serde_json::to_vec(export)?;
219    let codec = result_export_codec();
220
221    Ok(codec.encode(&json, Some(RESULT_EXPORT_CONTENT_TYPE))?)
222}
223
224/// Inverse of [`encode_result_export`].
225///
226/// # Errors
227///
228/// Returns [`DecodeExportError`] for an invalid envelope, payload, content type, or schema version.
229pub fn decode_result_export(encoded: &str) -> Result<ResultExport, DecodeExportError> {
230    let codec = result_export_codec();
231    let decoded = codec.decode(encoded)?;
232
233    if decoded.meta.content_type != RESULT_EXPORT_CONTENT_TYPE {
234        return Err(DecodeExportError::wrong_content_type(
235            decoded.meta.content_type,
236        ));
237    }
238
239    let export: ResultExport = serde_json::from_slice(&decoded.payload)?;
240
241    if export.v > RESULT_EXPORT_SCHEMA_VERSION {
242        return Err(DecodeExportError::unsupported_version(export.v));
243    }
244
245    Ok(export)
246}