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