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