Skip to main content

manabrew_protocol/
deck_dto.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::collections::BTreeMap;
4use std::fmt::Write;
5use ts_rs::TS;
6
7use crate::{game::PlaymatSettings, TokenScript};
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
10#[serde(rename_all = "camelCase")]
11#[ts(export, export_to = "deck/index.ts")]
12pub struct DeckCardIdentity {
13    #[serde(default, skip_serializing_if = "String::is_empty")]
14    pub id: String,
15    pub name: String,
16    pub set_code: String,
17    pub card_number: String,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    #[ts(optional)]
20    pub oracle_id: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    #[ts(optional)]
23    pub token_script: Option<TokenScript>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    #[ts(optional)]
26    pub foil: Option<bool>,
27}
28
29/// Mirror of `manabrew.ts:CardRulesSummary`. The engine derives most
30/// of this from its own card DB; included here so the same wire shape
31/// round-trips losslessly.
32#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
33#[serde(rename_all = "camelCase")]
34#[ts(export, export_to = "deck/index.ts")]
35pub struct CardRulesSummary {
36    #[serde(default)]
37    pub color: String,
38    #[serde(default)]
39    pub color_identity: Vec<String>,
40    #[serde(default)]
41    pub mana_cost: String,
42    #[serde(default)]
43    pub cmc: f32,
44    #[serde(default)]
45    pub types: Vec<String>,
46    #[serde(default)]
47    pub subtypes: Vec<String>,
48    #[serde(default)]
49    pub supertypes: Vec<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    #[ts(optional)]
52    pub keywords: Option<Vec<String>>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    #[ts(optional)]
55    pub power: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    #[ts(optional)]
58    pub toughness: Option<String>,
59    #[serde(default)]
60    pub text: String,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    #[ts(optional)]
63    pub layout: Option<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    #[ts(optional)]
66    pub is_double_faced: Option<bool>,
67    /// Back face of a transform / modal_dfc card, captured at deck import.
68    /// Absent on single-faced cards and on decks saved before it existed.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    #[ts(optional)]
71    pub back_face: Option<CardBackFaceSummary>,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
75#[serde(rename_all = "camelCase")]
76#[ts(export, export_to = "deck/index.ts")]
77pub struct CardBackFaceSummary {
78    pub name: String,
79    #[serde(default)]
80    pub mana_cost: String,
81    #[serde(default)]
82    pub type_line: String,
83    #[serde(default)]
84    pub oracle_text: String,
85    #[serde(default)]
86    pub uris: CardImageUris,
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
90#[ts(export, export_to = "deck/index.ts")]
91pub struct CardImageUris {
92    #[serde(default)]
93    pub small: String,
94    #[serde(default)]
95    pub normal: String,
96    #[serde(default)]
97    pub large: String,
98    #[serde(default)]
99    pub png: String,
100    #[serde(default)]
101    pub art_crop: String,
102    #[serde(default)]
103    pub border_crop: String,
104}
105
106#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
107#[serde(rename_all = "snake_case")]
108#[ts(export, export_to = "deck/index.ts")]
109pub enum CardPartComponent {
110    Token,
111    ComboPiece,
112    MeldPart,
113    MeldResult,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, TS)]
117#[serde(rename_all = "camelCase")]
118#[ts(export, export_to = "deck/index.ts")]
119pub struct CardPart {
120    pub name: String,
121    pub component: CardPartComponent,
122}
123
124#[derive(Debug, Clone, Default, Serialize, TS)]
125#[serde(rename_all = "camelCase")]
126#[ts(export, export_to = "deck/index.ts")]
127pub struct DeckCard {
128    pub identity: DeckCardIdentity,
129    #[serde(flatten)]
130    #[ts(flatten)]
131    pub rules: CardRulesSummary,
132    #[serde(default)]
133    pub uris: CardImageUris,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    #[ts(optional)]
136    pub all_parts: Option<Vec<CardPart>>,
137}
138
139pub const OUTDATED_CLIENT_MESSAGE: &str =
140    "this app version is out of date — download the latest release at manabrew.app to play online";
141
142// Clients ≤ v0.5.2 serialize the identity fields flattened onto the card.
143// Their gameplay wire (game view, prompts) has drifted too, so the legacy
144// shape is detected and rejected with an actionable message rather than
145// accepted into a game the client could not parse.
146#[derive(Deserialize)]
147#[serde(rename_all = "camelCase")]
148struct DeckCardWire {
149    #[serde(default)]
150    identity: Option<DeckCardIdentity>,
151    #[serde(default)]
152    name: Option<String>,
153    #[serde(flatten)]
154    rules: CardRulesSummary,
155    #[serde(default)]
156    uris: CardImageUris,
157    #[serde(default)]
158    all_parts: Option<Vec<CardPart>>,
159}
160
161impl<'de> Deserialize<'de> for DeckCard {
162    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
163        let wire = DeckCardWire::deserialize(deserializer)?;
164        let identity = match wire.identity {
165            Some(identity) => identity,
166            None if wire.name.is_some() => {
167                return Err(serde::de::Error::custom(OUTDATED_CLIENT_MESSAGE));
168            }
169            None => return Err(serde::de::Error::missing_field("identity")),
170        };
171        Ok(DeckCard {
172            identity,
173            rules: wire.rules,
174            uris: wire.uris,
175            all_parts: wire.all_parts,
176        })
177    }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, TS)]
181#[serde(rename_all = "camelCase")]
182#[ts(export, export_to = "deck/index.ts")]
183pub struct DeckLabel {
184    pub name: String,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    #[ts(optional)]
187    pub color: Option<String>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, TS)]
191#[serde(rename_all = "camelCase")]
192#[ts(export, export_to = "deck/index.ts")]
193pub struct DeckEditorTag {
194    pub id: String,
195    pub name: String,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    #[ts(optional)]
198    pub color: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    #[ts(optional)]
201    pub icon: Option<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, TS)]
205#[serde(rename_all = "camelCase")]
206#[ts(export, export_to = "deck/index.ts")]
207pub struct DeckEditorGroup {
208    pub id: String,
209    pub name: String,
210    pub card_names: Vec<String>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    #[ts(optional)]
213    pub collapsed: Option<bool>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    #[ts(optional)]
216    pub pinned: Option<bool>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, TS)]
220#[serde(rename_all = "camelCase")]
221#[ts(export, export_to = "deck/index.ts")]
222pub enum DeckEditorGroupBy {
223    Type,
224    Cmc,
225    Color,
226    Custom,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, TS)]
230#[ts(export, export_to = "deck/index.ts")]
231pub enum DeckEditorSortBy {
232    #[serde(rename = "name")]
233    Name,
234    #[serde(rename = "mana-value")]
235    ManaValue,
236    #[serde(rename = "quantity")]
237    Quantity,
238    #[serde(rename = "owned")]
239    Owned,
240    #[serde(rename = "not-owned")]
241    NotOwned,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, TS)]
245#[serde(rename_all = "camelCase")]
246#[ts(export, export_to = "deck/index.ts")]
247pub enum DeckEditorViewMode {
248    List,
249    Visual,
250    Stack,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, TS)]
254#[serde(rename_all = "camelCase")]
255#[ts(export, export_to = "deck/index.ts")]
256pub enum DeckEditorCollectionFilter {
257    All,
258    Exact,
259    Other,
260    Partial,
261    Missing,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, TS)]
265#[serde(rename_all = "camelCase")]
266#[ts(export, export_to = "deck/index.ts")]
267pub enum DeckEditorDestination {
268    Main,
269    Side,
270    Maybe,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, TS)]
274#[serde(rename_all = "camelCase")]
275#[ts(export, export_to = "deck/index.ts")]
276pub struct DeckEditorLayout {
277    pub id: String,
278    pub name: String,
279    pub group_by: DeckEditorGroupBy,
280    pub sort_by: DeckEditorSortBy,
281    pub groups: Vec<DeckEditorGroup>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    #[ts(optional)]
284    pub filter: Option<String>,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    #[ts(optional)]
287    pub card_size: Option<u32>,
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    #[ts(optional)]
290    pub view_mode: Option<DeckEditorViewMode>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    #[ts(optional)]
293    pub collection_filter: Option<DeckEditorCollectionFilter>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    #[ts(optional)]
296    pub default_destination: Option<DeckEditorDestination>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, TS)]
300#[serde(rename_all = "camelCase")]
301#[ts(export, export_to = "deck/index.ts")]
302pub enum DeckPriceProvider {
303    Tcgplayer,
304    Cardmarket,
305    Cardhoarder,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, TS)]
309#[serde(rename_all = "camelCase")]
310#[ts(export, export_to = "deck/index.ts")]
311pub enum DeckAcquisitionStatus {
312    Ordered,
313    Proxy,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, TS)]
317#[serde(rename_all = "camelCase")]
318#[ts(export, export_to = "deck/index.ts")]
319pub struct DeckSideboardPlan {
320    pub id: String,
321    pub matchup: String,
322    pub bring_in: String,
323    pub take_out: String,
324    pub notes: String,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, TS)]
328#[serde(rename_all = "camelCase")]
329#[ts(export, export_to = "deck/index.ts")]
330pub struct DeckEditorGoals {
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    #[ts(optional)]
333    pub min_lands: Option<u32>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    #[ts(optional)]
336    pub max_lands: Option<u32>,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    #[ts(optional)]
339    pub max_missing_cards: Option<u32>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    #[ts(optional)]
342    pub max_average_mana_value: Option<f64>,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    #[ts(optional, type = "Record<string, number>")]
345    pub tag_targets: Option<BTreeMap<String, u32>>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, TS)]
349#[serde(rename_all = "camelCase")]
350#[ts(export, export_to = "deck/index.ts")]
351pub struct DeckEditorMetadata {
352    pub version: u32,
353    #[serde(default)]
354    pub tags: Vec<DeckEditorTag>,
355    #[serde(default)]
356    pub layouts: Vec<DeckEditorLayout>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    #[ts(optional)]
359    pub active_layout_id: Option<String>,
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    #[ts(optional)]
362    pub sideboard_plans: Option<Vec<DeckSideboardPlan>>,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    #[ts(optional)]
365    pub budget_usd: Option<f64>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    #[ts(optional)]
368    pub budget_amount: Option<f64>,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    #[ts(optional)]
371    pub price_provider: Option<DeckPriceProvider>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    #[ts(optional)]
374    pub goals: Option<DeckEditorGoals>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    #[ts(optional)]
377    pub dismissed_hints: Option<Vec<String>>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    #[ts(optional, type = "Record<string, \"ordered\" | \"proxy\">")]
380    pub acquisition: Option<BTreeMap<String, DeckAcquisitionStatus>>,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
384#[serde(rename_all = "camelCase")]
385#[ts(export, export_to = "deck/index.ts")]
386pub enum DeckFormat {
387    Standard,
388    Pioneer,
389    Modern,
390    Legacy,
391    Vintage,
392    Pauper,
393    Commander,
394    Brawl,
395    Oathbreaker,
396    Draft,
397    Sealed,
398}
399
400#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
401#[serde(rename_all = "camelCase")]
402#[ts(export, export_to = "deck/index.ts")]
403pub struct Deck {
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    #[ts(optional)]
406    pub version: Option<String>,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    #[ts(optional)]
409    pub id: Option<String>,
410    pub name: String,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    #[ts(optional)]
413    pub description: Option<String>,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    #[ts(optional)]
416    pub color: Option<String>,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    #[ts(optional)]
419    pub format: Option<DeckFormat>,
420    #[serde(default)]
421    pub cards: Vec<DeckCard>,
422    #[serde(default)]
423    pub sideboard: Vec<DeckCard>,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    #[ts(optional)]
426    pub attractions: Option<Vec<DeckCard>>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    #[ts(optional)]
429    pub contraptions: Option<Vec<DeckCard>>,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    #[ts(optional)]
432    pub schemes: Option<Vec<DeckCard>>,
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    #[ts(optional)]
435    pub planes: Option<Vec<DeckCard>>,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    #[ts(optional)]
438    pub commanders: Option<Vec<DeckCard>>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    #[ts(optional)]
441    pub companion: Option<DeckCard>,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    #[ts(optional)]
444    pub maybeboard: Option<Vec<DeckCard>>,
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    #[ts(optional)]
447    pub draft: Option<bool>,
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    #[ts(optional)]
450    pub labels: Option<Vec<DeckLabel>>,
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    #[ts(optional)]
453    pub custom_tags: Option<Vec<String>>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    #[ts(optional, type = "Record<string, Array<string>>")]
456    pub card_tags: Option<BTreeMap<String, Vec<String>>>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    #[ts(optional)]
459    pub editor: Option<DeckEditorMetadata>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    #[ts(optional)]
462    pub cover_card_name: Option<String>,
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    #[ts(optional)]
465    pub cover_card_face: Option<u8>,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    #[ts(optional)]
468    pub playmat: Option<String>,
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    #[ts(optional)]
471    pub playmat_settings: Option<PlaymatSettings>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    #[ts(optional, type = "Record<string, { x: number; y: number }>")]
474    pub stack_positions: Option<serde_json::Value>,
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    #[ts(optional)]
477    pub tokens: Option<Vec<DeckCard>>,
478}
479
480pub fn deck_fingerprint(deck: &Deck) -> String {
481    let mut hasher = Sha256::new();
482    update_fingerprint(&mut hasher, &deck.name);
483    update_fingerprint(
484        &mut hasher,
485        &serde_json::to_string(&deck.format).unwrap_or_default(),
486    );
487    let mut cards = Vec::new();
488    cards.extend(deck.cards.iter().map(|card| ("main", card)));
489    cards.extend(deck.sideboard.iter().map(|card| ("sideboard", card)));
490    cards.extend(
491        deck.attractions
492            .iter()
493            .flatten()
494            .map(|card| ("attraction", card)),
495    );
496    cards.extend(
497        deck.contraptions
498            .iter()
499            .flatten()
500            .map(|card| ("contraption", card)),
501    );
502    cards.extend(deck.schemes.iter().flatten().map(|card| ("scheme", card)));
503    cards.extend(deck.planes.iter().flatten().map(|card| ("plane", card)));
504    cards.extend(
505        deck.commanders
506            .iter()
507            .flatten()
508            .map(|card| ("commander", card)),
509    );
510    cards.extend(deck.companion.iter().map(|card| ("companion", card)));
511    cards.sort_by(|(left_section, left), (right_section, right)| {
512        (
513            left_section,
514            &left.identity.name,
515            &left.identity.set_code,
516            &left.identity.card_number,
517        )
518            .cmp(&(
519                right_section,
520                &right.identity.name,
521                &right.identity.set_code,
522                &right.identity.card_number,
523            ))
524    });
525    for (section, card) in cards {
526        update_fingerprint(&mut hasher, section);
527        update_fingerprint(&mut hasher, &card.identity.name);
528        update_fingerprint(&mut hasher, &card.identity.set_code);
529        update_fingerprint(&mut hasher, &card.identity.card_number);
530    }
531    let digest = hasher.finalize();
532    let mut fingerprint = String::with_capacity(digest.len() * 2);
533    for byte in digest {
534        let _ = write!(fingerprint, "{byte:02x}");
535    }
536    fingerprint
537}
538
539fn update_fingerprint(hasher: &mut Sha256, value: &str) {
540    hasher.update((value.len() as u64).to_le_bytes());
541    hasher.update(value.as_bytes());
542}