diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index fc8897f..f57c523 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -10,8 +10,8 @@ use crate::model::{LibraryCard, LibraryFilter}; use crate::Catalog; /// Append the card-data predicates shared by [`Catalog::search_library`] and -/// [`Catalog::search_reference`] — everything except `in_deck`/`location`, -/// which only make sense against the owned library. Caller has already +/// [`Catalog::search_reference`] — everything except `in_deck`/`location`/ +/// `spares_only`, which read the library tables. Caller has already /// written the `FROM ... WHERE ` prefix. fn push_card_filters( sql: &mut String, @@ -95,6 +95,28 @@ fn push_card_filters( )); vals.push(Value::Text(code.clone())); } + if !filter.rarities.is_empty() { + let ph = vec!["?"; filter.rarities.len()].join(","); + sql.push_str(&format!( + " AND EXISTS (SELECT 1 FROM {REF_DB}.printings p + WHERE p.oracle_id = oc.oracle_id AND p.rarity IN ({ph})" + )); + vals.extend(filter.rarities.iter().map(|r| Value::Text(r.to_lowercase()))); + if let Some(code) = filter.set_code.as_ref().filter(|c| !c.is_empty()) { + // Browsing a release for its rares means rare *there*, not rare + // in some later reprint. + sql.push_str(" AND p.set_code = ? COLLATE NOCASE"); + vals.push(Value::Text(code.clone())); + } else if has_universes_beyond { + // Arena-only printings re-rarity some paper cards (Historic + // Anthologies, Alchemy sets), which would otherwise make a paper + // common read as rare too. They still count for a card that only + // exists digitally. `digital` shipped alongside the origin flags, + // hence that capability check. + sql.push_str(" AND (p.digital = 0 OR oc.digital_only = 1)"); + } + sql.push(')'); + } if filter.colorless_identity { sql.push_str(" AND oc.color_identity = 0"); } @@ -150,6 +172,14 @@ fn push_card_filters( } } +/// `spares_only`: more copies owned than the decks' main boards use. Both +/// searches alias the library row `lc` (LEFT JOINed by the reference search, +/// hence the COALESCE). Mirrors [`Catalog::assigned_totals_by_oracle`], which +/// is what the over-assigned ⚠ is computed from. +const SPARES_PREDICATE: &str = " AND COALESCE(lc.quantity_owned, 0) > COALESCE( + (SELECT SUM(dc.quantity) FROM deck_cards dc + WHERE dc.oracle_id = oc.oracle_id AND dc.is_maybeboard = 0), 0)"; + /// Enrich raw `(oracle_id, quantity_owned, location, CardRef)` rows with deck /// context, shared by [`Catalog::search_library`], [`Catalog::search_reference`], /// and [`Catalog::list_library`]. @@ -205,6 +235,9 @@ impl Catalog { sql.push_str(" AND LOWER(lc.location) LIKE ?"); vals.push(Value::Text(format!("%{}%", loc.to_lowercase()))); } + if filter.spares_only { + sql.push_str(SPARES_PREDICATE); + } sql.push_str(" ORDER BY oc.name COLLATE NOCASE"); @@ -257,6 +290,9 @@ impl Catalog { if filter.not_owned { sql.push_str(" AND COALESCE(lc.quantity_owned, 0) = 0"); } + if filter.spares_only { + sql.push_str(SPARES_PREDICATE); + } sql.push_str(" ORDER BY "); // Same reasoning as `NameIndex::search`: a card whose name *is* the query @@ -830,6 +866,102 @@ mod tests { assert!(unowned.iter().any(|c| c.card.oracle_id == "lightning-bolt")); } + #[test] + fn spares_only_keeps_cards_with_copies_beyond_what_decks_use() { + let fx = test_support::make(); + let cat = &fx.catalog; + cat.add_owned("sol-ring", 2).unwrap(); // one in a deck → one spare + cat.add_owned("lightning-bolt", 1).unwrap(); // its only copy is in a deck + cat.add_owned("serra-angel", 1).unwrap(); // in no deck → spare + cat.add_owned("llanowar-elves", 1).unwrap(); // only on a maybeboard → spare + let deck = cat.create_deck("Deck", None).unwrap(); + cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap(); + cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap(); + cat.set_deck_card(deck.id, "llanowar-elves", 1, false, true).unwrap(); + // Unowned, but wanted by a deck: over-assigned, nothing to spare. + cat.set_deck_card(deck.id, "deathrite-shaman", 1, false, false).unwrap(); + + let f = LibraryFilter { spares_only: true, ..Default::default() }; + let expected = vec!["Llanowar Elves", "Serra Angel", "Sol Ring"]; + assert_eq!(names(&cat.search_library(&f).unwrap()), expected); + + // Card Search honors it too, rather than quietly listing everything. + let mut from_reference = names(&cat.search_reference(&f, 50).unwrap()); + from_reference.sort(); + assert_eq!(from_reference, expected); + + assert_eq!(cat.search_library(&LibraryFilter::default()).unwrap().len(), 4); + } + + #[test] + fn rarity_matches_any_printing_and_any_chosen_rarity() { + let fx = owned_fixture(); + let conn = &fx.catalog.conn; + for (id, oid, set, rarity) in [ + ("p1", "lightning-bolt", "lea", "common"), + ("p2", "lightning-bolt", "sld", "rare"), + ("p3", "serra-angel", "m19", "uncommon"), + ("p4", "sol-ring", "c21", "uncommon"), + ("p5", "llanowar-elves", "dom", "mythic"), + ] { + conn.execute( + "INSERT INTO ref.printings(scryfall_id, oracle_id, set_code, rarity) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![id, oid, set, rarity], + ) + .unwrap(); + } + let by = |rarities: &[&str], set: Option<&str>| { + names( + &fx.catalog + .search_library(&LibraryFilter { + rarities: rarities.iter().map(|r| r.to_string()).collect(), + set_code: set.map(Into::into), + ..Default::default() + }) + .unwrap(), + ) + }; + + assert_eq!(by(&["rare"], None), vec!["Lightning Bolt"], "a rare reprint counts"); + assert_eq!(by(&["common"], None), vec!["Lightning Bolt"]); + assert_eq!( + by(&["uncommon", "mythic"], None), + vec!["Llanowar Elves", "Serra Angel", "Sol Ring"], + "several rarities are alternatives, not all required" + ); + // Within a set it's that set's printing that has to match. + assert!(by(&["rare"], Some("lea")).is_empty()); + assert_eq!(by(&["common"], Some("LEA")), vec!["Lightning Bolt"]); + // No rarity chosen is no filter. + assert_eq!(by(&[], None).len(), 5); + } + + #[test] + fn rarity_ignores_arena_printings_of_paper_cards() { + let fx = owned_fixture(); + let conn = &fx.catalog.conn; + conn.execute_batch( + "INSERT INTO ref.printings(scryfall_id, oracle_id, rarity, digital) + VALUES ('paper', 'serra-angel', 'uncommon', 0), + ('arena', 'serra-angel', 'rare', 1), + ('alchemy', 'sol-ring', 'rare', 1); + UPDATE ref.oracle_cards SET digital_only = 1 WHERE oracle_id = 'sol-ring';", + ) + .unwrap(); + + let rares = names( + &fx.catalog + .search_library(&LibraryFilter { rarities: vec!["rare".into()], ..Default::default() }) + .unwrap(), + ); + assert_eq!( + rares, + vec!["Sol Ring"], + "an Arena re-rarity doesn't make a paper card rare, but a digital-only card keeps its own" + ); + } + #[test] fn search_reference_respects_limit() { let fx = test_support::make(); diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 286632a..703e287 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -29,7 +29,7 @@ pub use model::{ LibraryFilter, LibraryStats, }; pub use search::NameIndex; -pub use synergy::{CardSuggestion, SynergyReason}; +pub use synergy::{CardSuggestion, SuggestOptions, SynergyReason}; /// A handle to an open catalog: the library db with the reference db attached. /// diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 622cb7d..77ca2be 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -347,6 +347,18 @@ pub struct LibraryFilter { /// Search tab's "Not owned" quick filter. #[serde(default)] pub not_owned: bool, + /// 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, } #[cfg(test)] diff --git a/crates/mtgc-core/src/synergy.rs b/crates/mtgc-core/src/synergy.rs index 80ad00f..91d4b3b 100644 --- a/crates/mtgc-core/src/synergy.rs +++ b/crates/mtgc-core/src/synergy.rs @@ -34,7 +34,7 @@ use std::collections::{HashMap, HashSet}; use rusqlite::params; use rusqlite::types::Value; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::db::REF_DB; use crate::error::{CoreError, Result}; @@ -109,6 +109,10 @@ pub struct SynergyReason { pub struct CardSuggestion { pub card: CardRef, pub quantity_owned: i64, + /// Copies other decks' main boards already use. Never this deck's: cards + /// already in it aren't suggested. Lets the UI tell a spare copy from one + /// you'd have to pull out of another deck. + pub quantity_assigned: i64, pub score: f64, pub reasons: Vec, /// Scryfall's EDHREC popularity rank (lower = more common in Commander); @@ -134,6 +138,18 @@ const ARCHETYPE_CAP: usize = 3; /// to invert the popularity tiebreak toward the least-played matches. const UNUSUAL_RANK_THRESHOLD: i64 = 2000; +/// How [`Catalog::suggest_cards`] picks its candidate pool. +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(default)] +pub struct SuggestOptions { + /// Favor cards that fit but aren't widely played — see + /// [`UNUSUAL_RANK_THRESHOLD`]. + pub unusual: bool, + /// Only cards you own at least one copy of: build from the collection + /// rather than a shopping list. + pub owned_only: bool, +} + /// A candidate row pulled from the reference db before scoring. struct Candidate { card: CardRef, @@ -146,7 +162,8 @@ impl Catalog { /// Suggest up to `limit` cards (skipping the first `offset`, for a /// "suggest more" continuation) that synergize with `deck_id`, scored and /// explained across the four synergy families. Pulls from the whole - /// reference db (ownership is flagged via `quantity_owned`, not filtered), + /// reference db (ownership is flagged via `quantity_owned`, and filtered + /// only on request — see [`SuggestOptions`]), /// excludes cards already on any board of the deck, and hard-filters to the /// deck's (or commander's) color identity. /// @@ -157,17 +174,19 @@ impl Catalog { /// candidate pool and can shift later pages by one; acceptable for this /// feature's scope). /// - /// When `unusual` is set, candidates ranked below [`UNUSUAL_RANK_THRESHOLD`] - /// (Scryfall's own popularity signal) or entirely unranked are favored - /// instead of the usual "float the staples" behavior, surfacing - /// underappreciated cards that still fit the deck's strategy. + /// When `opts.unusual` is set, candidates ranked below + /// [`UNUSUAL_RANK_THRESHOLD`] (Scryfall's own popularity signal) or + /// entirely unranked are favored instead of the usual "float the staples" + /// behavior, surfacing underappreciated cards that still fit the deck's + /// strategy. `opts.owned_only` narrows the pool to cards you own. pub fn suggest_cards( &self, deck_id: i64, limit: usize, offset: usize, - unusual: bool, + opts: SuggestOptions, ) -> Result> { + let SuggestOptions { unusual, owned_only } = opts; // The `themes`/`edhrec_rank` columns only exist in a reference.db built // after this feature shipped; an older ingest lacks them. Detect that // and return a clear, actionable message instead of a raw "no such @@ -360,6 +379,11 @@ impl Catalog { sql.push_str(" AND (oc.edhrec_rank IS NULL OR oc.edhrec_rank >= ?)"); vals.push(Value::Integer(UNUSUAL_RANK_THRESHOLD)); } + if owned_only { + // Filtered before scoring and paging, so "Suggest more" keeps + // finding owned cards instead of pages thinning out to nothing. + sql.push_str(" AND COALESCE(lc.quantity_owned, 0) > 0"); + } let candidates: Vec = { let mut stmt = self.conn.prepare(&sql)?; @@ -392,6 +416,7 @@ impl Catalog { let cand_ids: Vec = candidates.iter().map(|c| c.card.oracle_id.clone()).collect(); let sub_map = self.batch_attr(&cand_ids, "card_subtypes", "subtype")?; let kw_map = self.batch_attr(&cand_ids, "card_keywords", "keyword")?; + let assigned = self.assigned_totals_by_oracle()?; let empty: Vec = Vec::new(); let mut out: Vec = Vec::new(); @@ -484,6 +509,7 @@ impl Catalog { continue; } out.push(CardSuggestion { + quantity_assigned: assigned.get(oid).copied().unwrap_or(0), card: cand.card, quantity_owned: cand.quantity_owned, score, @@ -691,7 +717,7 @@ mod tests { cat.set_deck_card(d.id, "deathrite-shaman", 1, false, false).unwrap(); cat.set_deck_card(d.id, "elf-c", 1, false, false).unwrap(); - let out = cat.suggest_cards(d.id, 50, 0, false).unwrap(); + let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); let ids: Vec<&str> = out.iter().map(|s| s.card.oracle_id.as_str()).collect(); // Both Elf candidates appear with a Tribal reason mentioning Elf. @@ -710,7 +736,7 @@ mod tests { // `unusual` mode excludes the widely-played candidate entirely and // keeps the obscure one. add_ref_card(cat, "elf-unranked", "Unranked Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None); - let out = cat.suggest_cards(d.id, 50, 0, true).unwrap(); + let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions { unusual: true, ..Default::default() }).unwrap(); let ids: Vec<&str> = out.iter().map(|s| s.card.oracle_id.as_str()).collect(); assert!(!ids.contains(&"elf-lord"), "rank 5 is well below the unusual threshold"); assert!(ids.contains(&"elf-rare")); @@ -738,7 +764,7 @@ mod tests { // An Elf candidate whose only possible signal is tribal. add_ref_card(cat, "lone-elf", "Lone Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None); - let out = cat.suggest_cards(d.id, 50, 0, false).unwrap(); + let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); assert!(!out.iter().any(|s| s.card.oracle_id == "lone-elf")); } @@ -756,7 +782,7 @@ mod tests { add_ref_card(cat, "landfall-payoff", "Landfall Payoff", "Creature — Elemental", "G", "", &[], &["Landfall"], 0, None); add_ref_card(cat, "just-a-flyer", "Just A Flyer", "Creature — Bird", "G", "", &[], &["Flying"], 0, None); - let out = cat.suggest_cards(d.id, 50, 0, false).unwrap(); + let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); let payoff = out.iter().find(|s| s.card.oracle_id == "landfall-payoff"); assert!(payoff.is_some(), "non-evergreen keyword should drive suggestions"); assert!(payoff.unwrap().reasons.iter().any(|r| r.category == "Mechanic" && r.detail.contains("Landfall"))); @@ -776,7 +802,7 @@ mod tests { add_ref_card(cat, "green-elf", "Green Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None); add_ref_card(cat, "black-elf", "Black Elf", "Creature — Elf", "B", "", &["Elf"], &[], 0, None); - let out = cat.suggest_cards(d.id, 50, 0, false).unwrap(); + let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); let green = out.iter().find(|s| s.card.oracle_id == "green-elf"); assert!(green.is_some(), "commander's own type should be a tribal target"); assert!(green.unwrap().reasons.iter().any(|r| r.detail.contains("commander's type"))); @@ -793,7 +819,7 @@ mod tests { cat.set_deck_card(d.id, "deathrite-shaman", 1, false, false).unwrap(); add_ref_card(cat, "swamp-dork", "Swamp Dork", "Creature — Elf", "B", "B", &["Elf"], &[], 0, None); - let out = cat.suggest_cards(d.id, 50, 0, false).unwrap(); + let out = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); let dork = out.iter().find(|s| s.card.oracle_id == "swamp-dork").unwrap(); assert!(dork.reasons.iter().any(|r| r.category == "Mana base" && r.detail.contains("{B}"))); } @@ -803,7 +829,34 @@ mod tests { let fx = test_support::make(); let cat = &fx.catalog; let d = cat.create_deck("Empty", None).unwrap(); - assert!(cat.suggest_cards(d.id, 50, 0, false).unwrap().is_empty()); + assert!(cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap().is_empty()); + } + + #[test] + fn owned_only_keeps_your_cards_and_reports_copies_other_decks_use() { + let fx = test_support::make(); + let cat = &fx.catalog; + add_ref_card(cat, "elf-c", "Elf Cleric", "Creature — Elf Cleric", "G", "", &["Elf"], &[], 0, None); + add_ref_card(cat, "owned-elf", "Owned Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None); + add_ref_card(cat, "shop-elf", "Shop Elf", "Creature — Elf", "G", "", &["Elf"], &[], 0, None); + let d = cat.create_deck("Elfball", None).unwrap(); + for oid in ["llanowar-elves", "deathrite-shaman", "elf-c"] { + cat.set_deck_card(d.id, oid, 1, false, false).unwrap(); + } + cat.add_owned("owned-elf", 2).unwrap(); + let other = cat.create_deck("Other", None).unwrap(); + cat.set_deck_card(other.id, "owned-elf", 1, false, false).unwrap(); + + let all = cat.suggest_cards(d.id, 50, 0, SuggestOptions::default()).unwrap(); + assert!(all.iter().any(|s| s.card.oracle_id == "shop-elf")); + + let owned = cat + .suggest_cards(d.id, 50, 0, SuggestOptions { owned_only: true, ..Default::default() }) + .unwrap(); + let ids: Vec<&str> = owned.iter().map(|s| s.card.oracle_id.as_str()).collect(); + assert_eq!(ids, vec!["owned-elf"]); + assert_eq!(owned[0].quantity_owned, 2); + assert_eq!(owned[0].quantity_assigned, 1, "one copy is already in the other deck"); } #[cfg(test)] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 9a0028b..38be1f6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; use mtgc_core::{ CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, - LibraryStats, + LibraryStats, SuggestOptions, }; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; @@ -211,10 +211,15 @@ pub fn deck_contents(db: Db, deck_id: i64) -> Res> { } #[tauri::command] -pub fn suggest_cards(db: Db, deck_id: i64, offset: i64, unusual: bool) -> Res> { +pub fn suggest_cards( + db: Db, + deck_id: i64, + offset: i64, + options: SuggestOptions, +) -> Res> { let s = db.lock().unwrap(); s.catalog()? - .suggest_cards(deck_id, 50, offset.max(0) as usize, unusual) + .suggest_cards(deck_id, 50, offset.max(0) as usize, options) .map_err(|e| e.to_string()) } diff --git a/src/App.tsx b/src/App.tsx index 4449ff4..c91f2d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,9 +18,10 @@ import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { CardSearch } from "./views/CardSearch"; import { DeckView } from "./views/DeckView"; +import { GameView } from "./views/GameView"; import { NewsView } from "./views/NewsView"; -type Tab = "add" | "library" | "search" | "decks" | "news" | "info"; +type Tab = "add" | "library" | "search" | "decks" | "game" | "news" | "info"; const svg = (children: ReactNode, size = 20) => ( , ), }, + { + id: "game", + label: "Game", + icon: svg( + <> + + + , + ), + }, { id: "news", label: "News", @@ -670,6 +681,10 @@ export default function App() { ) : tab === "decks" ? ( + ) : tab === "game" ? ( + // Like News, independent of the library: the game in progress + // is this device's scratch state, not synced data. + ) : tab === "news" ? ( // Library-independent, so unlike the others it isn't keyed on the // account — switching libraries doesn't change the news. diff --git a/src/CommandPalette.tsx b/src/CommandPalette.tsx index c8dbd5d..c3746bc 100644 --- a/src/CommandPalette.tsx +++ b/src/CommandPalette.tsx @@ -3,7 +3,7 @@ import { api, CardRef, Deck } from "./api"; import { DialogFrame, useClickPreview } from "./components"; // Kept in step with App.tsx's Tab union by hand — it isn't exported. -type Tab = "add" | "library" | "search" | "decks" | "news" | "info"; +type Tab = "add" | "library" | "search" | "decks" | "game" | "news" | "info"; interface PaletteItem { key: string; @@ -94,6 +94,7 @@ export function CommandPalette({ { key: "nav-library", label: "Go to Library", run: () => onNavigate("library") }, { key: "nav-search", label: "Go to Card Search", run: () => onNavigate("search") }, { key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") }, + { key: "nav-game", label: "Go to Game night (life counter, dice)", run: () => onNavigate("game") }, { key: "nav-news", label: "Go to News", run: () => onNavigate("news") }, { key: "update-card-data", label: "Update card data", run: onUpdateCardData }, { key: "sync-now", label: "Sync library now", run: onSyncNow }, diff --git a/src/SuggestionsModal.tsx b/src/SuggestionsModal.tsx index e1e9046..4df6475 100644 --- a/src/SuggestionsModal.tsx +++ b/src/SuggestionsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { api, CardSuggestion } from "./api"; -import { CardThumb, ColorPips, DialogFrame } from "./components"; +import { CardThumb, ColorPips, DialogFrame, usePersistedState } from "./components"; // Matches the backend's hardcoded per-page limit (src-tauri/src/commands.rs). // A batch shorter than this means there's nothing left to fetch. @@ -46,13 +46,17 @@ export function SuggestionsModal({ // want one of them. const [activeSubjects, setActiveSubjects] = useState>(new Set()); const [unusual, setUnusual] = useState(false); + // Remembered across decks: building from what you own is a way of working, + // not a one-off filter. + const [ownedOnly, setOwnedOnly] = usePersistedState("suggest-owned-only", false); + const options = { unusual, owned_only: ownedOnly }; useEffect(() => { let cancelled = false; setSuggestions(null); setError(null); api - .suggestCards(deckId, 0, unusual) + .suggestCards(deckId, 0, options) .then((s) => { if (cancelled) return; setSuggestions(s); @@ -64,13 +68,15 @@ export function SuggestionsModal({ return () => { cancelled = true; }; - }, [deckId, unusual]); + // `options` is rebuilt every render; its two inputs are the real deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deckId, unusual, ownedOnly]); async function loadMore() { if (!suggestions) return; setLoadingMore(true); try { - const more = await api.suggestCards(deckId, suggestions.length, unusual); + const more = await api.suggestCards(deckId, suggestions.length, options); setSuggestions((cur) => { const seen = new Set((cur ?? []).map((s) => s.card.oracle_id)); return [...(cur ?? []), ...more.filter((s) => !seen.has(s.card.oracle_id))]; @@ -162,6 +168,14 @@ export function SuggestionsModal({ > ✦ Unusual picks + {error ? (

