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    Brawl,
423    Oathbreaker,
424    Draft,
425    Sealed,
426}
427
428#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
429#[serde(rename_all = "camelCase")]
430#[ts(export, export_to = "deck/index.ts")]
431pub struct Deck {
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    #[ts(optional)]
434    pub version: Option<String>,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    #[ts(optional)]
437    pub id: Option<String>,
438    pub name: String,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    #[ts(optional)]
441    pub description: Option<String>,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    #[ts(optional)]
444    pub color: Option<String>,
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    #[ts(optional)]
447    pub format: Option<DeckFormat>,
448    #[serde(default)]
449    pub cards: Vec<DeckCard>,
450    #[serde(default)]
451    pub sideboard: Vec<DeckCard>,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    #[ts(optional)]
454    pub attractions: Option<Vec<DeckCard>>,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    #[ts(optional)]
457    pub contraptions: Option<Vec<DeckCard>>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    #[ts(optional)]
460    pub schemes: Option<Vec<DeckCard>>,
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    #[ts(optional)]
463    pub planes: Option<Vec<DeckCard>>,
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    #[ts(optional)]
466    pub commanders: Option<Vec<DeckCard>>,
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    #[ts(optional)]
469    pub companion: Option<DeckCard>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    #[ts(optional)]
472    pub maybeboard: Option<Vec<DeckCard>>,
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    #[ts(optional)]
475    pub draft: Option<bool>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    #[ts(optional)]
478    pub labels: Option<Vec<DeckLabel>>,
479    #[serde(default, skip_serializing_if = "Option::is_none")]
480    #[ts(optional)]
481    pub custom_tags: Option<Vec<String>>,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    #[ts(optional, type = "Record<string, Array<string>>")]
484    pub card_tags: Option<BTreeMap<String, Vec<String>>>,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    #[ts(optional)]
487    pub editor: Option<DeckEditorMetadata>,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    #[ts(optional)]
490    pub cover_card_name: Option<String>,
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    #[ts(optional)]
493    pub cover_card_face: Option<u8>,
494    /// The hub fills this from `playmat_asset_id` and ignores whatever a write
495    /// supplies.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    #[ts(optional)]
498    pub playmat_url: Option<String>,
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    #[ts(optional)]
501    pub playmat_asset_id: Option<String>,
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    #[ts(optional)]
504    pub playmat_settings: Option<PlaymatSettings>,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    #[ts(optional, type = "Record<string, { x: number; y: number }>")]
507    pub stack_positions: Option<serde_json::Value>,
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    #[ts(optional)]
510    pub tokens: Option<Vec<DeckCard>>,
511}
512
513pub fn deck_fingerprint(deck: &Deck) -> String {
514    let mut hasher = Sha256::new();
515    update_fingerprint(&mut hasher, &deck.name);
516    update_fingerprint(
517        &mut hasher,
518        &serde_json::to_string(&deck.format).unwrap_or_default(),
519    );
520    let mut cards = Vec::new();
521    cards.extend(deck.cards.iter().map(|card| ("main", card)));
522    cards.extend(deck.sideboard.iter().map(|card| ("sideboard", card)));
523    cards.extend(
524        deck.attractions
525            .iter()
526            .flatten()
527            .map(|card| ("attraction", card)),
528    );
529    cards.extend(
530        deck.contraptions
531            .iter()
532            .flatten()
533            .map(|card| ("contraption", card)),
534    );
535    cards.extend(deck.schemes.iter().flatten().map(|card| ("scheme", card)));
536    cards.extend(deck.planes.iter().flatten().map(|card| ("plane", card)));
537    cards.extend(
538        deck.commanders
539            .iter()
540            .flatten()
541            .map(|card| ("commander", card)),
542    );
543    cards.extend(deck.companion.iter().map(|card| ("companion", card)));
544    cards.sort_by(|(left_section, left), (right_section, right)| {
545        (
546            left_section,
547            &left.identity.name,
548            &left.identity.set_code,
549            &left.identity.card_number,
550        )
551            .cmp(&(
552                right_section,
553                &right.identity.name,
554                &right.identity.set_code,
555                &right.identity.card_number,
556            ))
557    });
558    for (section, card) in cards {
559        update_fingerprint(&mut hasher, section);
560        update_fingerprint(&mut hasher, &card.identity.name);
561        update_fingerprint(&mut hasher, &card.identity.set_code);
562        update_fingerprint(&mut hasher, &card.identity.card_number);
563    }
564    let digest = hasher.finalize();
565    let mut fingerprint = String::with_capacity(digest.len() * 2);
566    for byte in digest {
567        let _ = write!(fingerprint, "{byte:02x}");
568    }
569    fingerprint
570}
571
572fn update_fingerprint(hasher: &mut Sha256, value: &str) {
573    hasher.update((value.len() as u64).to_le_bytes());
574    hasher.update(value.as_bytes());
575}