Something went wrong. Try again.
An atproto AppView for tracking a Magic: The Gathering collection github.com/eth0net/manaweb
appview atproto mtg rust typescript
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652// Scryfall's syntax, not one of ours — see `docs/search.md` for why and for// which terms the artifact can answer.
import { COLORS } from "./columns";import type { Card, Print } from "./index";import { normalize } from "./search";
export type Op = ":" | "=" | "!=" | ">" | "<" | ">=" | "<=";
export interface Term { key: string; op: Op; value: string;}
// `-` negates, `or` and parentheses group; anything adjacent is an `and`.export type Node = | { kind: "term"; term: Term } | { kind: "not"; of: Node } | { kind: "and"; of: Node[] } | { kind: "or"; of: Node[] };
// Where a term sat, so a control can replace one and leave every character// it did not write — see `docs/search.md`.export interface Written extends Term { not: boolean; start: number; end: number;}
export interface Query { // The bare words, which name ranking reads rather than a term. text: string; node: Node | null; order: string; direction: "asc" | "desc" | ""; // Recognized and unanswerable here, which is a prompt rather than an error. missing: string[]; // Every term as it was typed, including the ones the tree drops. written: Written[];}
// A term is a key, a comparison and a value. Anything else is part of a name.const TERM = /^([a-z]+)(>=|<=|!=|>|<|=|:)(.*)$/i;
// Wanting a part the device doesn't hold, or a phase that hasn't arrived.const LATER: Record<string, string> = { o: "oracle text", oracle: "oracle text", kw: "oracle text", keyword: "oracle text", f: "legality", format: "legality", legal: "legality", banned: "legality", restricted: "legality", usd: "prices", eur: "prices", tix: "prices",};
const ORDER = [ "name", "mv", "cmc", "pow", "tou", "rarity", "released", "set", "cn", "edhrec", "printings",];
export interface Token { text: string; at: number;}
interface Reading { tokens: Token[]; at: number; words: string[]; missing: string[]; written: Written[]; order: string; direction: "asc" | "desc" | "";}
export function parse(text: string): Query { const reading: Reading = { tokens: tokens(text), at: 0, words: [], missing: [], written: [], order: "", direction: "", };
const node = any(reading); return { text: reading.words.join(" "), node, order: reading.order, direction: reading.direction, missing: [...new Set(reading.missing)], written: reading.written, };}
// Quotes hold a value together and parentheses stand alone; everything else// breaks on a space.export function tokens(text: string): Token[] { const found = text.matchAll(/-?\(|\)|[^\s()"]*"[^"]*"?[^\s()"]*|[^\s()]+/g); return [...found].map((one) => ({ text: one[0], at: one.index }));}
function unquote(value: string): string { return value.replace(/"/g, "");}
function gather(of: Node[], kind: "and" | "or"): Node | null { if (of.length === 0) return null; return of.length === 1 ? (of[0] as Node) : { kind, of };}
// `or` binds loosest, so it is read over runs of everything else.function any(reading: Reading): Node | null { const of: Node[] = []; for (;;) { const node = every(reading); if (node) of.push(node); const next = reading.tokens[reading.at]; if (next?.text.toLowerCase() !== "or") return gather(of, "or"); reading.at++; }}
function every(reading: Reading): Node | null { const of: Node[] = []; for (;;) { const next = reading.tokens[reading.at]; if ( next === undefined || next.text === ")" || next.text.toLowerCase() === "or" ) { return gather(of, "and"); } if (next.text.toLowerCase() === "and") { reading.at++; continue; } const node = one(reading); if (node) of.push(node); }}
function one(reading: Reading): Node | null { const token = reading.tokens[reading.at++] as Token;
if (token.text === "(" || token.text === "-(") { const inner = any(reading); // A half-typed group is the normal state — see `docs/search.md`. if (reading.tokens[reading.at]?.text === ")") reading.at++; if (!inner) return null; return token.text === "-(" ? { kind: "not", of: inner } : inner; }
const not = token.text.startsWith("-"); const rest = not ? token.text.slice(1) : token.text; const found = TERM.exec(rest); if (!found) { reading.words.push(unquote(rest)); return null; }
const [, key, op, raw] = found as unknown as string[]; const term: Term = { key: (key as string).toLowerCase(), op: op as Op, value: unquote(raw as string), };
reading.written.push({ ...term, not, start: token.at, end: token.at + token.text.length, });
if (term.key === "order") { if (ORDER.includes(term.value)) reading.order = term.value; return null; } if (term.key === "direction" || term.key === "dir") { reading.direction = term.value === "desc" ? "desc" : "asc"; return null; } if (LATER[term.key]) { reading.missing.push(LATER[term.key] as string); return null; } if (!KEYS.has(term.key)) { // An unknown key is a name with a colon in it far less often than it is a // typo, so it is reported rather than searched for. reading.missing.push(`${term.key}:`); return null; }
const node: Node = { kind: "term", term }; return not ? { kind: "not", of: node } : node;}
// Every key below, so an unknown one can be told from a supported one.const KEYS = new Set([ "c", "color", "colors", "id", "identity", "t", "type", "mv", "cmc", "manavalue", "m", "mana", "pow", "power", "tou", "toughness", "r", "rarity", "s", "set", "e", "edition", "cn", "number", "a", "artist", "lang", "in", "layout", "is", "not", "year",]);
// Written out, and the guild and shard names players use for them.const NAMED: Record<string, string> = { white: "W", blue: "U", black: "B", red: "R", green: "G", colorless: "", c: "", azorius: "WU", dimir: "UB", rakdos: "BR", gruul: "RG", selesnya: "GW", orzhov: "WB", izzet: "UR", golgari: "BG", boros: "RW", simic: "GU", bant: "GWU", esper: "WUB", grixis: "UBR", jund: "BRG", naya: "RGW", abzan: "WBG", jeskai: "URW", sultai: "BGU", mardu: "RWB", temur: "GUR", // Four colors are named twice over, by the guild pattern and by the // nephilim, and players use both. glint: "UBRG", dune: "WBRG", ink: "GWUR", witch: "WUBG", yore: "WUBR", chaos: "UBRG", aggression: "WBRG", altruism: "GWUR", growth: "WUBG", artifice: "WUBR", wubrg: "WUBRG", five: "WUBRG",};
const RARITY = ["common", "uncommon", "rare", "mythic"];
// `s:lea r:rare` wants one Alpha rare rather than two printings between them,// so the whole query is put to each in turn — see `docs/search.md`.export function matches(query: Query, card: Card, prints: Print[]): boolean { if (query.node === null) return true; if (!printed(query)) return truth(query.node, card, undefined, prints); return answering(query, card, prints) !== undefined;}
// Which printing answered, and so which one a row shows.export function answering( query: Query, card: Card, prints: Print[],): Print | undefined { const node = query.node; if (node === null || !printed(query)) return undefined; return prints.find((print) => truth(node, card, print, prints));}
function truth( node: Node, card: Card, print: Print | undefined, all: Print[],): boolean { switch (node.kind) { case "term": return holds(node.term, card, print, all); case "not": return !truth(node.of, card, print, all); case "and": return node.of.every((one) => truth(one, card, print, all)); default: return node.of.some((one) => truth(one, card, print, all)); }}
// Whether the printings have to be built, so a scan asking nothing of them// doesn't pay for them.export function printed(query: Query): boolean { return query.node !== null && asks(query.node);}
function asks(node: Node): boolean { switch (node.kind) { case "term": return PRINTED.has(node.term.key); case "not": return asks(node.of); default: return node.of.some(asks); }}
const PRINTED = new Set([ "r", "rarity", "s", "set", "e", "edition", "cn", "number", "a", "artist", "lang", "in", "layout", "year", "is", "not",]);
function holds( term: Term, card: Card, print: Print | undefined, all: Print[],): boolean { const { key, op, value } = term;
switch (key) { case "c": case "color": case "colors": return sided( card, (of) => of.colors !== null && colored(op, value, of.colors), ); case "id": case "identity": return colored(op, value, card.colorIdentity); case "t": case "type": return has(card.typeLine, value); case "mv": case "cmc": case "manavalue": return compares(card.cmc, Number(value), op); case "m": case "mana": return sided(card, (of) => costs(op, value, of.manaCost)); case "pow": case "power": return sided(card, (of) => stat(of.stats, 0, op, value)); case "tou": case "toughness": return sided(card, (of) => stat(of.stats, 1, op, value)); case "is": case "not": // `not:foil` is `-is:foil` spelled the other way, and the leading minus // is already off by the time a term is read. return (key === "not") !== flagged(value, card, print); // The one term that asks the run rather than the row. case "in": return ( value.toLowerCase() === "paper" || all.some((one) => printedAs(one, value)) ); case "r": case "rarity": return compares( RARITY.indexOf(print?.rarity ?? ""), RARITY.indexOf(value), op, ); case "s": case "set": case "e": case "edition": return print?.set === value.toLowerCase(); case "cn": case "number": return print?.collectorNumber === value; case "a": case "artist": return has(print?.artist ?? null, value); case "lang": return print?.lang === value.toLowerCase(); case "layout": return print?.layout === value; case "year": return compares(Number(print?.released.slice(0, 4)), Number(value), op); default: return false; }}
// `in:` takes a set, a rarity or a language, and none of the three shares a// spelling with either other.function printedAs(print: Print, value: string): boolean { const wanted = value.toLowerCase(); return ( print.set === wanted || print.rarity === wanted || print.lang === wanted );}
// Symbols rather than a total, so `m>1G` is a subset relation and generic is// counted rather than named — see `docs/search.md`.function costs(op: Op, value: string, mine: string | null): boolean { if (mine === null) return false; const held = symbols(mine); const wanted = symbols(value);
const covers = [...wanted].every(([one, n]) => (held.get(one) ?? 0) >= n); const inside = [...held].every(([one, n]) => (wanted.get(one) ?? 0) >= n); const size = (cost: Map<string, number>) => [...cost.values()].reduce((all, n) => all + n, 0);
switch (op) { case ">": return covers && size(held) > size(wanted); case "<": return inside && size(held) < size(wanted); case "<=": return inside; case "!=": return !(covers && inside); case "=": return covers && inside; default: return covers; }}
// `{2}{W}{U}` as written, or the `3WU` shorthand a query is typed in.function symbols(cost: string): Map<string, number> { const held = new Map<string, number>(); const add = (one: string, n = 1) => held.set(one, (held.get(one) ?? 0) + n);
const braced = [...cost.matchAll(/\{([^}]+)\}/g)].map( (found) => found[1] as string, ); const parts = braced.length > 0 ? braced : (cost.match(/\d+|[a-z/]/gi) ?? []);
for (const part of parts) { const generic = Number(part); if (Number.isFinite(generic)) add("generic", generic); else add(part.toUpperCase()); } return held;}
function has(field: string | null, value: string): boolean { return field !== null && normalize(field).includes(normalize(value));}
const FINISHES = ["nonfoil", "foil", "etched"];
// `is:foil` asks a printing, everything else a flag either of them carries.// The artifact names those in camel case and nobody types `is:fullArt`.function flagged( value: string, card: Card, print: Print | undefined,): boolean { const wanted = value.toLowerCase(); if (FINISHES.includes(wanted)) { return print?.finishes.includes(wanted) ?? false; }
const same = (flag: string) => flag.toLowerCase() === wanted; return card.flags.some(same) || (print?.flags.some(same) ?? false);}
// `pow>tou` compares the two rather than a number, and a power that isn't a// number — Tarmogoyf's `*` — matches nothing rather than counting as zero.// What a card and one of its sides both answer for.type Sided = Pick<Card, "typeLine" | "manaCost" | "colors" | "stats">;
// A two-sided card's own columns are its sides combined or empty, so a term it// cannot answer for itself is put to each side in turn.function sided(card: Card, ask: (of: Sided) => boolean): boolean { return ask(card) || (card.faces?.some(ask) ?? false);}
function stat( stats: string | null, at: number, op: Op, value: string,): boolean { const parts = stats?.split("/") ?? []; const mine = Number(parts[at]); if (!Number.isFinite(mine)) return false;
const other = value === "pow" || value === "power" ? Number(parts[0]) : value === "tou" || value === "toughness" ? Number(parts[1]) : Number(value);
return compares(mine, other, op);}
function compares(mine: number | null, other: number, op: Op): boolean { if (mine === null || !Number.isFinite(mine) || !Number.isFinite(other)) { return false; } switch (op) { case ">": return mine > other; case "<": return mine < other; case ">=": return mine >= other; case "!=": return mine !== other; case "<=": return mine <= other; default: return mine === other; }}
// A color is a bit, in the order the cards file's header names them.export function bits(letters: string): number { let mask = 0; for (const one of letters) mask |= 1 << COLORS.indexOf(one); return mask;}
function many(mask: number): number { let held = 0; for (let bit = mask; bit !== 0; bit &= bit - 1) held++; return held;}
// `c:` is "at least", which is what makes it the useful default; `c=2` counts// colors instead of naming them.function colored(op: Op, value: string, mine: number): boolean { const count = Number(value); if (Number.isFinite(count) && value.trim() !== "") { return compares(many(mine), count, op); }
if (value.toLowerCase() === "m" || value.toLowerCase() === "multicolor") { return many(mine) > 1; }
const wanted = masked(value); if (wanted === null) return false; if (wanted === 0) return nothing(op, mine);
const covers = (mine & wanted) === wanted; const inside = (mine & ~wanted) === 0;
switch (op) { case ":": case ">=": return covers; case ">": return covers && many(mine) > many(wanted); case "<=": return inside; case "<": return inside && many(mine) < many(wanted); case "!=": return !(covers && inside); default: return covers && inside; }}
// Colorless is the empty set, so every operator is spelled out rather than// falling out of subset and superset — see `docs/search.md`.function nothing(op: Op, mine: number): boolean { switch (op) { case ">=": return true; case ">": case "!=": return mine !== 0; case "<": return false; default: return mine === 0; }}
// Null where the value names no colors at all, which asks for nothing rather// than for colorless.function masked(value: string): number | null { const named = NAMED[value.toLowerCase()]; if (named !== undefined) return bits(named); const found = [...value.toUpperCase()].filter((one) => COLORS.includes(one)); return found.length > 0 && found.length === [...value].length ? bits(found.join("")) : null;}