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