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