{error}

@@ -169,7 +183,9 @@ export function SuggestionsModal({

Analyzing your deck…

) : suggestions.length === 0 ? (

- {unusual + {ownedOnly + ? "Nothing you own fits this deck yet — turn off \"From my collection\" to see every card that would." + : unusual ? "No unusual picks found — try turning off \"Unusual picks\" to see the widely-played suggestions too." : "No suggestions yet — add a few cards (and a commander, if it's a Commander deck) so there's a strategy to build on."}

@@ -250,7 +266,20 @@ export function SuggestionsModal({ {s.card.name} {s.quantity_owned > 0 ? ( - Owned ×{s.quantity_owned} + <> + Owned ×{s.quantity_owned} + {s.quantity_assigned > 0 && ( + + {s.quantity_assigned >= s.quantity_owned + ? "all in other decks" + : `${s.quantity_owned - s.quantity_assigned} spare`} + + )} + ) : ( Not owned diff --git a/src/api.ts b/src/api.ts index 94e81ce..458b694 100644 --- a/src/api.ts +++ b/src/api.ts @@ -80,6 +80,8 @@ export interface SynergyReason { export interface CardSuggestion { card: CardRef; quantity_owned: number; + /** Copies other decks' main boards already use. */ + quantity_assigned: number; score: number; reasons: SynergyReason[]; edhrec_rank: number | null; @@ -133,6 +135,11 @@ export interface LibraryFilter { /** Only cards with zero owned copies. Only honored by `searchReference` * (`searchLibrary` already returns owned cards exclusively). */ not_owned?: boolean; + /** Only cards with more copies owned than the decks' main boards use. */ + spares_only?: boolean; + /** Only cards printed at any of these rarities ("common", "uncommon", + * "rare", "mythic"). Any printing counts; with `set_code`, that set's. */ + rarities?: string[]; } export interface Status { @@ -396,8 +403,8 @@ export const api = { isMaybeboard = false, ) => invoke("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }), deckContents: (deckId: number) => invoke("deck_contents", { deckId }), - suggestCards: (deckId: number, offset = 0, unusual = false) => - invoke("suggest_cards", { deckId, offset, unusual }), + suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) => + invoke("suggest_cards", { deckId, offset, options }), cardDecks: (oracleId: string) => invoke("card_decks", { oracleId }), exportDeck: (deckId: number, path: string) => invoke("export_deck", { deckId, path }), diff --git a/src/components.tsx b/src/components.tsx index 368c90a..fb9c1c5 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -1594,6 +1594,13 @@ const ICON_BLANK_BOX = icon( , ); +/** A card with another peeking out behind it — a copy no deck is using. */ +const ICON_SPARE = icon( + <> + + + , +); /** An open package — cards you don't have a copy of yet. */ const ICON_PACKAGE = icon( <> @@ -1622,6 +1629,9 @@ export type CardCriteria = { canBeCommander: boolean; vanillaOnly: boolean; notOwned: boolean; + sparesOnly: boolean; + /** Scryfall rarity names; empty means any rarity. */ + rarities: string[]; powerMin: string; powerMax: string; toughMin: string; @@ -1638,12 +1648,21 @@ export const EMPTY_CRITERIA: CardCriteria = { canBeCommander: false, vanillaOnly: false, notOwned: false, + sparesOnly: false, + rarities: [], powerMin: "", powerMax: "", toughMin: "", toughMax: "", }; +const RARITIES = [ + { key: "common", code: "C", label: "Common" }, + { key: "uncommon", code: "U", label: "Uncommon" }, + { key: "rare", code: "R", label: "Rare" }, + { key: "mythic", code: "M", label: "Mythic rare" }, +]; + export function useCardCriteria(key: string): [CardCriteria, (patch: Partial) => void] { const [value, setValue] = usePersistedState(key, EMPTY_CRITERIA); // Merged over the defaults so a stored object written before a field existed @@ -1670,6 +1689,8 @@ export function applyCardCriteria(c: CardCriteria): Partial { can_be_commander: c.canBeCommander, vanilla_only: c.vanillaOnly, not_owned: c.notOwned, + spares_only: c.sparesOnly, + rarities: c.rarities, power_min: num(c.powerMin), power_max: num(c.powerMax), toughness_min: num(c.toughMin), @@ -1690,6 +1711,8 @@ export function criteriaActive(c: CardCriteria): boolean { c.canBeCommander || c.vanillaOnly || c.notOwned || + c.sparesOnly || + c.rarities.length > 0 || c.legalIn !== "" || [c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some( (v) => v.trim() !== "", @@ -1746,15 +1769,41 @@ export function CardCriteriaFields({ value: c, onChange, hideNotOwned, + showSpares, }: { value: CardCriteria; onChange: (patch: Partial) => void; /** Omit the "Not owned" toggle — for the Library tab, which already shows * nothing but owned cards, so the filter would just be a no-op there. */ hideNotOwned?: boolean; + /** Offer the "spare copies" toggle — a question about your collection, so + * it belongs in the Library rather than in a search of every card. */ + showSpares?: boolean; }) { + const toggleRarity = (key: string) => + onChange({ + rarities: c.rarities.includes(key) + ? c.rarities.filter((r) => r !== key) + : [...c.rarities, key], + }); return ( <> +
+ + + {RARITIES.map((r) => ( + toggleRarity(r.key)} + > + {r.code} + + ))} + +
+
onChange({ name: e.target.value })} + /> + +
+ ); +} + +function GameSetup({ + current, + onStart, + onResume, +}: { + current: Game | null; + onStart: (game: Game) => void; + onResume: () => void; +}) { + const [stored, setStored] = usePersistedState("game-setup", DEFAULT_SETUP); + const setup: Setup = { ...DEFAULT_SETUP, ...stored }; + const patch = (p: Partial) => setStored({ ...setup, ...p }); + const [decks, setDecks] = useState([]); + const [starting, setStarting] = useState(false); + const [confirmReplace, setConfirmReplace] = useState(false); + + useEffect(() => { + api.listDecks().then(setDecks).catch(() => setDecks([])); + }, []); + + async function start() { + setConfirmReplace(false); + setStarting(true); + try { + const seats = setup.seats.slice(0, setup.count); + const players: Player[] = await Promise.all( + seats.map(async (seat, i) => { + const deck = decks.find((d) => d.id === seat.deckId) ?? null; + let art: string | null = null; + if (deck?.commander_oracle_id) { + // Art is decoration; a deck that fails to load still plays. + const contents = await api.deckContents(deck.id).catch(() => []); + const commander = contents.find((e) => e.oracle_id === deck.commander_oracle_id); + art = commander ? artCrop(commander.image_uri) : null; + } + return { + id: `p${i}-${Date.now()}`, + name: seat.name.trim() || `Player ${i + 1}`, + deckName: deck?.name ?? null, + art, + color: seat.color, + life: setup.startingLife, + poison: 0, + commanderDamage: {}, + }; + }), + ); + onStart({ + players, + startingLife: setup.startingLife, + commanderDamage: setup.commanderDamage, + firstPlayerId: null, + epoch: 0, + }); + } finally { + setStarting(false); + } + } + + return ( +
+
+

Game night

+
+ + {current && ( +
+
+ Game in progress +
+ {current.players.map((p) => `${p.name} ${p.life}`).join(" · ")} +
+
+ +
+ )} + +
+
+ +
+ {[2, 3, 4, 5, 6].map((n) => ( + + ))} +
+
+ +
+ +
+ {LIFE_PRESETS.map((p) => ( + + ))} + { + const n = Math.floor(Number(e.target.value)); + if (n > 0) patch({ startingLife: n }); + }} + /> +
+
+ + + +
+ +
+ {setup.seats.slice(0, setup.count).map((seat, i) => ( + + patch({ seats: setup.seats.map((s, j) => (j === i ? { ...s, ...p } : s)) }) + } + /> + ))} +
+ {decks.length > 0 && ( +
+ A deck with a commander puts its art behind that player's life total. +
+ )} +
+ +
+ +
+
+ + {confirmReplace && ( + setConfirmReplace(false)} + /> + )} +
+ ); +} + +// --- Board ------------------------------------------------------------------- + +/** One half of a player's panel: tap for ±1, hold for ±10 (repeating). */ +function LifeHalf({ sign, onChange }: { sign: 1 | -1; onChange: (delta: number) => void }) { + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const timer = useRef | null>(null); + // Down on this button and not dragged off it: only then does lifting count. + const pressed = useRef(false); + // The hold already applied its ±10s, so lifting mustn't add a ±1 as well. + const held = useRef(false); + + const stop = () => { + pressed.current = false; + if (timer.current) clearTimeout(timer.current); + timer.current = null; + }; + useEffect(() => stop, []); + + return ( + + ); +} + +/** "−7"-style running total of recent changes, cleared after a pause — how + * you check a big swing was entered right without doing the subtraction. */ +function useRecentDelta(life: number): number { + const [delta, setDelta] = useState(0); + const prev = useRef(life); + const timer = useRef | null>(null); + useEffect(() => { + const diff = life - prev.current; + prev.current = life; + if (diff === 0) return; + setDelta((d) => d + diff); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setDelta(0), 1800); + }, [life]); + useEffect(() => () => { + if (timer.current) clearTimeout(timer.current); + }, []); + return delta; +} + +function PlayerPanel({ + player, + game, + rotated, + dispatch, + onOpenCounters, +}: { + player: Player; + game: Game; + rotated: boolean; + dispatch: (a: Action) => void; + onOpenCounters: () => void; +}) { + const delta = useRecentDelta(player.life); + const out = outReason(player, game); + const worstCommander = Math.max(0, ...Object.values(player.commanderDamage)); + const isFirst = game.firstPlayerId === player.id; + return ( +
+ dispatch({ type: "life", id: player.id, delta: d })} /> + dispatch({ type: "life", id: player.id, delta: d })} /> + +
+ {player.name} + {player.deckName && {player.deckName}} +
+ +
+ {player.life} + {delta !== 0 && ( + 0 ? "up" : "down"}`}> + {delta > 0 ? `+${delta}` : `−${-delta}`} + + )} +
+ {out &&
{out}
} + +
+ {isFirst && ( + + )} + {player.poison > 0 && ( + + ☠ {player.poison} + + )} + {game.commanderDamage && worstCommander > 0 && ( + + ⚔ {worstCommander} + + )} + +
+
+ ); +} + +function Stepper({ + value, + onChange, + big, +}: { + value: number; + onChange: (delta: number) => void; + /** Adds ±5 buttons, for life. */ + big?: boolean; +}) { + return ( + + {big && } + + {value} + + {big && } + + ); +} + +function CountersDialog({ + player, + game, + dispatch, + onClose, +}: { + player: Player; + game: Game; + dispatch: (a: Action) => void; + onClose: () => void; +}) { + const opponents = game.players.filter((p) => p.id !== player.id); + return ( + +
+
+ Life + dispatch({ type: "life", id: player.id, delta: d })} /> +
+
+ + Poison · out at {POISON_LETHAL} + + dispatch({ type: "poison", id: player.id, delta: d })} + /> +
+ {game.commanderDamage && ( + <> +
+ Commander damage taken + · out at {COMMANDER_LETHAL} from one, and it comes off life too +
+ {opponents.map((o) => ( +
+ + + {o.name} + + dispatch({ type: "commander", id: player.id, from: o.id, delta: d })} + /> +
+ ))} + + )} +
+
+ +
+
+ ); +} + +type Roll = { id: number; label: string; result: string }; + +const DICE = [4, 6, 8, 10, 12, 20]; + +function DiceDialog({ onClose }: { onClose: () => void }) { + const [rolls, setRolls] = useState([]); + const nextId = useRef(0); + const push = (label: string, result: string) => + setRolls((r) => [{ id: nextId.current++, label, result }, ...r].slice(0, 8)); + const [latest, ...earlier] = rolls; + return ( + +
+ {DICE.map((n) => ( + + ))} + +
+
+ {latest ? ( + // Keyed per roll so the pop-in replays even when the number repeats. +
+ {latest.result} + {latest.label} +
+ ) : ( + Pick a die to roll. + )} +
+ {earlier.length > 0 && ( +
+ Earlier: {earlier.map((r) => `${r.label} ${r.result}`).join(" · ")} +
+ )} +
+ +
+
+ ); +} + +/** Keep the screen on while the board is up — a phone dimming mid-game is the + * most common annoyance with a life counter. Best effort: not every webview + * implements the Wake Lock API, and it's dropped whenever the app is hidden, + * so it's re-requested on return. */ +function useScreenWakeLock() { + useEffect(() => { + type Sentinel = { release: () => Promise }; + const wakeLock = ( + navigator as Navigator & { wakeLock?: { request: (type: "screen") => Promise } } + ).wakeLock; + if (!wakeLock) return; + let sentinel: Sentinel | null = null; + let active = true; + const request = () => { + if (document.visibilityState !== "visible") return; + wakeLock + .request("screen") + .then((s) => { + if (active) sentinel = s; + else s.release().catch(() => {}); + }) + .catch(() => {}); + }; + request(); + document.addEventListener("visibilitychange", request); + return () => { + active = false; + document.removeEventListener("visibilitychange", request); + sentinel?.release().catch(() => {}); + }; + }, []); +} + +function GameBoard({ + game, + dispatch, + onLeave, +}: { + game: Game; + dispatch: (a: Action) => void; + onLeave: () => void; +}) { + // Phones lie flat in the middle of the table, so by default the far row is + // turned to face the players sitting across from it. On a desktop screen + // everyone's looking from the same side. + const [faceToFace, setFaceToFace] = usePersistedState( + "game-face-to-face", + window.matchMedia("(max-width: 720px)").matches, + ); + const [menuOpen, setMenuOpen] = useState(false); + const [diceOpen, setDiceOpen] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); + const [countersFor, setCountersFor] = useState(null); + useScreenWakeLock(); + // On a phone the board covers the whole screen, bottom nav included, so + // back is the way off it. + useBackButton(onLeave); + + const n = game.players.length; + const nearCount = Math.ceil(n / 2); + // Seats run clockwise: along the near row left to right, then back along + // the far row — which, turned around, means right to left as drawn. + const rows = faceToFace + ? [game.players.slice(nearCount).reverse(), game.players.slice(0, nearCount)] + : [game.players.slice(0, nearCount), game.players.slice(nearCount)]; + const countersPlayer = game.players.find((p) => p.id === countersFor) ?? null; + + function pickFirst() { + setMenuOpen(false); + dispatch({ type: "first", id: game.players[randomInt(n)].id }); + } + + const renderRow = (row: Player[], r: number) => ( +
+ {row.map((p) => ( + setCountersFor(p.id)} + /> + ))} +
+ ); + + return ( +
+ {renderRow(rows[0], 0)} + {/* Its own strip rather than floating over the panels: with an odd + number of seats in a row, the centre is the middle player's name. */} +
+ +
+ {renderRow(rows[1], 1)} + + {menuOpen && ( + setMenuOpen(false)}> +
+ + + + + +
+
+ )} + {diceOpen && setDiceOpen(false)} />} + {confirmReset && ( + { + setConfirmReset(false); + dispatch({ type: "reset" }); + }} + onCancel={() => setConfirmReset(false)} + /> + )} + {countersPlayer && ( + setCountersFor(null)} + /> + )} +
+ ); +} + +// --- View -------------------------------------------------------------------- + +function loadGame(): Game | null { + try { + const raw = localStorage.getItem("game-current"); + return raw ? (JSON.parse(raw) as Game) : null; + } catch { + return null; + } +} + +/** + * Game-night tools: a life counter for 2–6 players with poison and commander + * damage, dice, and a "who goes first" pick. The game lives in localStorage + * rather than the library — it's per-device scratch state, not something to + * sync — so switching tabs or the app being killed mid-game loses nothing. + */ +export function GameView() { + const [game, dispatch] = useReducer(reducer, null, loadGame); + // Remembered so coming back to the tab returns to the table, not setup. + const [seated, setSeated] = usePersistedState("game-seated", false); + + useEffect(() => { + if (game) localStorage.setItem("game-current", JSON.stringify(game)); + else localStorage.removeItem("game-current"); + }, [game]); + + if (game && seated) { + return setSeated(false)} />; + } + return ( + setSeated(true)} + onStart={(g) => { + dispatch({ type: "start", game: g }); + setSeated(true); + }} + /> + ); +} diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index 8f816d5..4c72a5f 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -320,6 +320,16 @@ export function LibraryBrowser({ )) ); + // Under the spares filter, how many copies are free — only worth saying for + // a card some deck uses, since otherwise it's every copy shown. + const spareNote = (c: LibraryCard) => + criteria.sparesOnly && + c.quantity_assigned > 0 && ( + + {c.quantity_owned - c.quantity_assigned} spare + + ); + function libTable(cards: LibraryCard[]) { return ( @@ -371,6 +381,7 @@ export function LibraryBrowser({ ⚠ {c.quantity_assigned} assigned )} + {spareNote(c)} @@ -408,7 +419,12 @@ export function LibraryBrowser({ pending={pendingRemoval.has(c.oracle_id)} /> } - footer={deckOrLocation(c)} + footer={ + <> + {deckOrLocation(c)} + {spareNote(c)} + + } /> ))} @@ -632,7 +648,7 @@ export function LibraryBrowser({ placeholder="e.g. landfall" /> - +
{deckOrLocation(c)}