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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259// The list a scan lands in, and every move that can be made on it. Pure, so// what holds the list decides where it is kept — `docs/scanner.md`.
import { FLOOR } from "../catalog/artwork";import { COPIES, type Owned } from "../collection/cards";import type { Read } from "./read";
// One artwork the frame came near, and every printing carrying it.export type Matched = { distance: number; prints: string[] };
export type Entry = { id: string; at: string; // Bits between the nearest artwork and the next. margin: number; // False where no outline was found and the framing was guessed at. detected: boolean; // Nearest first. A printing is picked out of these, so changing one costs // no second photograph. matched: Matched[]; scryfallId: string; // Whether a person said so. An index proposing a printing and somebody // agreeing it is the card in their hand are different claims, and only the // second one may be reported as an answer. picked?: boolean; // When a frame of this stack was last sent back. Kept on the entry rather // than in a component, so it survives the fold closing and the tab dying. reported?: string; finish: string; // `""` is ungraded and absent follows the list's own — `docs/scanner.md`. condition?: string; quantity: number;};
// What the catalog says a printing was made in, passed in so nothing here// needs a catalog of its own.export type Finishes = (scryfallId: string) => string[];
const NONFOIL = "nonfoil";
// Whether the reader settled it on its own. Two conditions, because the// margin measures the artwork and an artwork is not a printing.export function sure(one: Entry): boolean { return one.margin >= FLOOR && (one.matched[0]?.prints.length ?? 0) === 1;}
// The printings the winning artwork carries. One is an answer; several is a// question the bottom line of the card would settle and nothing reads yet.export function among(one: Entry): string[] { return one.matched[0]?.prints ?? [];}
// One scan, one stack. Null where nothing it matched is a printing the// catalog holds, there being no card to put in a list.export function scanned(found: Read, id: string, at: string): Entry | null { const matched: Matched[] = []; const made = new Map<string, string[]>();
for (const one of found.found) { if (one.prints.length === 0) continue; matched.push({ distance: one.distance, prints: one.prints.map(({ print }) => print.id), }); for (const { print } of one.prints) made.set(print.id, print.finishes); }
const first = matched[0]?.prints[0]; if (!first) return null;
return { id, at, margin: gap(found, matched), detected: found.detected, matched, scryfallId: first, finish: settled(made.get(first) ?? []), quantity: 1, };}
// How far the next artwork the catalog can name was, which is not the read's// own margin: that one counts artworks no printing carries.function gap(found: Read, matched: Matched[]): number { const first = matched[0]; const second = matched[1]; if (!first) return 0; if (second) return second.distance - first.distance; return found.found[0]?.prints.length === 0 ? 0 : found.margin;}
// A printing made one way only settles its own finish; the rest start// nonfoil and can be turned over.export function settled(finishes: string[]): string { if (finishes.length === 1) return finishes[0] as string; return finishes.includes(NONFOIL) ? NONFOIL : (finishes[0] ?? NONFOIL);}
// The grade a stack is kept at, given what the list is set to.export function graded(one: Entry, grade: string): string { return one.condition ?? grade;}
export function plus(list: Entry[], id: string): Entry[] { return list.map((one) => one.id === id && one.quantity < COPIES ? { ...one, quantity: one.quantity + 1 } : one, );}
// Zero takes the stack away, so the undo for a card counted twice is the same// button as the one for a card counted once too often.export function minus(list: Entry[], id: string): Entry[] { return list.flatMap((one) => { if (one.id !== id) return [one]; return one.quantity > 1 ? [{ ...one, quantity: one.quantity - 1 }] : []; });}
export function reported(list: Entry[], id: string, at: string): Entry[] { return list.map((one) => (one.id === id ? { ...one, reported: at } : one));}
export function drop(list: Entry[], id: string): Entry[] { return list.filter((one) => one.id !== id);}
// Any printing, not only one the scan matched. Holding it to the matches// left nothing to say when the index had missed the card altogether, which// is the reading most worth being able to correct.export function choose( list: Entry[], id: string, scryfallId: string, finishes: Finishes,): Entry[] { return list.map((one) => { if (one.id !== id) return one; // Agreeing with what the reader already said is an answer too, and the // finish is left alone because nothing about the printing changed. if (one.scryfallId === scryfallId) return { ...one, picked: true }; return { ...one, scryfallId, picked: true, finish: settled(finishes(scryfallId)), }; });}
// Whether the index had this printing anywhere in what it returned. False// against a person's own answer is the strongest thing a report can say.export function offered(one: Entry, scryfallId: string): boolean { return one.matched.some(({ prints }) => prints.includes(scryfallId));}
// Every printing the list needs a catalog row for. The matches alone leave// out the one case `choose` exists to cover, a card named that nothing// matched.export function referenced(list: Entry[]): string[] { const all = new Set<string>(); for (const one of list) { all.add(one.scryfallId); for (const { prints } of one.matched) for (const id of prints) all.add(id); } return [...all].sort();}
// A finish the printing was never made in is refused whatever asked for it.export function refinish( list: Entry[], id: string, finish: string, finishes: Finishes,): Entry[] { return list.map((one) => one.id === id && finishes(one.scryfallId).includes(finish) ? { ...one, finish } : one, );}
// `null` puts the stack back to following the list, which is not ungraded.export function regraded( list: Entry[], id: string, condition: string | null,): Entry[] { return list.map((one) => { if (one.id !== id) return one; if (condition === null) { const { condition: _dropped, ...rest } = one; return rest; } return { ...one, condition }; });}
// Stacks of one printing become one, on request. Scanning records what// somebody saw, and arrival is the wrong moment to tidy that away.export function merged(list: Entry[], grade: string): Entry[] { const into = new Map<string, Entry>(); const kept: Entry[] = [];
for (const one of list) { // What the grade comes to rather than what was said about it: ungraded // and following a list that is ungraded are the same card to write. const key = `${one.scryfallId}\t${one.finish}\t${graded(one, grade)}`; const held = into.get(key); if (held) { // A stack the ceiling has closed stays the one to try, so what follows // still joins it where there is room. if (held.quantity + one.quantity <= COPIES) { held.quantity += one.quantity; // Pinned, there being nothing left for a joined stack to follow. if (held.condition !== one.condition) held.condition = graded(one, grade); } else kept.push({ ...one }); continue; } const copy = { ...one }; kept.push(copy); into.set(key, copy); }
return kept;}
// Stacks a merge would take away, so it is offered only where it does// something.export function joinable(list: Entry[], grade: string): number { return list.length - merged(list, grade).length;}
export function copies(list: Entry[]): number { return list.reduce((sum, one) => sum + one.quantity, 0);}
// The list as cards, for the path that writes them.export function cards( list: Entry[], container: string | null, grade: string,): Owned[] { return list.map((one) => { const condition = graded(one, grade); return { scryfallId: one.scryfallId, finish: one.finish, quantity: one.quantity, createdAt: one.at, ...(condition ? { condition } : {}), ...(container ? { container } : {}), }; });}