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.
This commit is contained in:
@@ -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 <ownership predicate>` 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();
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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<SynergyReason>,
|
||||
/// 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<Vec<CardSuggestion>> {
|
||||
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<Candidate> = {
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
@@ -392,6 +416,7 @@ impl Catalog {
|
||||
let cand_ids: Vec<String> = 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<String> = Vec::new();
|
||||
|
||||
let mut out: Vec<CardSuggestion> = 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)]
|
||||
|
||||
@@ -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<Vec<DeckEntry>> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn suggest_cards(db: Db, deck_id: i64, offset: i64, unusual: bool) -> Res<Vec<CardSuggestion>> {
|
||||
pub fn suggest_cards(
|
||||
db: Db,
|
||||
deck_id: i64,
|
||||
offset: i64,
|
||||
options: SuggestOptions,
|
||||
) -> Res<Vec<CardSuggestion>> {
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
17
src/App.tsx
17
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) => (
|
||||
<svg
|
||||
@@ -111,6 +112,16 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "game",
|
||||
label: "Game",
|
||||
icon: svg(
|
||||
<>
|
||||
<path d="M12 2.5l8.25 4.75v9.5L12 21.5l-8.25-4.75v-9.5z" />
|
||||
<path d="M12 7.5l4 6.5H8z" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "news",
|
||||
label: "News",
|
||||
@@ -670,6 +681,10 @@ export default function App() {
|
||||
<CardSearch key={status?.library_label} onOfferUndo={offerUndo} />
|
||||
) : tab === "decks" ? (
|
||||
<DeckView key={status?.library_label} refreshSignal={deckRefreshSignal} />
|
||||
) : tab === "game" ? (
|
||||
// Like News, independent of the library: the game in progress
|
||||
// is this device's scratch state, not synced data.
|
||||
<GameView />
|
||||
) : tab === "news" ? (
|
||||
// Library-independent, so unlike the others it isn't keyed on the
|
||||
// account — switching libraries doesn't change the news.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<Set<string>>(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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-chip ${ownedOnly ? "active" : ""}`}
|
||||
onClick={() => setOwnedOnly(!ownedOnly)}
|
||||
title="Only suggest cards you already own at least one copy of."
|
||||
>
|
||||
From my collection
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="hint">{error}</p>
|
||||
@@ -169,7 +183,9 @@ export function SuggestionsModal({
|
||||
<p className="hint">Analyzing your deck…</p>
|
||||
) : suggestions.length === 0 ? (
|
||||
<p className="hint">
|
||||
{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."}
|
||||
</p>
|
||||
@@ -250,7 +266,20 @@ export function SuggestionsModal({
|
||||
<ColorPips mask={s.card.color_identity} />
|
||||
<span className="card-name">{s.card.name}</span>
|
||||
{s.quantity_owned > 0 ? (
|
||||
<span className="badge">Owned ×{s.quantity_owned}</span>
|
||||
<>
|
||||
<span className="badge">Owned ×{s.quantity_owned}</span>
|
||||
{s.quantity_assigned > 0 && (
|
||||
<span
|
||||
className="muted"
|
||||
style={{ fontSize: 12 }}
|
||||
title={`${s.quantity_assigned} in your other decks`}
|
||||
>
|
||||
{s.quantity_assigned >= s.quantity_owned
|
||||
? "all in other decks"
|
||||
: `${s.quantity_owned - s.quantity_assigned} spare`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Not owned
|
||||
|
||||
11
src/api.ts
11
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<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
|
||||
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
|
||||
suggestCards: (deckId: number, offset = 0, unusual = false) =>
|
||||
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, unusual }),
|
||||
suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) =>
|
||||
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),
|
||||
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
||||
|
||||
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
|
||||
|
||||
@@ -1594,6 +1594,13 @@ const ICON_BLANK_BOX = icon(
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
</>,
|
||||
);
|
||||
/** A card with another peeking out behind it — a copy no deck is using. */
|
||||
const ICON_SPARE = icon(
|
||||
<>
|
||||
<rect x="8" y="3" width="12" height="16" rx="2" />
|
||||
<path d="M4 7v12a2 2 0 0 0 2 2h9" />
|
||||
</>,
|
||||
);
|
||||
/** 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<CardCriteria>) => void] {
|
||||
const [value, setValue] = usePersistedState<CardCriteria>(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<LibraryFilter> {
|
||||
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<CardCriteria>) => 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 (
|
||||
<>
|
||||
<div className="field">
|
||||
<label>Rarity</label>
|
||||
<span className="color-toggles">
|
||||
{RARITIES.map((r) => (
|
||||
<span
|
||||
key={r.key}
|
||||
title={`${r.label} — printed at this rarity in any set (or in the set being browsed)`}
|
||||
className={`pip rarity-pip ${r.key} ${c.rarities.includes(r.key) ? "on" : ""}`}
|
||||
onClick={() => toggleRarity(r.key)}
|
||||
>
|
||||
{r.code}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Legal in</label>
|
||||
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
|
||||
@@ -1835,6 +1884,15 @@ export function CardCriteriaFields({
|
||||
on={c.vanillaOnly}
|
||||
onToggle={() => onChange({ vanillaOnly: !c.vanillaOnly })}
|
||||
/>
|
||||
{showSpares && (
|
||||
<IconToggle
|
||||
icon={ICON_SPARE}
|
||||
label="Only spare copies"
|
||||
hint="Cards you own more copies of than your decks use — free for a trade or a new deck. Maybeboards don't count."
|
||||
on={c.sparesOnly}
|
||||
onToggle={() => onChange({ sparesOnly: !c.sparesOnly })}
|
||||
/>
|
||||
)}
|
||||
{!hideNotOwned && (
|
||||
<IconToggle
|
||||
icon={ICON_PACKAGE}
|
||||
|
||||
380
src/styles.css
380
src/styles.css
@@ -1413,6 +1413,9 @@ li:focus-within .row-actions {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.filter-chip.active {
|
||||
/* Plain on/off chips ("Unusual picks"); the category chips below swap in
|
||||
their own colour. */
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -1481,6 +1484,13 @@ li:focus-within .row-actions {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px var(--accent-hover);
|
||||
}
|
||||
/* Rarity toggles borrow the colour pips' on/off treatment, tinted like the
|
||||
expansion-symbol colours printed on the cards themselves. */
|
||||
/* Common's black symbol needs a ring to show at all on the dark panel. */
|
||||
.pip.rarity-pip.common { background: #2b2a2e; color: #e6e1d8; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4); }
|
||||
.pip.rarity-pip.uncommon { background: linear-gradient(135deg, #c9d3dc, #7f8c98); color: #1d232a; }
|
||||
.pip.rarity-pip.rare { background: linear-gradient(135deg, #e6c66e, #a77f2a); color: #2b2210; }
|
||||
.pip.rarity-pip.mythic { background: linear-gradient(135deg, #f08a3c, #c2401f); color: #fff; }
|
||||
|
||||
/* ============================================================
|
||||
Tables
|
||||
@@ -2919,3 +2929,373 @@ tbody tr:hover {
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Game night (life counter)
|
||||
============================================================ */
|
||||
|
||||
.game-setup {
|
||||
max-width: 720px;
|
||||
}
|
||||
.game-resume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.game-setup-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.game-seats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.game-seat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.game-seat-color {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.game-seat-name,
|
||||
.game-seat-deck {
|
||||
flex: 1 1 120px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-board {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.game-row {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.game-player {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px solid var(--player-color);
|
||||
background-color: var(--bg-panel);
|
||||
background-color: color-mix(in srgb, var(--player-color) 28%, var(--bg-panel));
|
||||
background-size: cover;
|
||||
background-position: center 30%;
|
||||
color: var(--text-strong);
|
||||
/* Lets the life total size itself to the panel (cq units) — a 6-player
|
||||
phone layout has panels a third the size of a 2-player one. */
|
||||
container-type: size;
|
||||
}
|
||||
/* Darkens commander art enough for the numbers to read over it. */
|
||||
.game-player::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.55));
|
||||
pointer-events: none;
|
||||
}
|
||||
.game-player.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.game-player.first {
|
||||
box-shadow: 0 0 0 3px var(--warn), 0 0 24px rgba(224, 163, 74, 0.45);
|
||||
}
|
||||
/* Dims the panel rather than filtering it, which would also grey out the
|
||||
"out" reason and the first-player highlight that need to stay legible. */
|
||||
.game-player.out::before {
|
||||
background: rgba(12, 11, 10, 0.78);
|
||||
}
|
||||
.game-player.out .game-life {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.game-life-half {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.game-life-half:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.game-life-half:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.game-life-half.minus {
|
||||
left: 0;
|
||||
justify-content: flex-start;
|
||||
padding-left: 8%;
|
||||
}
|
||||
.game-life-half.plus {
|
||||
right: 0;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8%;
|
||||
}
|
||||
|
||||
.game-player-head {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.game-player-name {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-player-deck {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.game-life {
|
||||
position: relative;
|
||||
font-size: 64px;
|
||||
font-size: min(34cqw, 44cqh);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
.game-delta {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-size: 16px;
|
||||
font-size: max(13px, min(8cqw, 11cqh));
|
||||
font-weight: 700;
|
||||
text-shadow: none;
|
||||
}
|
||||
.game-delta.up {
|
||||
color: #86e0a4;
|
||||
}
|
||||
.game-delta.down {
|
||||
color: #ff9a86;
|
||||
}
|
||||
.game-out-label {
|
||||
position: relative;
|
||||
margin-top: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
.game-player-foot {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
/* Taps between the badges still reach the −/+ halves underneath. */
|
||||
pointer-events: none;
|
||||
}
|
||||
.game-player-foot > button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.game-badge {
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: var(--text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-badge-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.game-badge.first {
|
||||
border-color: transparent;
|
||||
background: var(--warn);
|
||||
color: #2b1d05;
|
||||
}
|
||||
|
||||
.game-strip {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.game-hub {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.game-hub:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.game-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.game-menu button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.game-counters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.game-counter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.game-counter-heading {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.game-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.game-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.game-stepper button {
|
||||
min-width: 34px;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.game-stepper-value {
|
||||
min-width: 34px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-dice-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.game-dice-buttons button {
|
||||
justify-content: center;
|
||||
}
|
||||
.game-dice-result {
|
||||
min-height: 96px;
|
||||
margin: 12px 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.game-dice-latest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
animation: game-dice-pop 0.22s ease-out;
|
||||
}
|
||||
.game-dice-value {
|
||||
font-size: 52px;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.game-dice-history {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
@keyframes game-dice-pop {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* On a phone the table takes the whole screen — topbar and bottom nav
|
||||
included; the hub menu's "Leave the table" (or back) is the way out. */
|
||||
@media (max-width: 720px) {
|
||||
.game-board {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: calc(env(safe-area-inset-top) + 6px) 6px calc(env(safe-area-inset-bottom) + 6px);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.game-life-half {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
861
src/views/GameView.tsx
Normal file
861
src/views/GameView.tsx
Normal file
@@ -0,0 +1,861 @@
|
||||
import { CSSProperties, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { api, Deck } from "../api";
|
||||
import { ConfirmDialog, DialogFrame, usePersistedState } from "../components";
|
||||
import { useBackButton } from "../backButton";
|
||||
|
||||
// --- Game state ------------------------------------------------------------
|
||||
|
||||
type Player = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Subtitle under the name; the deck being played, if one was picked. */
|
||||
deckName: string | null;
|
||||
/** Art crop of that deck's commander, for the panel background. */
|
||||
art: string | null;
|
||||
color: string;
|
||||
life: number;
|
||||
poison: number;
|
||||
/** Commander damage taken, keyed by the id of the player whose commander
|
||||
* dealt it — 21 from any single one is lethal, so they can't be summed. */
|
||||
commanderDamage: Record<string, number>;
|
||||
};
|
||||
|
||||
type Game = {
|
||||
players: Player[];
|
||||
startingLife: number;
|
||||
commanderDamage: boolean;
|
||||
/** Who won the "who goes first?" roll, until it's dismissed. */
|
||||
firstPlayerId: string | null;
|
||||
/** Bumped by a reset, so panels drop their running "−3" bubbles rather
|
||||
* than showing the whole jump back to starting life as one change. */
|
||||
epoch: number;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "start"; game: Game }
|
||||
| { type: "life"; id: string; delta: number }
|
||||
| { type: "poison"; id: string; delta: number }
|
||||
| { type: "commander"; id: string; from: string; delta: number }
|
||||
| { type: "first"; id: string | null }
|
||||
| { type: "reset" }
|
||||
| { type: "end" };
|
||||
|
||||
const POISON_LETHAL = 10;
|
||||
const COMMANDER_LETHAL = 21;
|
||||
|
||||
function updatePlayer(game: Game, id: string, f: (p: Player) => Player): Game {
|
||||
return { ...game, players: game.players.map((p) => (p.id === id ? f(p) : p)) };
|
||||
}
|
||||
|
||||
function reducer(game: Game | null, action: Action): Game | null {
|
||||
if (action.type === "start") return action.game;
|
||||
if (action.type === "end" || !game) return null;
|
||||
switch (action.type) {
|
||||
case "life":
|
||||
return updatePlayer(game, action.id, (p) => ({ ...p, life: p.life + action.delta }));
|
||||
case "poison":
|
||||
return updatePlayer(game, action.id, (p) => ({
|
||||
...p,
|
||||
poison: Math.max(0, p.poison + action.delta),
|
||||
}));
|
||||
case "commander":
|
||||
// Commander damage is still damage: it comes off life as well, which is
|
||||
// what you'd otherwise have to remember to do by hand every time.
|
||||
return updatePlayer(game, action.id, (p) => {
|
||||
const before = p.commanderDamage[action.from] ?? 0;
|
||||
const after = Math.max(0, before + action.delta);
|
||||
return {
|
||||
...p,
|
||||
life: p.life - (after - before),
|
||||
commanderDamage: { ...p.commanderDamage, [action.from]: after },
|
||||
};
|
||||
});
|
||||
case "first":
|
||||
return { ...game, firstPlayerId: action.id };
|
||||
case "reset":
|
||||
return {
|
||||
...game,
|
||||
epoch: game.epoch + 1,
|
||||
firstPlayerId: null,
|
||||
players: game.players.map((p) => ({
|
||||
...p,
|
||||
life: game.startingLife,
|
||||
poison: 0,
|
||||
commanderDamage: {},
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Why a player is out, or null while they're still in. Shown, not enforced:
|
||||
* life totals get corrected after mistakes, so nothing is locked. */
|
||||
function outReason(p: Player, game: Game): string | null {
|
||||
if (p.life <= 0) return "Out of life";
|
||||
if (p.poison >= POISON_LETHAL) return "Poisoned";
|
||||
if (game.commanderDamage && Object.values(p.commanderDamage).some((d) => d >= COMMANDER_LETHAL)) {
|
||||
return "Commander damage";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Uniform in [0, n), without the modulo bias of `% n` — these are dice. */
|
||||
function randomInt(n: number): number {
|
||||
const buf = new Uint32Array(1);
|
||||
const limit = Math.floor(0x100000000 / n) * n;
|
||||
do {
|
||||
crypto.getRandomValues(buf);
|
||||
} while (buf[0] >= limit);
|
||||
return buf[0] % n;
|
||||
}
|
||||
|
||||
/** Scryfall serves every crop size at the same path under a different
|
||||
* segment, so the stored "normal" card image gives us the art alone. */
|
||||
function artCrop(imageUri: string): string | null {
|
||||
if (!imageUri) return null;
|
||||
return imageUri.replace(/(cards\.scryfall\.io\/)(normal|large|small|png)\//, "$1art_crop/");
|
||||
}
|
||||
|
||||
const PLAYER_COLORS = ["#c8553d", "#3a8fd0", "#479b63", "#8a63c9", "#e0a34a", "#3fa9a5"];
|
||||
|
||||
const LIFE_PRESETS = [
|
||||
{ life: 20, label: "Constructed", commander: false },
|
||||
{ life: 25, label: "Brawl", commander: false },
|
||||
{ life: 40, label: "Commander", commander: true },
|
||||
];
|
||||
|
||||
// --- Setup -------------------------------------------------------------------
|
||||
|
||||
type Seat = { name: string; color: string; deckId: number | null };
|
||||
|
||||
type Setup = {
|
||||
count: number;
|
||||
startingLife: number;
|
||||
commanderDamage: boolean;
|
||||
/** Always six long, so names and decks survive turning the count down and
|
||||
* back up again. */
|
||||
seats: Seat[];
|
||||
};
|
||||
|
||||
const DEFAULT_SETUP: Setup = {
|
||||
count: 4,
|
||||
startingLife: 40,
|
||||
commanderDamage: true,
|
||||
seats: PLAYER_COLORS.map((color) => ({ name: "", color, deckId: null })),
|
||||
};
|
||||
|
||||
function SeatRow({
|
||||
index,
|
||||
seat,
|
||||
decks,
|
||||
onChange,
|
||||
}: {
|
||||
index: number;
|
||||
seat: Seat;
|
||||
decks: Deck[];
|
||||
onChange: (patch: Partial<Seat>) => void;
|
||||
}) {
|
||||
const nextColor = () =>
|
||||
onChange({
|
||||
color: PLAYER_COLORS[(PLAYER_COLORS.indexOf(seat.color) + 1) % PLAYER_COLORS.length],
|
||||
});
|
||||
return (
|
||||
<div className="game-seat">
|
||||
<button
|
||||
type="button"
|
||||
className="game-seat-color"
|
||||
style={{ background: seat.color }}
|
||||
title="Change color"
|
||||
aria-label="Change color"
|
||||
onClick={nextColor}
|
||||
/>
|
||||
<input
|
||||
className="game-seat-name"
|
||||
value={seat.name}
|
||||
placeholder={`Player ${index + 1}`}
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="game-seat-deck"
|
||||
value={seat.deckId ?? ""}
|
||||
onChange={(e) => onChange({ deckId: e.target.value === "" ? null : Number(e.target.value) })}
|
||||
>
|
||||
<option value="">No deck</option>
|
||||
{decks.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameSetup({
|
||||
current,
|
||||
onStart,
|
||||
onResume,
|
||||
}: {
|
||||
current: Game | null;
|
||||
onStart: (game: Game) => void;
|
||||
onResume: () => void;
|
||||
}) {
|
||||
const [stored, setStored] = usePersistedState<Setup>("game-setup", DEFAULT_SETUP);
|
||||
const setup: Setup = { ...DEFAULT_SETUP, ...stored };
|
||||
const patch = (p: Partial<Setup>) => setStored({ ...setup, ...p });
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
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 (
|
||||
<div className="game-setup">
|
||||
<div className="page-header">
|
||||
<h2 className="page-header-title">Game night</h2>
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<div className="panel game-resume">
|
||||
<div>
|
||||
<strong>Game in progress</strong>
|
||||
<div className="muted" style={{ fontSize: 13 }}>
|
||||
{current.players.map((p) => `${p.name} ${p.life}`).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<button className="primary" onClick={onResume}>
|
||||
Back to the table
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel game-setup-form">
|
||||
<div className="field">
|
||||
<label>Players</label>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{[2, 3, 4, 5, 6].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`filter-chip ${setup.count === n ? "active" : ""}`}
|
||||
onClick={() => patch({ count: n })}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Starting life</label>
|
||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{LIFE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.life}
|
||||
type="button"
|
||||
className={`filter-chip ${setup.startingLife === p.life ? "active" : ""}`}
|
||||
onClick={() => patch({ startingLife: p.life, commanderDamage: p.commander })}
|
||||
>
|
||||
{p.label} · {p.life}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
style={{ width: 72 }}
|
||||
aria-label="Custom starting life"
|
||||
value={setup.startingLife}
|
||||
onChange={(e) => {
|
||||
const n = Math.floor(Number(e.target.value));
|
||||
if (n > 0) patch({ startingLife: n });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="row" style={{ gap: 8, cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setup.commanderDamage}
|
||||
onChange={(e) => patch({ commanderDamage: e.target.checked })}
|
||||
/>
|
||||
Track commander damage
|
||||
</label>
|
||||
|
||||
<div className="field">
|
||||
<label>Seats, in turn order</label>
|
||||
<div className="game-seats">
|
||||
{setup.seats.slice(0, setup.count).map((seat, i) => (
|
||||
<SeatRow
|
||||
key={i}
|
||||
index={i}
|
||||
seat={seat}
|
||||
decks={decks}
|
||||
onChange={(p) =>
|
||||
patch({ seats: setup.seats.map((s, j) => (j === i ? { ...s, ...p } : s)) })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{decks.length > 0 && (
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
A deck with a commander puts its art behind that player's life total.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<button
|
||||
className="primary"
|
||||
disabled={starting}
|
||||
onClick={() => (current ? setConfirmReplace(true) : start())}
|
||||
>
|
||||
{starting ? "Setting up…" : "Start game"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmReplace && (
|
||||
<ConfirmDialog
|
||||
title="Start a new game?"
|
||||
message="The game in progress will be ended."
|
||||
confirmLabel="Start new game"
|
||||
danger
|
||||
onConfirm={start}
|
||||
onCancel={() => setConfirmReplace(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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<ReturnType<typeof setTimeout> | 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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`game-life-half ${sign > 0 ? "plus" : "minus"}`}
|
||||
aria-label={sign > 0 ? "Gain 1 life" : "Lose 1 life"}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
stop();
|
||||
pressed.current = true;
|
||||
held.current = false;
|
||||
const tick = () => {
|
||||
held.current = true;
|
||||
onChangeRef.current(sign * 10);
|
||||
timer.current = setTimeout(tick, 600);
|
||||
};
|
||||
timer.current = setTimeout(tick, 450);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
if (pressed.current && !held.current) onChangeRef.current(sign);
|
||||
stop();
|
||||
}}
|
||||
onPointerLeave={stop}
|
||||
onPointerCancel={stop}
|
||||
// A long press would otherwise open the webview's context menu.
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
// Keyboard activation; pointer clicks (detail > 0) were handled above.
|
||||
onClick={(e) => e.detail === 0 && onChangeRef.current(sign)}
|
||||
>
|
||||
<span aria-hidden>{sign > 0 ? "+" : "−"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** "−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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div
|
||||
className={["game-player", rotated ? "rotated" : "", out ? "out" : "", isFirst ? "first" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={
|
||||
{
|
||||
"--player-color": player.color,
|
||||
backgroundImage: player.art ? `url("${player.art}")` : undefined,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<LifeHalf sign={-1} onChange={(d) => dispatch({ type: "life", id: player.id, delta: d })} />
|
||||
<LifeHalf sign={1} onChange={(d) => dispatch({ type: "life", id: player.id, delta: d })} />
|
||||
|
||||
<div className="game-player-head">
|
||||
<span className="game-player-name">{player.name}</span>
|
||||
{player.deckName && <span className="game-player-deck">{player.deckName}</span>}
|
||||
</div>
|
||||
|
||||
<div className="game-life">
|
||||
{player.life}
|
||||
{delta !== 0 && (
|
||||
<span className={`game-delta ${delta > 0 ? "up" : "down"}`}>
|
||||
{delta > 0 ? `+${delta}` : `−${-delta}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{out && <div className="game-out-label">{out}</div>}
|
||||
|
||||
<div className="game-player-foot">
|
||||
{isFirst && (
|
||||
<button
|
||||
type="button"
|
||||
className="game-badge first"
|
||||
title="Goes first — tap to dismiss"
|
||||
onClick={() => dispatch({ type: "first", id: null })}
|
||||
>
|
||||
★ First
|
||||
</button>
|
||||
)}
|
||||
{player.poison > 0 && (
|
||||
<span className="game-badge" title="Poison counters">
|
||||
☠ {player.poison}
|
||||
</span>
|
||||
)}
|
||||
{game.commanderDamage && worstCommander > 0 && (
|
||||
<span className="game-badge" title="Most commander damage from one opponent">
|
||||
⚔ {worstCommander}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="game-badge game-badge-btn" onClick={onOpenCounters}>
|
||||
Counters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
value,
|
||||
onChange,
|
||||
big,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (delta: number) => void;
|
||||
/** Adds ±5 buttons, for life. */
|
||||
big?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="game-stepper">
|
||||
{big && <button onClick={() => onChange(-5)}>−5</button>}
|
||||
<button onClick={() => onChange(-1)} aria-label="Decrease">
|
||||
−
|
||||
</button>
|
||||
<span className="game-stepper-value">{value}</span>
|
||||
<button onClick={() => onChange(1)} aria-label="Increase">
|
||||
+
|
||||
</button>
|
||||
{big && <button onClick={() => onChange(5)}>+5</button>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<DialogFrame title={player.name} onCancel={onClose}>
|
||||
<div className="game-counters">
|
||||
<div className="game-counter-row">
|
||||
<span>Life</span>
|
||||
<Stepper big value={player.life} onChange={(d) => dispatch({ type: "life", id: player.id, delta: d })} />
|
||||
</div>
|
||||
<div className="game-counter-row">
|
||||
<span>
|
||||
Poison <span className="muted">· out at {POISON_LETHAL}</span>
|
||||
</span>
|
||||
<Stepper
|
||||
value={player.poison}
|
||||
onChange={(d) => dispatch({ type: "poison", id: player.id, delta: d })}
|
||||
/>
|
||||
</div>
|
||||
{game.commanderDamage && (
|
||||
<>
|
||||
<div className="game-counter-heading">
|
||||
Commander damage taken
|
||||
<span className="muted"> · out at {COMMANDER_LETHAL} from one, and it comes off life too</span>
|
||||
</div>
|
||||
{opponents.map((o) => (
|
||||
<div className="game-counter-row" key={o.id}>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="game-dot" style={{ background: o.color }} />
|
||||
{o.name}
|
||||
</span>
|
||||
<Stepper
|
||||
value={player.commanderDamage[o.id] ?? 0}
|
||||
onChange={(d) => dispatch({ type: "commander", id: player.id, from: o.id, delta: d })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
<button className="primary" onClick={onClose} autoFocus>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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<Roll[]>([]);
|
||||
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 (
|
||||
<DialogFrame title="Dice" onCancel={onClose}>
|
||||
<div className="game-dice-buttons">
|
||||
{DICE.map((n) => (
|
||||
<button key={n} onClick={() => push(`d${n}`, String(randomInt(n) + 1))}>
|
||||
d{n}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => push("Coin", randomInt(2) === 0 ? "Heads" : "Tails")}>Coin</button>
|
||||
</div>
|
||||
<div className="game-dice-result">
|
||||
{latest ? (
|
||||
// Keyed per roll so the pop-in replays even when the number repeats.
|
||||
<div key={latest.id} className="game-dice-latest">
|
||||
<span className="game-dice-value">{latest.result}</span>
|
||||
<span className="muted">{latest.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted">Pick a die to roll.</span>
|
||||
)}
|
||||
</div>
|
||||
{earlier.length > 0 && (
|
||||
<div className="game-dice-history muted">
|
||||
Earlier: {earlier.map((r) => `${r.label} ${r.result}`).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button className="primary" onClick={onClose} autoFocus>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<void> };
|
||||
const wakeLock = (
|
||||
navigator as Navigator & { wakeLock?: { request: (type: "screen") => Promise<Sentinel> } }
|
||||
).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<string | null>(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) => (
|
||||
<div className="game-row">
|
||||
{row.map((p) => (
|
||||
<PlayerPanel
|
||||
key={`${p.id}-${game.epoch}`}
|
||||
player={p}
|
||||
game={game}
|
||||
rotated={faceToFace && r === 0}
|
||||
dispatch={dispatch}
|
||||
onOpenCounters={() => setCountersFor(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="game-board">
|
||||
{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. */}
|
||||
<div className="game-strip">
|
||||
<button
|
||||
type="button"
|
||||
className="game-hub"
|
||||
aria-label="Game menu"
|
||||
title="Game menu"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2l8.66 5v10L12 22l-8.66-5V7z" />
|
||||
<path d="M12 2v20M3.34 7L20.66 17M20.66 7L3.34 17" opacity="0.45" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{renderRow(rows[1], 1)}
|
||||
|
||||
{menuOpen && (
|
||||
<DialogFrame title="Game" onCancel={() => setMenuOpen(false)}>
|
||||
<div className="game-menu">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setDiceOpen(true);
|
||||
}}
|
||||
>
|
||||
Roll dice / flip a coin
|
||||
</button>
|
||||
<button onClick={pickFirst}>Who goes first?</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setConfirmReset(true);
|
||||
}}
|
||||
>
|
||||
Restart this game
|
||||
</button>
|
||||
<label className="row" style={{ gap: 8, cursor: "pointer", padding: "6px 2px" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={faceToFace}
|
||||
onChange={(e) => setFaceToFace(e.target.checked)}
|
||||
/>
|
||||
Turn the far side to face across the table
|
||||
</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onLeave();
|
||||
}}
|
||||
>
|
||||
Leave the table
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
)}
|
||||
{diceOpen && <DiceDialog onClose={() => setDiceOpen(false)} />}
|
||||
{confirmReset && (
|
||||
<ConfirmDialog
|
||||
title="Restart this game?"
|
||||
message={`Everyone goes back to ${game.startingLife} life with no counters. Same players and decks.`}
|
||||
confirmLabel="Restart"
|
||||
danger
|
||||
onConfirm={() => {
|
||||
setConfirmReset(false);
|
||||
dispatch({ type: "reset" });
|
||||
}}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
/>
|
||||
)}
|
||||
{countersPlayer && (
|
||||
<CountersDialog
|
||||
player={countersPlayer}
|
||||
game={game}
|
||||
dispatch={dispatch}
|
||||
onClose={() => setCountersFor(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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 <GameBoard game={game} dispatch={dispatch} onLeave={() => setSeated(false)} />;
|
||||
}
|
||||
return (
|
||||
<GameSetup
|
||||
current={game}
|
||||
onResume={() => setSeated(true)}
|
||||
onStart={(g) => {
|
||||
dispatch({ type: "start", game: g });
|
||||
setSeated(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<span className="muted" title="Copies no deck is using">
|
||||
{c.quantity_owned - c.quantity_assigned} spare
|
||||
</span>
|
||||
);
|
||||
|
||||
function libTable(cards: LibraryCard[]) {
|
||||
return (
|
||||
<table>
|
||||
@@ -371,6 +381,7 @@ export function LibraryBrowser({
|
||||
⚠ {c.quantity_assigned} assigned
|
||||
</span>
|
||||
)}
|
||||
{spareNote(c)}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Decks / Location">{deckOrLocation(c)}</td>
|
||||
@@ -408,7 +419,12 @@ export function LibraryBrowser({
|
||||
pending={pendingRemoval.has(c.oracle_id)}
|
||||
/>
|
||||
}
|
||||
footer={deckOrLocation(c)}
|
||||
footer={
|
||||
<>
|
||||
{deckOrLocation(c)}
|
||||
{spareNote(c)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -632,7 +648,7 @@ export function LibraryBrowser({
|
||||
placeholder="e.g. landfall"
|
||||
/>
|
||||
</div>
|
||||
<CardCriteriaFields value={criteria} onChange={patchCriteria} hideNotOwned />
|
||||
<CardCriteriaFields value={criteria} onChange={patchCriteria} hideNotOwned showSpares />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user