diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index e0e3206..8abc80d 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -177,7 +177,9 @@ impl Catalog { let rows = stmt .query_map(rusqlite::params_from_iter(deck_id), |r| { let type_line: String = r.get(8)?; - let partner = crate::partner::parse(&r.get::<_, String>(18)?, &type_line); + let oracle_text: String = r.get(18)?; + let partner = crate::partner::parse(&oracle_text, &type_line); + let roles = crate::roles::card_roles(&oracle_text, &type_line); let entry = DeckEntry { quantity: r.get(0)?, is_commander: r.get::<_, i64>(1)? != 0, @@ -203,6 +205,7 @@ impl Catalog { quantity_pulled: r.get(20)?, location: r.get(21)?, copy_limit: r.get(22)?, + roles, quantity_spare: 0, quantity_in_box: 0, claimed_by: Vec::new(), diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 4a7657a..1b2b16a 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -19,6 +19,7 @@ pub mod model; pub mod news; pub mod partner; pub mod printings; +pub mod roles; pub mod search; pub mod sync; pub mod synergy; diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 7568122..789b942 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -246,6 +246,10 @@ pub struct DeckEntry { /// format's rule applies, 0 for any number, else the most copies allowed. #[serde(default)] pub copy_limit: Option, + /// What the card does in a deck ("Removal", "Mill", …) — see + /// [`crate::roles::card_roles`]. Empty when nothing matches. + #[serde(default)] + pub roles: Vec, } /// A building/built deck's claim on a card's copies. diff --git a/crates/mtgc-core/src/roles.rs b/crates/mtgc-core/src/roles.rs new file mode 100644 index 0000000..0be96a3 --- /dev/null +++ b/crates/mtgc-core/src/roles.rs @@ -0,0 +1,272 @@ +//! What a card does in a deck — Ramp, Removal, Card draw… — for the deck +//! builder's "Group by role". +//! +//! Two sources, read live from oracle text so nothing needs re-ingesting: +//! a short list of utility roles every deck counts (the "goodstuff" that +//! [`crate::synergy::ARCHETYPES`] deliberately leaves out), then those same +//! archetypes (Mill, Tokens, Sacrifice…). Like the archetypes, this is +//! substring matching over rules text: a useful sort, not rules truth. + +/// Utility roles, in display order. Archetype names follow these. +pub const UTILITY_ROLES: [&str; 6] = [ + "Ramp", + "Card draw", + "Removal", + "Board wipe", + "Counterspell", + "Tutor", +]; + +/// The only role a land gets: its mana ability isn't ramp, and fetching or +/// sacrificing itself isn't a tutor or a sacrifice deck. +pub const LANDS: &str = "Lands"; + +/// Words that make a library search a land search (ramp) rather than a tutor. +const LAND_WORDS: [&str; 6] = ["land", "forest", "island", "swamp", "mountain", "plains"]; + +/// How many cards a "draw …"/"draws …" draws, for card draw. +const DRAW_COUNTS: [&str; 9] = [ + "a card", + "two cards", + "three cards", + "four cards", + "five cards", + "six cards", + "seven cards", + "x cards", + "that many cards", +]; + +/// The roles a card plays, utility roles first, then archetypes, each in +/// their list's order. Empty when nothing matches. +pub fn card_roles(oracle_text: &str, type_line: &str) -> Vec { + let front_type = type_line.split(" // ").next().unwrap_or(type_line); + if front_type.contains("Land") { + return vec![LANDS.to_string()]; + } + let text = strip_reminder_text(&oracle_text.to_lowercase()); + let has = |p: &str| text.contains(p); + let any = |ps: &[&str]| ps.iter().any(|p| text.contains(p)); + + // What each "search your library for …" is after, up to the card it names. + let searches: Vec<&str> = text + .match_indices("search your library for ") + .map(|(i, m)| { + let rest = &text[i + m.len()..]; + rest.find("card").map_or(rest, |end| &rest[..end]) + }) + .collect(); + let land_search = searches + .iter() + .any(|s| LAND_WORDS.iter().any(|w| s.contains(w))); + + let ramp = land_search + || any(&[ + "add {", + "add one mana", + "add two mana", + "add three mana", + "add x mana", + "add an amount of", + "put a land card from your hand onto the battlefield", + "play an additional land", + // Mana doublers and land untappers. + "additional {", + "additional one mana", + "adds one mana of any type that land produced", + "untap up to two lands", + "untap up to three lands", + "untap up to five lands", + "untap all lands you control", + ]); + let draw = has("draw cards equal") + || DRAW_COUNTS + .iter() + .any(|n| has(&format!("draw {n}")) || has(&format!("draws {n}"))); + let wipe = any(&[ + "destroy all ", + "destroy each ", + "exile all creatures", + "exile all nonland", + "exile all other", + "exile all artifacts", + "exile all enchantments", + "damage to each creature", + "damage to each other creature", + "all creatures get -", + "all other creatures get -", + "creatures your opponents control get -", + "return all nonland permanents", + "return all creatures to their owners' hands", + "return all attacking creatures", + ]); + let removal = any(&[ + "destroy target", + "damage to any target", + "damage to target creature", + "damage to target planeswalker", + "damage to target artifact", + "target creature gets -", + "target creature an opponent controls gets -", + " fights ", + " fight ", + "each opponent sacrifices", + "target opponent sacrifices", + "target player sacrifices", + ]) || ["exile target ", "exile up to one target "] + .iter() + .any(|v| targets_opponents_stuff(&text, v)) + || (has("to its owner's hand") + && ["return target ", "return up to one target "] + .iter() + .any(|v| targets_opponents_stuff(&text, v))); + let counter = has("counter target"); + let tutor = searches + .iter() + .any(|s| !LAND_WORDS.iter().any(|w| s.contains(w))); + + let mut roles: Vec = [ramp, draw, removal, wipe, counter, tutor] + .iter() + .zip(UTILITY_ROLES) + .filter(|(on, _)| **on) + .map(|(_, name)| name.to_string()) + .collect(); + let themes = crate::synergy::compute_themes(&text); + for (i, (name, _)) in crate::synergy::ARCHETYPES.iter().enumerate() { + if themes & (1 << i) != 0 { + roles.push(name.to_string()); + } + } + roles +} + +/// Whether some " target …" in `text` aims at something other than your +/// own creature (a blink or a save) or a card in a graveyard or hand. +fn targets_opponents_stuff(text: &str, verb: &str) -> bool { + text.match_indices(verb).any(|(i, m)| { + let rest = &text[i + m.len()..]; + let object = rest.split([',', '.', ';']).next().unwrap_or(rest); + !(object.contains("you control") + || object.contains("card") + || object.contains("spell") + || object.starts_with("player")) + }) +} + +/// Drop parenthesised reminder text, which describes a keyword rather than +/// what the card is for ("Investigate (… Draw a card.)" isn't card draw). +fn strip_reminder_text(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut depth = 0usize; + for c in text.chars() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + _ if depth == 0 => out.push(c), + _ => {} + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roles(text: &str, type_line: &str) -> Vec { + card_roles(text, type_line) + } + + #[test] + fn utility_roles() { + assert_eq!(roles("{T}: Add {C}{C}.", "Artifact"), ["Ramp"]); + assert_eq!( + roles( + "Search your library for a basic land card, put it onto the battlefield tapped, then shuffle.", + "Sorcery" + ), + ["Ramp"] + ); + assert_eq!(roles("Draw two cards.", "Sorcery"), ["Card draw"]); + assert_eq!( + roles("Target player draws X cards.", "Sorcery"), + ["Card draw"] + ); + assert_eq!( + roles("Untap up to five lands.", "Creature — Drake"), + ["Ramp"] + ); + assert_eq!( + roles( + "Return up to one target creature to its owner's hand.", + "Creature — Human" + ), + ["Removal"] + ); + assert_eq!( + roles( + "Exile target creature. Its controller gains life equal to its power.", + "Instant" + ), + ["Removal"] + ); + assert_eq!( + roles( + "Destroy all creatures. They can't be regenerated.", + "Sorcery" + ), + ["Board wipe"] + ); + assert_eq!( + roles( + "Return all attacking creatures to their owner's hand.", + "Instant" + ), + ["Board wipe"] + ); + assert_eq!(roles("Counter target spell.", "Instant"), ["Counterspell"]); + assert_eq!( + roles("Search your library for a card, put that card into your hand, then shuffle. You lose 2 life.", "Sorcery"), + ["Tutor"] + ); + } + + #[test] + fn archetypes_follow_utility_roles() { + assert_eq!( + roles("Target player mills three cards. Draw a card.", "Instant"), + ["Card draw", "Mill"] + ); + } + + #[test] + fn lands_are_only_lands() { + assert_eq!( + roles("{T}, Pay 1 life, Sacrifice Polluted Delta: Search your library for an Island or Swamp card, put it onto the battlefield, then shuffle.", "Land"), + ["Lands"] + ); + // A modal double-faced spell is its front face. + assert_eq!( + roles("Destroy target creature.", "Sorcery // Land"), + ["Removal"] + ); + } + + #[test] + fn your_own_targets_and_reminder_text_dont_count() { + // Blink and graveyard hate aren't removal. + assert!(!roles( + "Exile target creature you control, then return that card to the battlefield under its owner's control.", + "Instant" + ) + .contains(&"Removal".to_string())); + assert!(roles("Exile target card from a graveyard.", "Instant").is_empty()); + // Clue tokens draw cards, but the reminder text isn't what the card does. + assert!(!roles( + "When this creature enters, investigate. (Create a Clue token. It's an artifact with \"{2}, Sacrifice this token: Draw a card.\")", + "Creature — Human" + ) + .contains(&"Card draw".to_string())); + assert_eq!(roles("Flying", "Creature — Bird"), Vec::::new()); + } +} diff --git a/src/api.ts b/src/api.ts index 8a8292b..283794c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -66,6 +66,9 @@ export interface DeckEntry extends CardRef { * any number of cards named …"): null when the format's rule applies, 0 * for any number, else the most copies allowed. */ copy_limit: number | null; + /** What the card does in a deck ("Removal", "Mill", …), read from its + * rules text; a card can have several, or none. */ + roles: string[]; } export interface DeckHolding { diff --git a/src/components.tsx b/src/components.tsx index e335234..b91ad0f 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -1649,7 +1649,9 @@ function ClickPreviewModal({ // --- Grouping ------------------------------------------------------------- -export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors"; +/** "role" needs cards that carry `roles` — deck entries — so only the deck + * builder offers it. */ +export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors" | "role"; export const GROUP_OPTIONS: { value: GroupMode; label: string }[] = [ { value: "none", label: "No grouping" }, @@ -1657,8 +1659,19 @@ export const GROUP_OPTIONS: { value: GroupMode; label: string }[] = [ { value: "subtype", label: "Subtype" }, { value: "cmc", label: "Mana value" }, { value: "colors", label: "Color identity" }, + { value: "role", label: "Role" }, ]; +// Role groups: the utility roles in the order crates/mtgc-core/src/roles.rs +// lists them (keep in step), then the archetypes alphabetically, then lands, +// then cards that matched nothing. +const ROLE_ORDER = ["Ramp", "Card draw", "Removal", "Board wipe", "Counterspell", "Tutor"]; +const NO_ROLE = "No role"; +function roleRank(role: string): number { + const i = ROLE_ORDER.indexOf(role); + return i !== -1 ? i : role === "Lands" ? 1000 : role === NO_ROLE ? 1001 : ROLE_ORDER.length; +} + // Priority for choosing a card's primary type from its (multi-type) type line. const TYPE_PRIORITY = [ "Creature", @@ -1698,13 +1711,16 @@ function colorLabel(mask: number): string { return letters === "" ? "Colorless" : letters; } -/** Group cards by the chosen attribute, returning ordered groups. */ -export function groupCards( +/** Group cards by the chosen attribute, returning ordered groups. By role, a + * card with several roles is in each of their groups. */ +export function groupCards( cards: T[], mode: GroupMode, ): { label: string; cards: T[] }[] { if (mode === "none") return [{ label: "", cards }]; + const keysOf = (c: T): string[] => + mode === "role" ? (c.roles?.length ? c.roles : [NO_ROLE]) : [keyOf(c)]; const keyOf = (c: T) => mode === "type" ? primaryType(c.type_line) @@ -1716,14 +1732,17 @@ export function groupCards( const map = new Map(); for (const c of cards) { - const k = keyOf(c); - const arr = map.get(k); - if (arr) arr.push(c); - else map.set(k, [c]); + for (const k of keysOf(c)) { + const arr = map.get(k); + if (arr) arr.push(c); + else map.set(k, [c]); + } } const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs })); - if (mode === "type") { + if (mode === "role") { + groups.sort((a, b) => roleRank(a.label) - roleRank(b.label) || a.label.localeCompare(b.label)); + } else if (mode === "type") { groups.sort((a, b) => typeRank(a.label) - typeRank(b.label) || a.label.localeCompare(b.label)); } else if (mode === "cmc") { groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value); @@ -1737,15 +1756,18 @@ export function groupCards( export function GroupBySelect({ value, onChange, + roles, }: { value: GroupMode; onChange: (m: GroupMode) => void; + /** Offer "Role": only for cards that carry roles (deck entries). */ + roles?: boolean; }) { return (