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.
418 lines
16 KiB
TypeScript
418 lines
16 KiB
TypeScript
// Typed wrappers over the Tauri command bridge into mtgc-core.
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
// --- Types (mirror the serde-serialized core structs) ---------------------
|
|
|
|
export interface CardRef {
|
|
oracle_id: string;
|
|
name: string;
|
|
mana_value: number;
|
|
colors: number; // WUBRG bitmask
|
|
color_identity: number; // WUBRG bitmask
|
|
type_line: string;
|
|
// Printed/displayed sideways (Battles, Planes, Phenomena). The stored
|
|
// image itself is portrait-shaped with the content rotated, so viewers
|
|
// must rotate it 90° to display right-side up.
|
|
horizontal: boolean;
|
|
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
|
|
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
|
|
produced_mana: number; // WUBRG bitmask of mana this card can tap for
|
|
produces_colorless: boolean; // can produce {C}
|
|
price_usd: number; // USD market price, or 0 if unknown
|
|
legalities: number; // 2 bits/format bitmask, see LEGALITY_FORMATS
|
|
}
|
|
|
|
export interface LibraryCard extends CardRef {
|
|
quantity_owned: number;
|
|
decks: string[];
|
|
quantity_assigned: number;
|
|
location: string;
|
|
}
|
|
|
|
export interface DeckEntry extends CardRef {
|
|
quantity: number;
|
|
is_commander: boolean;
|
|
/** On the maybeboard: tracked against the deck but excluded from deck
|
|
* analysis stats and library assignment totals. */
|
|
is_maybeboard: boolean;
|
|
quantity_owned: number;
|
|
}
|
|
|
|
/**
|
|
* A `CardRef` plus whatever ownership context the caller happens to have
|
|
* (present on `LibraryCard`/`DeckEntry`, absent on a bare `CardRef`). The
|
|
* click-to-preview modal's info tile renders these fields when present and
|
|
* omits them otherwise — callers don't need to do anything special, since
|
|
* `LibraryCard`/`DeckEntry`/`CardRef` are all structurally assignable here.
|
|
*/
|
|
export type PreviewCard = CardRef & {
|
|
quantity_owned?: number;
|
|
decks?: string[];
|
|
};
|
|
|
|
export interface Deck {
|
|
id: number;
|
|
name: string;
|
|
format: string;
|
|
commander_oracle_id: string | null;
|
|
notes: string;
|
|
}
|
|
|
|
export interface DeckImportResult {
|
|
deck: Deck;
|
|
unresolved: string[];
|
|
}
|
|
|
|
export interface LibraryImportResult {
|
|
added: [string, number][];
|
|
unresolved: string[];
|
|
}
|
|
|
|
export interface SynergyReason {
|
|
category: string; // "Tribal" | "Mechanic" | "Archetype" | "Mana base"
|
|
/** The specific thing matched — "Dragon", "Flying", "Landfall". Separate from
|
|
* `detail` so the UI can filter on it: a deck usually has several archetypes
|
|
* and you want one of them, not all. */
|
|
subject: string;
|
|
detail: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export interface LibraryFilter {
|
|
text?: string | null;
|
|
colors_exact?: number | null;
|
|
colors_include?: number | null;
|
|
commander_identity?: number | null;
|
|
cmc_min?: number | null;
|
|
cmc_max?: number | null;
|
|
types?: string[];
|
|
subtypes?: string[];
|
|
keywords?: string[];
|
|
in_deck?: number | null;
|
|
location?: string | null;
|
|
oracle_text?: string | null;
|
|
/** Hide cards that exist *only* as Universes Beyond (Lord of the Rings,
|
|
* Warhammer 40,000, Marvel, …). Ordinary cards reprinted into a UB set are
|
|
* kept. Needs card data downloaded after this shipped; inert otherwise. */
|
|
exclude_universes_beyond?: boolean;
|
|
/** Hide Arena/MTGO-only cards (Alchemy rebalances) — they can't be owned on
|
|
* paper. Needs card data downloaded after this shipped; inert otherwise. */
|
|
exclude_digital_only?: boolean;
|
|
/** Hide Un-set cards (Unglued, Unhinged, Unstable, Unfinity). */
|
|
exclude_funny?: boolean;
|
|
/** Cheapest known printing in USD; cards with no known price count as 0. */
|
|
price_min?: number | null;
|
|
price_max?: number | null;
|
|
/** Format slug ("commander", "modern", …). Legal and restricted match. */
|
|
legal_in?: string | null;
|
|
/** Only cards that can head a Commander deck. */
|
|
can_be_commander?: boolean;
|
|
/** Only creatures with an empty rules box (Adventure/Prepared creature halves count). */
|
|
vanilla_only?: boolean;
|
|
/** Only cards printed in this set (matched against printings.set_code).
|
|
* Lets the release browser reuse the whole criteria pipeline. */
|
|
set_code?: string | null;
|
|
/** Only cards with an empty color identity — a colorless commander's pool.
|
|
* Separate from the color bitmasks, where 0 means "no filter". */
|
|
colorless_identity?: boolean;
|
|
/** Only cards that are themselves colorless. */
|
|
colorless_colors?: boolean;
|
|
/** Printed power/toughness. Cards printed with "*" or "1+*" are excluded,
|
|
* since those can't be compared numerically. */
|
|
power_min?: number | null;
|
|
power_max?: number | null;
|
|
toughness_min?: number | null;
|
|
toughness_max?: number | null;
|
|
/** 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 {
|
|
/** Where the library syncs: "user @ host", or this device only. */
|
|
library_label: string;
|
|
reference_path: string;
|
|
reference_ready: boolean;
|
|
card_count: number;
|
|
reference_updated_at: number | null; // unix seconds
|
|
/** True on mobile builds. Used to hide affordances that depend on choosing an
|
|
* arbitrary filesystem path for library.db — Android scoped storage has no
|
|
* equivalent, and SQLite can't be backed by a content:// URI. */
|
|
is_mobile: boolean;
|
|
}
|
|
|
|
/** The account a device is signed in to on a sync server. */
|
|
export interface SyncAccount {
|
|
id: string;
|
|
username: string;
|
|
display_name: string;
|
|
email: string;
|
|
/** "local" (username/password) or "oidc" (single sign-on). */
|
|
method: string;
|
|
}
|
|
|
|
/** Library sync with a sync server. The library itself always lives on this
|
|
* device and works offline; syncing exchanges edits with the other devices. */
|
|
export interface SyncStatus {
|
|
signed_in: boolean;
|
|
/** Kept after signing out, to offer signing back in. */
|
|
server_url: string | null;
|
|
account: SyncAccount | null;
|
|
/** Unix seconds of the last completed sync. */
|
|
last_synced_at: number | null;
|
|
/** Why the last sync failed, until one succeeds. */
|
|
last_error: string | null;
|
|
/** The first sync found libraries both here and on the server; waiting on
|
|
* `syncConnect` to say whether to combine them or take the server's. */
|
|
needs_choice: boolean;
|
|
/** Edits made here that the server hasn't acknowledged yet. */
|
|
pending_changes: boolean;
|
|
/** A sign-in is waiting on the browser. */
|
|
signing_in: boolean;
|
|
/** A pre-document library.db from an older build, waiting to be imported. */
|
|
legacy_library: string | null;
|
|
}
|
|
|
|
/** One of the account's signed-in devices. */
|
|
export interface SyncDevice {
|
|
id: number;
|
|
name: string;
|
|
created_at: number;
|
|
last_seen_at: number;
|
|
/** Whether it's this device. */
|
|
current: boolean;
|
|
}
|
|
|
|
export type FirstSync = "combine" | "use_server";
|
|
|
|
export interface MergeReport {
|
|
cards_changed: number;
|
|
decks_changed: number;
|
|
decks_removed: number;
|
|
/** Fields both sides changed, where the server's value was kept. */
|
|
conflicts: string[];
|
|
}
|
|
|
|
/** What an import of a library file brought in. */
|
|
export interface ImportReport {
|
|
cards: number;
|
|
decks: number;
|
|
report: MergeReport;
|
|
}
|
|
|
|
/** What one sync did. */
|
|
export interface SyncReport {
|
|
/** Whether the merge altered this device's library. */
|
|
changed: boolean;
|
|
/** Whether the server's copy was replaced (skipped when there was nothing to add). */
|
|
published: boolean;
|
|
/** The first sync is waiting on the user — see `SyncStatus.needs_choice`. */
|
|
needs_choice: boolean;
|
|
report: MergeReport;
|
|
}
|
|
|
|
/** A published or upcoming Magic set, from Scryfall. */
|
|
export interface SetInfo {
|
|
code: string;
|
|
name: string;
|
|
set_type: string;
|
|
/** ISO date; null for sets with no announced date, which sort last. */
|
|
released_at: string | null;
|
|
card_count: number;
|
|
icon_uri: string;
|
|
scryfall_uri: string;
|
|
}
|
|
|
|
/** A news article — title and link only; the app links out rather than
|
|
* reproducing anyone's writing. */
|
|
export interface Article {
|
|
category: string;
|
|
title: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface News {
|
|
sets: SetInfo[];
|
|
articles: Article[];
|
|
/** Unix seconds of the last successful fetch; 0 if never. */
|
|
fetched_at: number;
|
|
/** False when the article source couldn't be reached or its markup changed.
|
|
* Only that section is affected — releases stay usable. */
|
|
articles_ok: boolean;
|
|
}
|
|
|
|
/** A card in a set. Extends CardRef so it renders with the same CardTile —
|
|
* and therefore the same click-to-preview — as every other card in the app. */
|
|
export interface SetCard extends CardRef {
|
|
collector_number: string;
|
|
quantity_owned: number;
|
|
}
|
|
|
|
export interface LibraryStats {
|
|
unique_cards: number;
|
|
total_copies: number;
|
|
deck_count: number;
|
|
color_counts: [number, number, number, number, number]; // W, U, B, R, G
|
|
colorless_count: number;
|
|
}
|
|
|
|
export interface LibraryFacets {
|
|
types: string[];
|
|
subtypes: string[];
|
|
keywords: string[];
|
|
}
|
|
|
|
export interface IngestStageInfo {
|
|
id: string;
|
|
label: string;
|
|
weight: number; // fraction of total work, 0..1, sums to 1 across all stages
|
|
}
|
|
|
|
export interface IngestProgress {
|
|
stage: "index" | "oracle" | "printings" | "optimize" | string;
|
|
message: string;
|
|
overall_percent: number; // 0..100 cumulative across all stages
|
|
stages: IngestStageInfo[];
|
|
}
|
|
|
|
// --- WUBRG bitmask helpers ------------------------------------------------
|
|
|
|
export const COLORS: { bit: number; code: string; label: string }[] = [
|
|
{ bit: 1, code: "W", label: "White" },
|
|
{ bit: 2, code: "U", label: "Blue" },
|
|
{ bit: 4, code: "B", label: "Black" },
|
|
{ bit: 8, code: "R", label: "Red" },
|
|
{ bit: 16, code: "G", label: "Green" },
|
|
];
|
|
|
|
export function maskToLetters(mask: number): string {
|
|
return COLORS.filter((c) => mask & c.bit)
|
|
.map((c) => c.code)
|
|
.join("");
|
|
}
|
|
|
|
// --- Legality bitmask helpers ----------------------------------------------
|
|
// Mirrors mtgc-core's model::LEGALITY_FORMATS / Legalities exactly: 2 bits per
|
|
// format, in this fixed order. Do not reorder.
|
|
|
|
export const LEGALITY_FORMATS: { key: string; label: string }[] = [
|
|
{ key: "standard", label: "Standard" },
|
|
{ key: "pioneer", label: "Pioneer" },
|
|
{ key: "modern", label: "Modern" },
|
|
{ key: "legacy", label: "Legacy" },
|
|
{ key: "vintage", label: "Vintage" },
|
|
{ key: "pauper", label: "Pauper" },
|
|
{ key: "commander", label: "Commander" },
|
|
{ key: "brawl", label: "Brawl" },
|
|
];
|
|
|
|
export type LegalStatus = "not_legal" | "legal" | "banned" | "restricted";
|
|
|
|
const LEGAL_STATUS_CODES: readonly LegalStatus[] = ["not_legal", "legal", "banned", "restricted"];
|
|
|
|
export function legalStatusFor(legalities: number, formatLabel: string): LegalStatus {
|
|
const i = LEGALITY_FORMATS.findIndex((f) => f.label === formatLabel);
|
|
if (i < 0) return "not_legal";
|
|
const code = (legalities >> (i * 2)) & 0b11;
|
|
return LEGAL_STATUS_CODES[code];
|
|
}
|
|
|
|
// --- Commands -------------------------------------------------------------
|
|
|
|
export const api = {
|
|
getStatus: () => invoke<Status>("get_status"),
|
|
updateCardData: () => invoke<Status>("update_card_data"),
|
|
cancelUpdateCardData: () => invoke<void>("cancel_update_card_data"),
|
|
/** Adopt an already-built reference.db from `path` instead of downloading it. */
|
|
importReferenceDb: (path: string) => invoke<Status>("import_reference_db", { path }),
|
|
syncStatus: () => invoke<SyncStatus>("sync_status"),
|
|
/** Sign in through the browser, then sync. Resolves once the user finishes
|
|
* (or abandons) signing in there. */
|
|
syncSignIn: (serverUrl: string, deviceName?: string) =>
|
|
invoke<SyncStatus>("sync_sign_in", { serverUrl, deviceName: deviceName || null }),
|
|
syncCancelSignIn: () => invoke<void>("sync_cancel_sign_in"),
|
|
/** Sign this device out. The library stays here. */
|
|
syncSignOut: () => invoke<SyncStatus>("sync_sign_out"),
|
|
/** Merge with the server and publish local edits. Resolves with `null` when
|
|
* not signed in, or a sync was already running and this call stood down —
|
|
* expected, since sync also fires automatically. */
|
|
syncNow: () => invoke<SyncReport | null>("sync_now"),
|
|
/** Answer the first-sync question. */
|
|
syncConnect: (choice: FirstSync) => invoke<SyncReport | null>("sync_connect", { choice }),
|
|
syncDevices: () => invoke<SyncDevice[]>("sync_devices"),
|
|
/** Sign another device out (a lost phone, say); returns the remaining list. */
|
|
syncRevokeDevice: (id: number) => invoke<SyncDevice[]>("sync_revoke_device", { id }),
|
|
/** Merge a library file into this one: a JSON backup, or an old library.db. */
|
|
importLibraryFile: (path: string) => invoke<ImportReport>("import_library_file", { path }),
|
|
/** Save the whole library as JSON, as a backup. */
|
|
exportLibraryDocument: (path: string) => invoke<void>("export_library_document", { path }),
|
|
|
|
/** Set releases and article links. `refresh` forces a fetch past the cache. */
|
|
getNews: (refresh = false) => invoke<News>("get_news", { refresh }),
|
|
/** Every card in a set, annotated with how many you own. */
|
|
getSetCards: (code: string) => invoke<SetCard[]>("get_set_cards", { code }),
|
|
|
|
searchCards: (query: string, limit = 20) =>
|
|
invoke<CardRef[]>("search_cards", { query, limit }),
|
|
|
|
addOwned: (oracleId: string, delta: number) =>
|
|
invoke<number>("add_owned", { oracleId, delta }),
|
|
setOwned: (oracleId: string, quantity: number) =>
|
|
invoke<number>("set_owned", { oracleId, quantity }),
|
|
setLocation: (oracleId: string, location: string) =>
|
|
invoke<void>("set_location", { oracleId, location }),
|
|
|
|
searchLibrary: (filter: LibraryFilter) =>
|
|
invoke<LibraryCard[]>("search_library", { filter }),
|
|
searchReference: (filter: LibraryFilter, limit: number) =>
|
|
invoke<LibraryCard[]>("search_reference", { filter, limit }),
|
|
getLibraryStats: () => invoke<LibraryStats>("get_library_stats"),
|
|
getLibraryFacets: () => invoke<LibraryFacets>("get_library_facets"),
|
|
getReferenceFacets: () => invoke<LibraryFacets>("get_reference_facets"),
|
|
|
|
listDecks: () => invoke<Deck[]>("list_decks"),
|
|
createDeck: (name: string, commanderOracleId: string | null) =>
|
|
invoke<Deck>("create_deck", { name, commanderOracleId }),
|
|
deleteDeck: (deckId: number) => invoke<void>("delete_deck", { deckId }),
|
|
renameDeck: (deckId: number, name: string) => invoke<void>("rename_deck", { deckId, name }),
|
|
duplicateDeck: (deckId: number, newName: string) =>
|
|
invoke<Deck>("duplicate_deck", { deckId, newName }),
|
|
setDeckFormat: (deckId: number, format: string) =>
|
|
invoke<void>("set_deck_format", { deckId, format }),
|
|
setDeckCommander: (deckId: number, oracleId: string | null) =>
|
|
invoke<void>("set_deck_commander", { deckId, oracleId }),
|
|
setDeckCard: (
|
|
deckId: number,
|
|
oracleId: string,
|
|
quantity: number,
|
|
isCommander = false,
|
|
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, 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 }),
|
|
importDeck: (path: string, name: string) =>
|
|
invoke<DeckImportResult>("import_deck", { path, name }),
|
|
exportLibraryCards: (oracleIds: string[], path: string) =>
|
|
invoke<void>("export_library_cards", { oracleIds, path }),
|
|
importLibraryCards: (path: string) =>
|
|
invoke<LibraryImportResult>("import_library_cards", { path }),
|
|
};
|