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#[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 #[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#[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 #[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}