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