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