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