Files
mtgc/crates/mtgc-core/src/model.rs

439 lines
18 KiB
Rust
Raw Normal View History

//! 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<I, S>(letters: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
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<String, String>) -> 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<String>,
/// 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,
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
/// The printing whose art this deck slot shows, if one was chosen. The
/// card's `image_uri`/`back_image_uri` already reflect it (falling back
/// to the default when the id no longer resolves).
pub preferred_scryfall_id: Option<String>,
/// The card's partner-style ability, which lets it share the command
/// zone with a second commander.
#[serde(default)]
pub partner: Option<crate::partner::Partner>,
Printing tracking for owned copies and deck art, and an outdated-card-data notice Owned copies can now be pinned to a printing + finish (nonfoil / foil / etched), activating the long-dormant library_card_copies table as groups of identical copies. library_cards.quantity_owned stays authoritative: groups are a subset of it, and the rest are "unspecified". Tracking more copies than are unspecified grows ownership to fit; lowering ownership trims the most recently added groups first. Migration 0006 adds the group quantity, a unique (card, printing, finish) index, and deck_cards.preferred_scryfall_id. A deck slot can show a chosen printing's art, both faces from that printing, falling back to the default when the id no longer resolves. It survives quantity/board changes and is kept by duplicate_deck. UI: the click preview's info tile gets a Printings section when opened from the Library or a deck (copy groups with steppers, "Add a printing…", and "Shown in this deck" with Change…/Default), backed by a printing picker grid. The picker takes Escape in the capture phase so it closes without closing the preview under it. Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and DeckCardEntry.printing. Cards now merge field by field with groups per key, so a quantity edit and a printing added on another device both survive; merged groups are clamped deterministically. A document is written as version 2 only once it holds printing data, so older builds and servers refuse it instead of dropping the data and syncing the loss. Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck and library export/import. Library export writes a line per group plus one for unspecified copies. reference.db printings gain lang and released_at for picker labels and ordering. When the installed card data lacks any column this build's reference schema has (found by diffing against the schema applied in-memory, so no list to maintain), a notice under the top bar offers Update card data (and Import card data… on phones); "Not now" is remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
}
/// One printing of a card, for the printing picker and copy labels.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PrintingRef {
pub scryfall_id: String,
/// Scryfall's set code, lowercase (e.g. "2x2").
pub set_code: String,
pub collector_number: String,
/// Language code ("en", "ja", ...); empty on an older reference.db.
pub lang: String,
/// The finishes this printing exists in: "nonfoil", "foil", "etched".
pub finishes: Vec<String>,
pub rarity: String,
pub image_uri: String,
pub back_image_uri: String,
/// Scryfall's `prices.usd` for this printing, 0 if unknown.
pub price_usd: f64,
/// Release date as YYYY-MM-DD; empty on an older reference.db.
pub released_at: String,
}
/// A group of identical owned copies pinned to one printing + finish. Groups
/// are a subset of the card's `quantity_owned`; copies not in any group are
/// simply unspecified.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LibraryCopy {
pub scryfall_id: String,
pub finish: String,
pub quantity: i64,
/// The printing's details, or `None` when the id no longer resolves in
/// the reference db (e.g. after a re-ingest dropped it).
pub printing: Option<PrintingRef>,
}
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<String>,
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<String>,
pub subtypes: Vec<String>,
pub keywords: Vec<String>,
}
/// 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<String>,
/// Cards whose `colors` exactly equal this set.
pub colors_exact: Option<ColorSet>,
/// Cards whose `colors` include at least these.
pub colors_include: Option<ColorSet>,
/// Commander compatibility: only cards whose color identity is a subset of
/// this set (playable under a commander of this identity).
pub commander_identity: Option<ColorSet>,
pub cmc_min: Option<f64>,
pub cmc_max: Option<f64>,
/// Card supertype/type, e.g. "Creature", "Instant" (matched against the
/// normalized `card_types` table).
pub types: Vec<String>,
/// Subtype, e.g. "Elf", "Equipment".
pub subtypes: Vec<String>,
/// Keyword ability, e.g. "Flying", "Deathtouch".
pub keywords: Vec<String>,
/// Restrict to cards assigned to this deck id.
pub in_deck: Option<i64>,
/// 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<String>,
/// Case-insensitive substring match against the card's oracle rules text.
pub oracle_text: Option<String>,
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
/// 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<f64>,
pub price_max: Option<f64>,
/// 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<String>,
/// 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,
Add colorless filters, and make set listings filterable Colorless There was no way to ask for colorless cards — the colour toggles are a 5-bit WUBRG mask where 0 already means "don't filter by colour", leaving nothing to express "no colours at all". That's exactly what you need to find a colorless commander's card pool. Adds a "C" pip to both colour rows, matching how ColorPips already renders colorless cards, backed by two explicit booleans rather than a sixth bit — a filter mask and a card's real colour mask staying the same shape is worth keeping. The two rows mean different things and both are useful: on the commander row C filters colour *identity*, which is the Commander rule; on the includes row it filters the card's own colours. A card printed colorless can still carry a coloured identity through mana symbols in its text, and it's the identity that bars it from a colorless commander's deck. Set listings News > Releases had its own card path and so couldn't be filtered. A new set_code filter (matched against printings.set_code, already ingested) lets the set browser run through search_reference instead, so every criterion works there identically rather than being reimplemented over a live Scryfall response. That alone would have cost the ability to browse an upcoming set, since search_reference only knows downloaded card data — and upcoming sets are half the point of the tab. So the browser picks a source per set: downloaded data where it exists, with the full filter panel; otherwise Scryfall directly, with the panel hidden and a line explaining why. A panel that silently does nothing would be worse than no panel. The source is decided by an *unfiltered* probe. Keying off whether the current criteria match anything would flip a properly indexed set to "live" the moment a filter excluded everything, dropping the panel exactly when it was in use.
2026-08-10 22:34:46 -05:00
/// 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<String>,
/// 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,
Rework library sync around a JSON document; add card filters Sync The previous design put the merge inside SQL: timestamp-stamping triggers, a tombstone table, per-device SQLite snapshots, a device id in the database, and row merges written as correlated-subquery UPDATEs. All of that schema was inherited by desktop, and the merge was very hard to verify. Replace it with a single JSON document plus a three-way merge in plain Rust. Storing the last-read remote document as the merge base makes "who changed what" and "what was deleted" derivable, which removes the tombstone table, the updated_at columns and all eight triggers. It also makes a single shared file safe: if a publish is lost the remote is unchanged, so the next merge re-detects the local edits and republishes rather than reading their absence as a deletion. That removes the need for per-device files and for device identity entirely. Schema impact drops to one column, decks.uuid. Conflicts resolve to "theirs", which is required for convergence rather than chosen for taste — preferring ours has each device re-asserting its own value forever. Every such choice is reported to the user. SQLite becomes invisible: the working copy lives in the app data dir and the user only ever names a .json file. There is always a document, defaulting to private storage, so edits are never stranded with nowhere to be saved. Pointing at a file that doesn't exist yet moves the current library there; pointing at one that does adopts it. A converter brings a pre-document library.db across. Android reaches the folder through the Storage Access Framework rather than MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all. Filters Track card origin in reference.db: Universes Beyond, digital-only and Un-set cards, plus numeric power/toughness. Each origin flag is derived as "every printing", not "any printing" — ordinary cards get reprinted into those sets, and Lightning Bolt appearing in Marvel Commander must not hide it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range filters instead of being compared as zero. Also wire up filters over data already present: price range, format legality (restricted counts as legal) and "can be a commander". All of these degrade silently against an older reference.db rather than failing every query. The shared criteria live in one component used by both filter panels, as a compact row of icon toggles. Card Search gains the Library tab's bulk selection: checkboxes in both views, select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
/// 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<f64>,
pub power_max: Option<f64>,
pub toughness_min: Option<f64>,
pub toughness_max: Option<f64>,
/// Only cards with zero owned copies. Meaningless against
/// [`Catalog::search_library`] (which already only returns owned cards),
/// so it's only honored by [`Catalog::search_reference`] — the Card
/// Search tab's "Not owned" quick filter.
#[serde(default)]
pub not_owned: bool,
Spare/rarity filters, owned-only suggestions, and a Game night tab Library filters: - "Only spare copies" quick filter (Library): cards with more copies owned than the decks' main boards use, the opposite of the ⚠ over-assignment warning. Rows show "N spare" while it's on. - Rarity toggles (C/U/R/M) in the shared criteria panel, so Library, Card Search and the set browser all get them. Any printing counts, since the library tracks cards rather than printings; within a set it's that set's printing. Arena-only printings don't count for a card that also exists on paper, so a digital re-rarity can't make a paper common read as rare. Suggestions: - "From my collection" toggle (remembered) narrows candidates to owned cards before scoring and paging, so "Suggest more" keeps finding them. - Each suggestion reports copies other decks already use, shown as "N spare" / "all in other decks". suggest_cards now takes a SuggestOptions struct instead of a bare `unusual` flag. Game tab: a life counter for 2-6 players with tap/hold ±1/±10, a running change bubble, poison and per-opponent commander damage (which also comes off life), out-of-the-game markers, dice and a coin, and a random first player. A seat's deck puts its commander's art behind the life total. On phones the board fills the screen with the far row turned to face across the table, and asks to keep the screen awake. The game is per-device scratch state in localStorage, not synced. Plain filter chips now show an accent background when active; before, "Unusual picks" only changed its text colour.
2026-10-14 22:09:39 -05:00
/// Only cards with copies to spare: more owned than the decks' main
/// boards use between them (the opposite of the over-assigned ⚠). A card
/// in no deck at all has every copy spare. Maybeboards don't count, the
/// same as for over-assignment.
#[serde(default)]
pub spares_only: bool,
/// Only cards printed at one of these rarities ("common", "uncommon",
/// "rare", "mythic"); any one of them matches. The library tracks cards
/// rather than printings, so any printing counts — narrowed to the
/// printing in [`Self::set_code`] when that's set.
#[serde(default)]
pub rarities: Vec<String>,
}
#[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);
}
}