Skip to main content

manabrew_protocol/
deck_dto.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::fmt::Write;
4use ts_rs::TS;
5
6use crate::game::PlaymatSettings;
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
9#[serde(rename_all = "camelCase")]
10#[ts(export, export_to = "deck/index.ts")]
11pub struct DeckCardIdentity {
12    #[serde(default, skip_serializing_if = "String::is_empty")]
13    pub id: String,
14    pub name: String,
15    pub set_code: String,
16    pub card_number: String,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    #[ts(optional)]
19    pub foil: Option<bool>,
20}
21
22/// Mirror of `manabrew.ts:CardRulesSummary`. The engine derives most
23/// of this from its own card DB; included here so the same wire shape
24/// round-trips losslessly.
25#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
26#[serde(rename_all = "camelCase")]
27#[ts(export, export_to = "deck/index.ts")]
28pub struct CardRulesSummary {
29    #[serde(default)]
30    pub color: String,
31    #[serde(default)]
32    pub color_identity: Vec<String>,
33    #[serde(default)]
34    pub mana_cost: String,
35    #[serde(default)]
36    pub cmc: f32,
37    #[serde(default)]
38    pub types: Vec<String>,
39    #[serde(default)]
40    pub subtypes: Vec<String>,
41    #[serde(default)]
42    pub supertypes: Vec<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[ts(optional)]
45    pub keywords: Option<Vec<String>>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    #[ts(optional)]
48    pub power: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    #[ts(optional)]
51    pub toughness: Option<String>,
52    #[serde(default)]
53    pub text: String,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    #[ts(optional)]
56    pub layout: Option<String>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    #[ts(optional)]
59    pub is_double_faced: Option<bool>,
60    /// Back face of a transform / modal_dfc card, captured at deck import.
61    /// Absent on single-faced cards and on decks saved before it existed.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    #[ts(optional)]
64    pub back_face: Option<CardBackFaceSummary>,
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
68#[serde(rename_all = "camelCase")]
69#[ts(export, export_to = "deck/index.ts")]
70pub struct CardBackFaceSummary {
71    pub name: String,
72    #[serde(default)]
73    pub mana_cost: String,
74    #[serde(default)]
75    pub type_line: String,
76    #[serde(default)]
77    pub oracle_text: String,
78    #[serde(default)]
79    pub uris: CardImageUris,
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
83#[ts(export, export_to = "deck/index.ts")]
84pub struct CardImageUris {
85    #[serde(default)]
86    pub small: String,
87    #[serde(default)]
88    pub normal: String,
89    #[serde(default)]
90    pub large: String,
91    #[serde(default)]
92    pub png: String,
93    #[serde(default)]
94    pub art_crop: String,
95    #[serde(default)]
96    pub border_crop: String,
97}
98
99#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
100#[serde(rename_all = "snake_case")]
101#[ts(export, export_to = "deck/index.ts")]
102pub enum CardPartComponent {
103    Token,
104    ComboPiece,
105    MeldPart,
106    MeldResult,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, TS)]
110#[serde(rename_all = "camelCase")]
111#[ts(export, export_to = "deck/index.ts")]
112pub struct CardPart {
113    pub name: String,
114    pub component: CardPartComponent,
115}
116
117#[derive(Debug, Clone, Default, Serialize, TS)]
118#[serde(rename_all = "camelCase")]
119#[ts(export, export_to = "deck/index.ts")]
120pub struct DeckCard {
121    pub identity: DeckCardIdentity,
122    #[serde(flatten)]
123    #[ts(flatten)]
124    pub rules: CardRulesSummary,
125    #[serde(default)]
126    pub uris: CardImageUris,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    #[ts(optional)]
129    pub all_parts: Option<Vec<CardPart>>,
130}
131
132pub const OUTDATED_CLIENT_MESSAGE: &str =
133    "this app version is out of date — download the latest release at manabrew.app to play online";
134
135// Clients ≤ v0.5.2 serialize the identity fields flattened onto the card.
136// Their gameplay wire (game view, prompts) has drifted too, so the legacy
137// shape is detected and rejected with an actionable message rather than
138// accepted into a game the client could not parse.
139#[derive(Deserialize)]
140#[serde(rename_all = "camelCase")]
141struct DeckCardWire {
142    #[serde(default)]
143    identity: Option<DeckCardIdentity>,
144    #[serde(default)]
145    name: Option<String>,
146    #[serde(flatten)]
147    rules: CardRulesSummary,
148    #[serde(default)]
149    uris: CardImageUris,
150    #[serde(default)]
151    all_parts: Option<Vec<CardPart>>,
152}
153
154impl<'de> Deserialize<'de> for DeckCard {
155    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
156        let wire = DeckCardWire::deserialize(deserializer)?;
157        let identity = match wire.identity {
158            Some(identity) => identity,
159            None if wire.name.is_some() => {
160                return Err(serde::de::Error::custom(OUTDATED_CLIENT_MESSAGE));
161            }
162            None => return Err(serde::de::Error::missing_field("identity")),
163        };
164        Ok(DeckCard {
165            identity,
166            rules: wire.rules,
167            uris: wire.uris,
168            all_parts: wire.all_parts,
169        })
170    }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, TS)]
174#[serde(rename_all = "camelCase")]
175#[ts(export, export_to = "deck/index.ts")]
176pub struct DeckLabel {
177    pub name: String,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    #[ts(optional)]
180    pub color: Option<String>,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
184#[serde(rename_all = "camelCase")]
185#[ts(export, export_to = "deck/index.ts")]
186pub enum DeckFormat {
187    Standard,
188    Pioneer,
189    Modern,
190    Legacy,
191    Vintage,
192    Pauper,
193    Commander,
194    Brawl,
195    Oathbreaker,
196    Draft,
197    Sealed,
198}
199
200#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
201#[serde(rename_all = "camelCase")]
202#[ts(export, export_to = "deck/index.ts")]
203pub struct Deck {
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    #[ts(optional)]
206    pub version: Option<String>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    #[ts(optional)]
209    pub id: Option<String>,
210    pub name: String,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    #[ts(optional)]
213    pub description: Option<String>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    #[ts(optional)]
216    pub color: Option<String>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    #[ts(optional)]
219    pub format: Option<DeckFormat>,
220    #[serde(default)]
221    pub cards: Vec<DeckCard>,
222    #[serde(default)]
223    pub sideboard: Vec<DeckCard>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    #[ts(optional)]
226    pub attractions: Option<Vec<DeckCard>>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    #[ts(optional)]
229    pub contraptions: Option<Vec<DeckCard>>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    #[ts(optional)]
232    pub schemes: Option<Vec<DeckCard>>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    #[ts(optional)]
235    pub planes: Option<Vec<DeckCard>>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    #[ts(optional)]
238    pub commanders: Option<Vec<DeckCard>>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    #[ts(optional)]
241    pub companion: Option<DeckCard>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    #[ts(optional)]
244    pub maybeboard: Option<Vec<DeckCard>>,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    #[ts(optional)]
247    pub draft: Option<bool>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    #[ts(optional)]
250    pub labels: Option<Vec<DeckLabel>>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    #[ts(optional)]
253    pub cover_card_name: Option<String>,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    #[ts(optional)]
256    pub cover_card_face: Option<u8>,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    #[ts(optional)]
259    pub playmat: Option<String>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    #[ts(optional)]
262    pub playmat_settings: Option<PlaymatSettings>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    #[ts(optional, type = "Record<string, { x: number; y: number }>")]
265    pub stack_positions: Option<serde_json::Value>,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    #[ts(optional)]
268    pub tokens: Option<Vec<DeckCard>>,
269}
270
271pub fn deck_fingerprint(deck: &Deck) -> String {
272    let mut hasher = Sha256::new();
273    update_fingerprint(&mut hasher, &deck.name);
274    update_fingerprint(
275        &mut hasher,
276        &serde_json::to_string(&deck.format).unwrap_or_default(),
277    );
278    let mut cards = Vec::new();
279    cards.extend(deck.cards.iter().map(|card| ("main", card)));
280    cards.extend(deck.sideboard.iter().map(|card| ("sideboard", card)));
281    cards.extend(
282        deck.attractions
283            .iter()
284            .flatten()
285            .map(|card| ("attraction", card)),
286    );
287    cards.extend(
288        deck.contraptions
289            .iter()
290            .flatten()
291            .map(|card| ("contraption", card)),
292    );
293    cards.extend(deck.schemes.iter().flatten().map(|card| ("scheme", card)));
294    cards.extend(deck.planes.iter().flatten().map(|card| ("plane", card)));
295    cards.extend(
296        deck.commanders
297            .iter()
298            .flatten()
299            .map(|card| ("commander", card)),
300    );
301    cards.extend(deck.companion.iter().map(|card| ("companion", card)));
302    cards.sort_by(|(left_section, left), (right_section, right)| {
303        (
304            left_section,
305            &left.identity.name,
306            &left.identity.set_code,
307            &left.identity.card_number,
308        )
309            .cmp(&(
310                right_section,
311                &right.identity.name,
312                &right.identity.set_code,
313                &right.identity.card_number,
314            ))
315    });
316    for (section, card) in cards {
317        update_fingerprint(&mut hasher, section);
318        update_fingerprint(&mut hasher, &card.identity.name);
319        update_fingerprint(&mut hasher, &card.identity.set_code);
320        update_fingerprint(&mut hasher, &card.identity.card_number);
321    }
322    let digest = hasher.finalize();
323    let mut fingerprint = String::with_capacity(digest.len() * 2);
324    for byte in digest {
325        let _ = write!(fingerprint, "{byte:02x}");
326    }
327    fingerprint
328}
329
330fn update_fingerprint(hasher: &mut Sha256, value: &str) {
331    hasher.update((value.len() as u64).to_le_bytes());
332    hasher.update(value.as_bytes());
333}