Deck builder: group cards by role
A Role option in the deck builder's Group by sorts cards into Ramp, Card draw, Removal, Board wipe, Counterspell and Tutor, then the synergy archetypes Suggest Cards already detects (Mill, Tokens, …), then Lands and No role. A card with several roles shows in each group. Roles come from the new roles module, which reads each deck card's oracle text as the deck loads, so no re-ingest is needed. Reminder text is ignored, and exiling or bouncing your own things isn't removal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<i64>,
|
||||
/// What the card does in a deck ("Removal", "Mill", …) — see
|
||||
/// [`crate::roles::card_roles`]. Empty when nothing matches.
|
||||
#[serde(default)]
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
/// A building/built deck's claim on a card's copies.
|
||||
|
||||
272
crates/mtgc-core/src/roles.rs
Normal file
272
crates/mtgc-core/src/roles.rs
Normal file
@@ -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<String> {
|
||||
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<String> = [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 "<verb> 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<String> {
|
||||
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::<String>::new());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T extends CardRef>(
|
||||
/** 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<T extends CardRef & { roles?: string[] }>(
|
||||
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<T extends CardRef>(
|
||||
|
||||
const map = new Map<string, T[]>();
|
||||
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<T extends CardRef>(
|
||||
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 (
|
||||
<label className="group-by">
|
||||
<span className="muted">Group by</span>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value as GroupMode)}>
|
||||
{GROUP_OPTIONS.map((o) => (
|
||||
{GROUP_OPTIONS.filter((o) => roles || o.value !== "role").map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
|
||||
@@ -1421,7 +1421,7 @@ function DeckBuilder({
|
||||
</span>
|
||||
</label>
|
||||
<span className="spacer" />
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||
<GroupBySelect value={groupBy} onChange={setGroupBy} roles />
|
||||
<SortBySelect
|
||||
value={sortBy}
|
||||
dir={sortDir}
|
||||
|
||||
Reference in New Issue
Block a user