Files
mtgc/src/api.ts
Garrett Mills ea689b0f2b Honor cards' own copy limits in deck legality
Cards like Relentless Rats (any number), Nazgûl (up to nine), and Once
More with Feeling (only one) override the format's copy limit. The limit
is parsed from oracle text at card-data import into a new
oracle_cards.copy_limit column; older reference dbs fall back to the
format rule until re-imported. The legality check and illegal-card
selection use it. Also count snow basics as basic lands.

Closes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:10:23 +00:00

607 lines
25 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[];
/** The subset of `decks` that physically exist (building or built). */
physical_decks: string[];
/** Copies claimed by building/built decks (idea/retired decks claim none). */
quantity_assigned: number;
/** Copies physically pulled into decks; the rest are at `location`. */
quantity_in_decks: number;
location: string;
}
/** A partner-style ability, letting a card share the command zone with a
* second commander. Mirrors `mtgc_core::partner::Partner`. */
export type Partner =
| { kind: "partner" | "chooses_background" | "background" | "doctors_companion" | "doctor" }
/** "Partner—Survivors" and the like (`variant`); "Partner with <name>" (`with`). */
| { kind: "variant" | "with"; name: 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;
/** The printing whose art this slot shows; `image_uri` already reflects it. */
preferred_scryfall_id: string | null;
partner: Partner | null;
/** Copies physically in this deck; meaningful for building/built decks. */
quantity_pulled: number;
/** Owned copies not claimed by any *other* building/built deck. */
quantity_spare: number;
/** Owned copies not pulled into any deck — still in their box. */
quantity_in_box: number;
/** The *other* building/built decks claiming copies; those with copies
* pulled are where to take one from when the box is empty. */
claimed_by: DeckHolding[];
/** Where the card's library copies are stored ("" if unset). */
location: string;
/** The card's own exception to the format's copy limit ("A deck can have
* any number of cards named …"): null when the format's rule applies, 0
* for any number, else the most copies allowed. */
copy_limit: number | null;
}
export interface DeckHolding {
deck_id: number;
name: string;
/** Copies the deck's main board lists. */
quantity: number;
/** Of those, copies physically in the deck. */
pulled: number;
}
/** Where a deck stands physically. Building and built decks hold cards and
* claim owned copies; ideas and retired decks are just lists. */
export type DeckStatus = "idea" | "building" | "built" | "retired";
export const DECK_STATUSES: { value: DeckStatus; label: string }[] = [
{ value: "idea", label: "Idea" },
{ value: "building", label: "Building" },
{ value: "built", label: "Built" },
{ value: "retired", label: "Retired" },
];
export const isPhysical = (s: DeckStatus) => s === "building" || s === "built";
/** One printing of a card, for the printing picker and copy labels. */
export interface PrintingRef {
scryfall_id: string;
set_code: string; // lowercase, e.g. "2x2"
collector_number: string;
lang: string; // "" on an older card database
finishes: string[]; // "nonfoil" | "foil" | "etched"
rarity: string;
image_uri: string;
back_image_uri: string;
price_usd: number;
released_at: string; // YYYY-MM-DD, "" on an older card database
}
/** Owned copies pinned to one printing + finish. A subset of the card's
* `quantity_owned`; copies in no group are unspecified. */
export interface LibraryCopy {
scryfall_id: string;
finish: string;
quantity: number;
/** Null when the printing has left the card database. */
printing: PrintingRef | null;
}
/**
* 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[];
physical_decks?: string[];
quantity_in_decks?: number;
location?: string;
preferred_scryfall_id?: string | null;
};
/**
* Where a click preview was opened from, when the caller lets it edit
* printings: the info tile then tracks owned copies by printing, and — for a
* deck slot — chooses which printing's art the slot shows.
*/
export interface PreviewContext {
/** Called after any printing change, so the caller can refetch. */
onChanged: () => void;
/** The deck the previewed card is a slot of. */
deckId?: number;
/** That deck's chosen cover card, so the preview can offer to change it. */
coverOracleId?: string | null;
}
export interface Deck {
id: number;
name: string;
format: string;
commander_oracle_id: string | null;
notes: string;
/** The card chosen to headline the deck in the deck list; null = automatic. */
cover_oracle_id: string | null;
status: DeckStatus;
}
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 {
/** The open library's name and where it syncs: "Main · user @ host", or
* this device only. Changes whenever another library is opened. */
library_label: string;
/** The open library. */
library: { id: string; name: string };
reference_path: string;
reference_ready: boolean;
card_count: number;
reference_updated_at: number | null; // unix seconds
/** Columns the installed card data lacks ("table.column") because an older
* version built it. Non-empty means some features are degraded until the
* user updates card data. */
reference_missing_columns: string[];
/** 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;
/** Libraries were added, renamed, or deleted on the server: re-list them. */
libraries_changed: boolean;
}
/** One of the libraries on this device. */
export interface LibraryInfo {
/** Local only; never shown. */
id: string;
name: string;
/** The one that's open. */
active: boolean;
/** The library the device started with, which can't be deleted. */
is_default: boolean;
/** Whether it should be on the sync server. */
sync: boolean;
/** Whether it's linked to a library on the server yet. */
linked: boolean;
/** Another device deleted it from the server; this copy is all that's left. */
server_deleted: boolean;
}
/** The open library, for `libraryKey`. Set by `App` from each status. */
let openLibraryId = "default";
export function setOpenLibrary(id: string) {
openLibraryId = id;
}
/** A localStorage key for something that belongs to the open library — a
* selected deck, say — so each library remembers its own. The default
* library keeps the bare key it always had. */
export function libraryKey(key: string): string {
return openLibraryId === "default" ? key : `${key}:${openLibraryId}`;
}
/** 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 }),
listLibraries: () => invoke<LibraryInfo[]>("list_libraries"),
/** Start a new, empty library and open it. Resolves with the new list. */
createLibrary: (name: string, sync: boolean) =>
invoke<LibraryInfo[]>("create_library", { name, sync }),
/** Open another library. */
switchLibrary: (id: string) => invoke<LibraryInfo[]>("switch_library", { id }),
/** Rename a library, on the sync server too when it's there. */
renameLibrary: (id: string, name: string) => invoke<LibraryInfo[]>("rename_library", { id, name }),
/** Delete a library from this device; with `everywhere`, from the server too. */
deleteLibrary: (id: string, everywhere: boolean) =>
invoke<LibraryInfo[]>("delete_library", { id, everywhere }),
/** Put a library on the sync server, or keep it on this device only. */
setLibrarySync: (id: string, sync: boolean) => invoke<LibraryInfo[]>("set_library_sync", { id, sync }),
/** Merge a library file into the open one: a JSON backup, or an old library.db. */
importLibraryFile: (path: string) => invoke<ImportReport>("import_library_file", { path }),
/** Save the whole open 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 }),
/** Change a deck's status. Leaving building/built returns pulled copies to
* their boxes; entering it with `assembled` marks the copies in the box as
* already in the deck. */
setDeckStatus: (deckId: number, status: DeckStatus, assembled = false) =>
invoke<void>("set_deck_status", { deckId, status, assembled }),
/** Record copies physically in a deck; returns the (clamped) count stored. */
setDeckCardPulled: (deckId: number, oracleId: string, pulled: number) =>
invoke<number>("set_deck_card_pulled", { deckId, oracleId, pulled }),
/** Move one pulled copy between decks; false if there was nothing to move. */
takeDeckCardCopy: (fromDeckId: number, toDeckId: number, oracleId: string) =>
invoke<boolean>("take_deck_card_copy", { fromDeckId, toDeckId, oracleId }),
/** Choose the card headlining the deck in the deck list; null = automatic. */
setDeckCover: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_cover", { deckId, oracleId }),
setDeckCommander: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_commander", { deckId, oracleId }),
/** One commander, or a partner pair; the first is the deck's primary. */
setDeckCommanders: (deckId: number, oracleIds: string[]) =>
invoke<void>("set_deck_commanders", { deckId, oracleIds }),
/** Name search narrowed to cards that could partner with the commander. */
searchPartners: (commanderOracleId: string, query: string, limit: number) =>
invoke<CardRef[]>("search_partners", { commanderOracleId, query, limit }),
setDeckCard: (
deckId: number,
oracleId: string,
quantity: number,
isCommander = false,
isMaybeboard = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
/** Copy (desktop) or share (Android) a card image; says which happened. */
shareCardImage: (url: string, name: string) =>
invoke<"copied" | "shared">("share_card_image", { url, name }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
/** Every deck's contents by deck id; decks with no cards are absent. */
allDeckContents: () => invoke<Record<number, DeckEntry[]>>("all_deck_contents"),
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 }),
/** Every paper printing of a card, newest first. */
listPrintings: (oracleId: string) => invoke<PrintingRef[]>("list_printings", { oracleId }),
listLibraryCopies: (oracleId: string) =>
invoke<LibraryCopy[]>("list_library_copies", { oracleId }),
/** Set how many owned copies are this printing + finish (0 removes the
* group). Resolves with the card's owned quantity, which grows to fit. */
setLibraryCopy: (oracleId: string, scryfallId: string, finish: string, quantity: number) =>
invoke<number>("set_library_copy", { oracleId, scryfallId, finish, quantity }),
/** Choose which printing a deck slot shows; null restores the default.
* Resolves with the slot afterwards (its art reflecting the choice). */
setDeckCardPrinting: (deckId: number, oracleId: string, scryfallId: string | null) =>
invoke<DeckEntry | null>("set_deck_card_printing", { deckId, oracleId, scryfallId }),
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 }),
/** `location`, unless blank, is set on every imported card. */
importLibraryCards: (path: string, location: string) =>
invoke<LibraryImportResult>("import_library_cards", { path, location }),
};