//! Shared domain types used across the core. use serde::{Deserialize, Serialize}; /// A card's color identity / colors, encoded as a 5-bit WUBRG mask. /// /// Bit layout (little end first): W=1, U=2, B=4, R=8, G=16. /// Storing colors this way turns commander color-identity compatibility into a /// single integer predicate (see [`ColorSet::is_subset_of`]). pub const W: u8 = 1 << 0; pub const U: u8 = 1 << 1; pub const B: u8 = 1 << 2; pub const R: u8 = 1 << 3; pub const G: u8 = 1 << 4; /// The five colors in canonical WUBRG order, paired with their single-letter code. pub const WUBRG: [(u8, char); 5] = [(W, 'W'), (U, 'U'), (B, 'B'), (R, 'R'), (G, 'G')]; /// A set of colors as a WUBRG bitmask. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct ColorSet(pub u8); impl ColorSet { pub const COLORLESS: ColorSet = ColorSet(0); /// Build from an iterator of single-letter color codes (e.g. Scryfall's /// `["B", "G"]`). Unknown letters are ignored. pub fn from_letters(letters: I) -> Self where I: IntoIterator, S: AsRef, { let mut mask = 0u8; for l in letters { match l.as_ref() { "W" | "w" => mask |= W, "U" | "u" => mask |= U, "B" | "b" => mask |= B, "R" | "r" => mask |= R, "G" | "g" => mask |= G, _ => {} } } ColorSet(mask) } /// Parse from a compact string like "BG" or "wubrg". Order-insensitive. pub fn parse(s: &str) -> Self { Self::from_letters(s.chars().map(|c| c.to_string())) } /// Canonical WUBRG string, e.g. `ColorSet(B|G)` -> "BG". Colorless -> "". pub fn to_letters(self) -> String { WUBRG .iter() .filter(|(bit, _)| self.0 & bit != 0) .map(|(_, ch)| *ch) .collect() } pub fn is_colorless(self) -> bool { self.0 == 0 } pub fn contains(self, bit: u8) -> bool { self.0 & bit != 0 } /// True if every color in `self` is also in `other`. /// /// This is the commander-legality rule: a card is playable in a commander of /// color identity `C` iff `card_identity.is_subset_of(C)`. pub fn is_subset_of(self, other: ColorSet) -> bool { self.0 & !other.0 == 0 } } /// The 8 formats this app tracks legality for, in a fixed order that defines /// their bit position within [`Legalities`] (2 bits each, in order). This /// order must never change once shipped — it's baked into every ingested /// reference.db. `.0` is the Scryfall `legalities` JSON key; `.1` is the /// display label used throughout the app (matches DeckView's /// `FORMATS`/`COMMANDER_FORMATS`). pub const LEGALITY_FORMATS: [(&str, &str); 8] = [ ("standard", "Standard"), ("pioneer", "Pioneer"), ("modern", "Modern"), ("legacy", "Legacy"), ("vintage", "Vintage"), ("pauper", "Pauper"), ("commander", "Commander"), ("brawl", "Brawl"), ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LegalStatus { NotLegal, Legal, Banned, Restricted, } /// Per-format legality, encoded as 2 bits/format x 8 formats packed into one /// i64 (see [`LEGALITY_FORMATS`] for bit-position order). Mirrors /// `ColorSet`'s bitmask-newtype pattern but with 2-bit fields instead of /// 1-bit flags, since each format has 4 possible statuses (not-legal / legal /// / banned / restricted), not just a binary flag. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct Legalities(pub i64); impl Legalities { /// Build from Scryfall's `legalities` object (format-slug -> status-string /// map). Any format not present in `map`, or present with an unrecognized /// status string, decodes as `NotLegal`. pub fn from_scryfall(map: &std::collections::HashMap) -> Self { let mut bits = 0i64; for (i, (key, _label)) in LEGALITY_FORMATS.iter().enumerate() { let status: i64 = match map.get(*key).map(String::as_str) { Some("legal") => 1, Some("banned") => 2, Some("restricted") => 3, _ => 0, // "not_legal", or key missing entirely }; bits |= status << (i * 2); } Legalities(bits) } /// Decode the status for a given format's display label (e.g. /// "Commander", "Modern" — matches DeckView's `FORMATS` strings). /// Unknown labels decode as `NotLegal`. pub fn status_for(self, format_label: &str) -> LegalStatus { let Some(i) = LEGALITY_FORMATS.iter().position(|(_, label)| *label == format_label) else { return LegalStatus::NotLegal; }; match (self.0 >> (i * 2)) & 0b11 { 1 => LegalStatus::Legal, 2 => LegalStatus::Banned, 3 => LegalStatus::Restricted, _ => LegalStatus::NotLegal, } } } /// A card as returned by search / library queries. Oracle-level (one per /// `oracle_id`), enriched with the caller's ownership + deck context where relevant. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CardRef { pub oracle_id: String, pub name: String, pub mana_value: f64, pub colors: ColorSet, pub color_identity: ColorSet, pub type_line: String, /// Whether this card is printed/displayed sideways (Scryfall layout /// "battle" or "planar" — Battles, Planes, and Phenomena). Its stored /// image is itself portrait-shaped with the content rotated, so viewers /// must rotate it 90° to display right-side up. pub horizontal: bool, /// URL of a card image (Scryfall "normal" size) for one of its printings, /// or empty if none is available. pub image_uri: String, /// URL of the *back face* image for the same printing (transform / modal /// double-faced / reversible cards), or empty for single-faced cards. pub back_image_uri: String, /// WUBRG bitmask of colors this card can *produce* (lands, mana rocks, /// dorks, etc.) — independent of `colors`/`color_identity`, which /// describe what color the card *is*, not what it taps for. pub produced_mana: ColorSet, /// Whether this card can produce colorless mana (Scryfall's /// `produced_mana` including "C"), tracked separately since colorless /// isn't a WUBRG bit. pub produces_colorless: bool, /// USD market price (Scryfall's `prices.usd`) for the same printing that /// supplied `image_uri`, or 0 if unknown. pub price_usd: f64, /// Per-format legality (banned/restricted/legal). See /// [`LEGALITY_FORMATS`] for the bit-position order and /// [`Legalities::status_for`] to decode a specific format. pub legalities: Legalities, } /// A library row: a card the user owns, with quantity and its deck memberships. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LibraryCard { #[serde(flatten)] pub card: CardRef, pub quantity_owned: i64, /// Names of decks this card is currently assigned to. pub decks: Vec, /// Total copies assigned across all decks. If it exceeds `quantity_owned`, /// the physical copies are over-committed. pub quantity_assigned: i64, /// Free-text physical storage location (e.g. "Box 1"). Meaningful mainly for /// cards not currently in a deck. pub location: String, } /// A card in a deck, whether or not the user owns it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeckEntry { #[serde(flatten)] pub card: CardRef, /// Copies of this card in the deck. pub quantity: i64, pub is_commander: bool, /// On the maybeboard: tracked against the deck for consideration, but /// excluded from deck analysis stats and library assignment totals — /// it isn't actually part of the deck. pub is_maybeboard: bool, /// Copies owned in the library (0 if the card isn't in the library). pub quantity_owned: i64, } impl LibraryCard { pub fn is_over_assigned(&self) -> bool { self.quantity_assigned > self.quantity_owned } } /// A deck. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Deck { pub id: i64, pub name: String, /// Free-text format, e.g. "Commander", "Modern" (empty if unset). pub format: String, /// The commander card's oracle id (meaningful for Commander/Brawl formats). pub commander_oracle_id: Option, pub notes: String, } /// Aggregate stats about the owned library, for a dashboard-style summary. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct LibraryStats { /// Distinct oracle cards owned (quantity_owned > 0). pub unique_cards: i64, /// Total physical copies owned, summed across all cards. pub total_copies: i64, pub deck_count: i64, /// Copies owned in each color, WUBRG order. A multicolor card's copies /// count toward every color it contains, so these can sum to more than /// `total_copies`. pub color_counts: [i64; 5], /// Copies owned with no color (colors == 0). pub colorless_count: i64, } /// Distinct type/subtype/keyword values found among *owned* cards, sorted. /// Powers autocomplete for the library filter panel — e.g. no point /// suggesting "Phyrexian" as a subtype if you don't own any. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct LibraryFacets { pub types: Vec, pub subtypes: Vec, pub keywords: Vec, } /// Criteria for searching the owned library. All fields are optional; an empty /// filter matches every owned card. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct LibraryFilter { /// Fuzzy-ish name text (applied as a case-insensitive substring here; the /// fast fuzzy path is [`crate::search`]). pub text: Option, /// Cards whose `colors` exactly equal this set. pub colors_exact: Option, /// Cards whose `colors` include at least these. pub colors_include: Option, /// Commander compatibility: only cards whose color identity is a subset of /// this set (playable under a commander of this identity). pub commander_identity: Option, pub cmc_min: Option, pub cmc_max: Option, /// Card supertype/type, e.g. "Creature", "Instant" (matched against the /// normalized `card_types` table). pub types: Vec, /// Subtype, e.g. "Elf", "Equipment". pub subtypes: Vec, /// Keyword ability, e.g. "Flying", "Deathtouch". pub keywords: Vec, /// Restrict to cards assigned to this deck id. pub in_deck: Option, /// Case-insensitive substring match against the card's stored location /// (e.g. "Box 1"). Cards with no location set never match a non-empty value. pub location: Option, /// Case-insensitive substring match against the card's oracle rules text. pub oracle_text: Option, /// Hide cards that exist only as Universes Beyond (Lord of the Rings, /// Warhammer 40,000, Marvel, ...). Ordinary cards reprinted into a UB set /// are kept — see `oracle_cards.universes_beyond`. /// /// Silently inert against a `reference.db` built before that column /// existed, the same way the other reference-schema additions degrade. #[serde(default)] pub exclude_universes_beyond: bool, /// Hide cards that exist only on Arena/MTGO (Alchemy rebalances and the /// like) and so can never be in a physical collection. #[serde(default)] pub exclude_digital_only: bool, /// Hide cards that exist only in "funny" sets (Unglued, Unhinged, Unstable, /// Unfinity). #[serde(default)] pub exclude_funny: bool, /// Cheapest known printing, in USD. Cards with no known price count as 0, /// matching how they're displayed. pub price_min: Option, pub price_max: Option, /// Only cards legal in this format (a [`LEGALITY_FORMATS`] slug, e.g. /// "commander"). Excludes banned and not-legal; restricted still counts as /// legal, since a restricted card is playable as a single copy. pub legal_in: Option, /// Only cards that can head a Commander deck. #[serde(default)] pub can_be_commander: bool, /// Only "vanilla" creatures — a creature with an empty rules box. Includes /// Adventure and Prepared cards whose creature half has no abilities, which /// is why this reads a precomputed column instead of testing `oracle_text`: /// that column joins every face, so the attached spell's text is in it. #[serde(default)] pub vanilla_only: bool, /// Only cards with an empty colour identity — what a colourless commander /// can actually play. The colour bitmasks can't express this: 0 there means /// "no filter", not "no colours". #[serde(default)] pub colorless_identity: bool, /// Only cards printed in this set (matched against `printings.set_code`). /// Lets the release browser reuse the whole criteria pipeline instead of /// reimplementing it over a live Scryfall response. pub set_code: Option, /// Only cards that are themselves colourless (`colors` empty). Distinct /// from `colorless_identity`: a card can be colourless yet carry a coloured /// identity through mana symbols in its rules text. #[serde(default)] pub colorless_colors: bool, /// Printed power/toughness range. Cards whose printed value isn't a plain /// number ("*", "1+*") are excluded from these, since they can't be /// compared — see `oracle_cards.power`. pub power_min: Option, pub power_max: Option, pub toughness_min: Option, pub toughness_max: Option, } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; #[test] fn legalities_round_trip_all_statuses() { let mut map = HashMap::new(); map.insert("standard".to_string(), "legal".to_string()); map.insert("modern".to_string(), "banned".to_string()); map.insert("vintage".to_string(), "restricted".to_string()); // "pauper" deliberately absent let legalities = Legalities::from_scryfall(&map); assert_eq!(legalities.status_for("Standard"), LegalStatus::Legal); assert_eq!(legalities.status_for("Modern"), LegalStatus::Banned); assert_eq!(legalities.status_for("Vintage"), LegalStatus::Restricted); assert_eq!(legalities.status_for("Pauper"), LegalStatus::NotLegal); } #[test] fn legalities_unknown_format_label_is_not_legal() { let legalities = Legalities::from_scryfall(&HashMap::new()); assert_eq!(legalities.status_for("Nonsense"), LegalStatus::NotLegal); } #[test] fn legalities_default_is_all_not_legal() { let legalities = Legalities::default(); assert_eq!(legalities.status_for("Commander"), LegalStatus::NotLegal); assert_eq!(legalities.status_for("Brawl"), LegalStatus::NotLegal); } }