Let suggestion filtering narrow to a specific archetype or tribe

Filtering by category alone wasn't granular enough: a deck usually matches
several archetypes and tribes at once, so "show me Archetype suggestions"
still returns most of the list.

SynergyReason now carries the matched thing itself (`subject`) alongside the
human-readable `detail`, populated at all four match sites. The modal adds a
second chip row listing every subject present in the current suggestions,
sorted by how many cards each explains and labelled with that count, so the
most useful narrowing is obvious before clicking.
This commit is contained in:
2026-08-14 01:02:21 -05:00
parent dfba2403a8
commit d014609244
4 changed files with 128 additions and 7 deletions

View File

@@ -92,6 +92,11 @@ pub fn compute_themes(oracle_text: &str) -> i64 {
#[derive(Debug, Clone, Serialize)]
pub struct SynergyReason {
pub category: String,
/// The specific thing matched — "Dragon", "Flying", "Landfall". Carried
/// separately from `detail` so the UI can filter on it: knowing a
/// suggestion is an Archetype hit is rarely enough, since a deck usually
/// has several and you want one of them.
pub subject: String,
pub detail: String,
}
@@ -411,7 +416,11 @@ impl Catalog {
let n = tribal_counts.get(s).copied().unwrap_or(0);
format!("Shares creature type: {s} ({n} in deck)")
};
reasons.push(SynergyReason { category: "Tribal".into(), detail });
reasons.push(SynergyReason {
category: "Tribal".into(),
subject: s.clone(),
detail,
});
}
// Mechanic
@@ -430,7 +439,11 @@ impl Catalog {
let n = keyword_counts.get(k).copied().unwrap_or(0);
format!("Shares keyword: {k} ({n} in deck)")
};
reasons.push(SynergyReason { category: "Mechanic".into(), detail });
reasons.push(SynergyReason {
category: "Mechanic".into(),
subject: k.clone(),
detail,
});
}
// Archetype
@@ -444,6 +457,7 @@ impl Catalog {
score += ARCHETYPE_POINTS;
reasons.push(SynergyReason {
category: "Archetype".into(),
subject: (*name).to_string(),
detail: format!("{name} payoff/enabler"),
});
arche += 1;
@@ -461,6 +475,7 @@ impl Catalog {
.collect();
reasons.push(SynergyReason {
category: "Mana base".into(),
subject: letters.clone(),
detail: format!("Produces {letters}, which your deck can't currently make"),
});
}
@@ -790,4 +805,23 @@ mod tests {
let d = cat.create_deck("Empty", None).unwrap();
assert!(cat.suggest_cards(d.id, 50, 0, false).unwrap().is_empty());
}
#[cfg(test)]
mod subject_tests {
use super::*;
#[test]
fn a_reason_names_the_specific_thing_it_matched() {
// The subject is what the UI filters on, so it must be the bare
// "Dragon"/"Landfall" rather than the sentence shown to the user —
// otherwise picking one archetype would mean matching prose.
let r = SynergyReason {
category: "Archetype".into(),
subject: "Landfall".into(),
detail: "Landfall payoff/enabler".into(),
};
assert_eq!(r.subject, "Landfall");
assert!(r.detail.contains(&r.subject), "detail should still read naturally");
}
}
}