Collection tracker for Pokémon TCG Pocket — record what you own, say what you want, and see which pack gets you there fastest.
Browser
│
▼
Blazor WebAssembly (.NET 10 — static files, no account, no sign-in)
│
├── / Collection (grid or list, counts, filters, undo)
├── /progress Every unfinished set, and what each one still needs
├── /packs Which pack to open next, against any target
├── /log Log a pack; accrues pack points
├── /chase Chase lists, shared entirely inside a link
├── /decks Deck import, building, legality, QR share codes
├── /trades · /board Trade ranking, and the in-game 20-slot wishlist
├── /wonder · /resources Wonder Pick verdicts; the three currency systems
└── /collection/screenshot Read cards off a screenshot of the game
│
├── PackProphet.Core (pure C#, no UI reference — the compiler enforces it)
│ ├── Engine odds, targets, ranking, allocation, trades, wonder picks
│ ├── Vision fingerprint matching, screen classification, count reading
│ ├── Deck share-code codec, legality linter, QR render
│ ├── Data card index, pull rates, rarity ladder, set catalogue
│ └── State profiles, undo history, chase-list codec, serialisation
│
├── JS interop (only what Blazor cannot reach)
│ └── capped-concurrency image loading, touch drag-select, grid keys,
│ theme and palette before first paint, tooltips, jsQR, screenshot slotting
│
├── localStorage collections, chase lists, decks, pack log, settings
│
├── Cloud sync optional. A 12-character pairing code derives the document id and the
│ │ AES-GCM key; only the id and a proof token ever leave the browser
│ └── Supabase one table, no policies, reachable only through three functions
│
└── Card data
├── cdn.jsdelivr.net live card lists and card detail
└── wwwroot/data/ vendored snapshot + fingerprint table,
so a cold start never boots empty
One odds engine drives every screen. The same maths prices a set of rarities, a deck, a whole series, or a hand-picked chase list.
- Every unfinished set on one page — how far short of your target each one is, what would finish it, and the state of all three routes into it: packs, that set's pack points, and how many of the missing cards are tradeable. One panel per set, each leading with its own figure, because the figures do not add up: a set can be three cards short and four hundred packs away, if those three are Crowns. There is deliberately no total and no finish-everything date — cards are cards and the page counts them, but packs of one set are not packs of another, and the daily allowance is shared. Expand a set to see what is left by rarity rung, with the rungs you collect none of drawn as gaps rather than zeroes
- Which pack to open next — every pack ranked against whatever you are collecting, using published pull rates. The chance a pack gives you something you need and the expected packs until it does; where in the pack that chance is, per card position, since the first three cards come from the common pool — a collection that has finished the commons has all of its chance in the last two, and the same headline percentage means different things in two sets. Expected packs to finish converts into calendar time at your own pack rate ("≈2.6 years, or 1.7 with premium"). Scope it to everything, one set, a whole series, a deck, one chase list, or all chase lists
- Target advisor and pack points — every rarity priced side by side, at one copy and at two; per-set point balances, cap warnings, and a ranking of what points buy. Ranked by packs saved per pack's worth of points: a Crown returns about 2.5, an Immersive 0.3
- Collection in a virtualised grid or a compact list — counts, per-set progress against your target, bulk drag-select, and undo/redo. Ownership is keyed by artwork rather than by set entry, matching how the game treats it, so a card reprinted in a later set is one card you own
- Filters that hold more than one thing at a time — rarity as the game's own symbols, type as the fourteen energy and trainer pips, ownership, and the pack a card comes from, picked by its wrapper rather than by name. Whatever is applied shows as a chip on the bar, because a count read without knowing it is filtered is a different answer to the one it appears to be
- A heart on every card puts it on your Want it list in one tap — its own list, so a passing heart never rewrites a chase list you built deliberately. A sticky strip names which set you are scrolling through once the list spans more than one
- A tile shows the art and nothing over it — the count, the card's name and the heart sit in a caption under the picture; on a phone the grid keeps the count as a badge and stays as dense as it was. A card whose art the CDN has not published yet shows its set and number in the art's place rather than a broken-image marker
- Keyboard throughout — arrow keys move a cursor, digits set a count outright,
enterand-adjust,shift+move selects a range,wwants a card,iopens its page, and/jumps to search - Import from a screenshot — screenshot the game and the cards are read off the picture. Nothing is uploaded and it works offline: every card's artwork was reduced to 128 bits ahead of time and the fingerprints ship with the app, so recognising a card is a distance comparison against a 150 KB table rather than a model or a server. See Reading a screenshot below
- Promos, without the scroll — a promo arrives one at a time, so there is no pack to log and
nothing a screenshot saves; the work is all in the finding.
/logoffers the ten newest in the promo set currently being filled, one tap per copy, owned ones included because a promo can be earned twice. Newest rather than currently on offer: no dataset carries an event calendar, but promo numbers are issued in release order, so recency is exact where availability is not knowable at all — and the panel says so - Decks — import one by screenshotting the in-game share code, decoded in the browser; or build one by hand with live legality checking and a search covering rules text and card type as well as names ("what puts something to Sleep", "show me the Supporters"). Export back to a scannable code the game accepts. Saved decks are ranked closest-to-buildable first
- Chase lists — any set of cards you want: chase cards, a binder page, the alternate arts of one Pokémon, priced by the same engine as a rarity target. Chase lists want a particular printing, unlike decks: wanting the alternate art is not satisfied by owning the plain one
- Chase lists share as a link, with nothing uploaded — the list travels entirely inside the link. It shows only what is wanted, never your own collection, and if the person opening it has their own PackProphet collection loaded it highlights which of it they already own. Or copy it as a few lines of text for the chat threads these trades actually happen in: what is still short, set and number first, with the rarity on every line, since the game's trade rules turn on it
- Log a pack — pick the pack, search it by name or tap the cards that came out of it, done. It accrues pack points, warns at the cap, and updates every recommendation immediately. Pin the packs you open daily and they lead the picker whatever series you are looking at
- History — packs over time, what you pulled against what the model predicted, and a showcase of your best hits, with the threshold for a "hit" set by you. If you played before finding this app, enter the game's own lifetime counters and the odds check scales to them; logged and imported figures are kept apart
- Wonder Pick — tap the five cards on offer and get a take-or-skip verdict. The cost is set by the highest rarity in the offer rather than by what the offer is worth to you, so an offer can be priced at four stamina because of a 2-star you already own. Those offers are flagged
- Resources — the three currency systems side by side: Pack, Wonder and Trade. They are never summed, because nothing converts between them. Each panel leads with waste: a full pool has stopped regenerating, so every 12 hours it sits there is a unit lost, and a pool at two of five has 36 hours of slack the panel names
- Trades — which single trade deserves your next stamina. Stamina caps trading at about two a day while shinedust accumulates, so the ranking treats trades as the scarce resource rather than currency. A share? column marks cards to skip trading for: 1–4 diamond cards can be Shared, which costs no dust, no stamina and no card given back
- Wishlist — the game's own 20-slot public trade board, filled with the eligible cards that cost the most to get any other way. A listing costs nothing but a slot and slots do not expire, so low-probability high-value requests are worth listing; a minority of slots is reserved for widely-held cards someone is likely to offer. Recommendations come as swaps against what is already listed, since the board is retyped by hand
- Multiple collections — track alt accounts and compare two of them. A trade is a swap: both sides hand over a card and the rarities must match, so the return card is as much a constraint as the wanted one. Diamonds are reported separately, because a Share is one-way and free — shares are listed first, and trades hold only what a share cannot carry
- Evolution gaps — evolutions you own but cannot play, because you do not own what they evolve from. Reported per missing card, with the cheapest printing that would fill it. It walks the whole chain, so a Stage 2 with neither lower stage reports two missing cards
- Appearance — two independent settings. Light, dark or follow-the-OS, and a palette: Paper, warm neutral surfaces with one accent, or Slate, the greys and blues the app shipped with. Every skin has a light and a dark form, so choosing one is not also choosing the other
- Accessibility — contrast measured live in every skin and theme, names on every control, keyboard operation throughout, and a text alternative for the history chart
Four in-game screens can be read, and the import lives where the answer is useful rather than in one place that then asks what you meant:
| Screen | Where | What it does |
|---|---|---|
| Opening Results | Log a pack | Works out which pack from the cards, and fills the log in |
| Wonder Pick | Wonder Pick | Feeds the five cards straight into the appraisal |
| My Cards, five across | Collection | Whole set, blanks for what you are missing — reads both ways |
| My Cards, three across | Collection | Only what you own, with how many copies |
- The pack identifies itself. A card lists the packs it can come from, so the packs that could have produced a whole hand are the intersection of five short lists. In Genetic Apex, where each pack has about 80 exclusive cards against 46 shared, one exclusive card settles it. When the cards genuinely do not narrow it — a God Pack holds only the rarities every pack shares — the shortlist is offered rather than a guess made
- The two card lists are read differently, and that is a safety rule. The five-across list draws unowned cards as blank slots, so a blank is a card known to be missing and the numbering around it gives its name. The three-across list leaves unowned cards out instead, so a gap there means nothing at all — positional reasoning is switched off on it, and it can never report a card missing
- It stops rather than guesses. Two cards whose artwork is too alike to choose between, a slot matching nothing, a list not in number order: each comes back as a slot that was not recognised and is left out. Nothing is applied without being shown, marking cards as not owned is off until asked for, and the whole import is one undo step
- The same picture imported twice is caught. A logged opening carries its pack and its cards and so does a reading, so the two are compared — against the pack log, and against the rest of the batch. It warns rather than refuses: three common slots draw from a pool in the low tens, so over a few hundred packs of one booster a genuinely identical hand is percentage points rather than nothing, and the date of the earlier one is named so you can settle it. Card identity rather than pixels, because a file hash would miss a re-crop or a second shot of the same still screen
- Only a card's window is fingerprinted — everything but the outer frame and the bottom sixth — because a card on screen is not its artwork file. The game draws a gold flair border over any card held ten times or more and prints a copy-count badge across the bottom. Measured on a real screenshot, this recognises six of six whole cards at 4 to 12 bits of 128, flair and all; sampling the whole card managed one in nine
- Every box is rebuilt from what all the cards on screen agree about — the median size, the median row and column — because one card's own outline is measured eight pixels differently from its neighbour's, and eight pixels is enough to change its fingerprint completely. Cards are found by their colour and texture against the page in the first place
- A card is not always found by looking at it. A white-bodied card has no colour to catch and only sparse text, so the mask misses it entirely — it is read because the other cards in its row fix the row's phase and the column pitch says where the remaining slots must be. The fingerprint then decides whether a card is there, rather than the mask
- Nine crops per card, and the most confident one wins. A card's box is only ever located to within a few pixels and the fingerprint has no tolerance for that — three pixels is worth ten bits or more — so each card is offered to the matcher nudged three pixels each way. Every crop still has to clear the same threshold and margin on its own, so more crops cannot manufacture a confident answer
- Copy counts are read completely or not at all. The three-across list gives up how many copies you hold, read off the badge the game prints on each card. Ten shapes in a fixed-pitch font is a far easier problem than card art, and a far less forgiving one — 1 recorded where the badge said 14 is silent — so a count is never read in part
- Fingerprints are the one piece of card data that cannot be newer than the deploy, since generating one means downloading the art. Card lists come live from the CDN, so a set can be browsable and unrecognisable at the same time; the page says which sets those are and when the table was built. A weekly workflow closes the gap — see Refreshing the fingerprints
All four screens read on real screenshots: both card lists with copy counts, a pack's reveal with the pack named from the cards, and a Wonder Pick line-up — five of five on each. One fixture still reads nothing, a pack reveal in which every card is white-bodied, so no row has anything to extend from. The six real fixtures, the measurements, and everything already ruled out are in KNOWN-ISSUES.md.
- Everything lives in your browser's
localStorage. Export a JSON backup from Settings - Card data comes from public community datasets over a CDN, with a bundled snapshot as a fallback so the app never boots empty
- Screenshot import is decoded locally. No image is ever uploaded
- The app does not connect to in-game accounts and has no support for doing so
- Cloud sync is off until you turn it on, and encrypted on this device when you do. See below
Optional, and off by default. There is no account and no sign-in: one device makes a twelve-character pairing code, the other types it, and that code is the whole credential.
The code never leaves the browser. It is stretched with PBKDF2 (300,000 iterations, SHA-256) into a master secret, and three independent HKDF expansions of that master do three different jobs:
| Derived value | Job | Leaves the device? |
|---|---|---|
id |
Names the stored document | Yes |
auth |
Proves you know the code, so an id-guesser cannot overwrite you | Yes |
key |
AES-256-GCM, encrypts the collection | Never |
The host stores ciphertext it has no way to read. That is also the trade: lose the code and the stored copy is unrecoverable. The collection on each paired device is untouched either way, and a JSON export is still the backup that survives everything.
Two devices edited apart have to be reconciled, not ranked. Sync keeps the last state both devices agreed on and does a three-way merge against it, so "changed here" is distinguishable from "changed there" — logging packs on a phone and ticking cards on a laptop loses neither.
| Rule | |
|---|---|
| Card counts | Whoever changed it wins. Both changed it → the higher count |
| Pack and Wonder logs | Union. Append-only, so an absence is never a deletion |
| Decks, chase lists | Per id. Both edited → this device wins, and says so |
| Resource pools | The more recently read balance, by its own timestamp |
| Everything else | Whoever changed it wins. Both changed it → this device, and it says which |
| Theme, columns, active profile | Never synced — they belong to the device you are holding |
On a first pair there is no ancestor, so nothing can be read as a deletion and nothing a device has never set can outvote a device that has: a blank window joining an established collection adopts its settings rather than blanking them.
Conflicts are reported in Settings rather than resolved silently, and a merge arrives through the same path as any other change, so Ctrl+Z undoes one.
Sync is disabled unless the build names a Supabase project, which is why a clone of this repository is local-only out of the box. To enable it:
- Create a Supabase project and run
db/sync.sqlin its SQL editor. It creates one table with row-level security and no policies — PostgREST cannot touch it — plus the threesecurity definerfunctions that are the entire API. - Set two repository variables (Settings → Secrets and variables → Actions → Variables):
PACKPROPHET_SYNC_URLandPACKPROPHET_SYNC_KEY. The deploy workflow substitutes both intoappsettings.jsonand into theconnect-srcinindex.html, and fails the build if only one of them lands. Leave them unset and the deploy ships with sync off. - For local
dotnet run, put the same two values insrc/PackProphet.App/wwwroot/appsettings.Development.json, which is gitignored. Blazor layers it overappsettings.json.
Variables rather than secrets, deliberately. The browser has to send the anon key to Supabase on every request, so it is public the moment the site is deployed — it identifies the project and grants nothing without a pairing code. Filing it as a secret would dress that up as something it is not. What keeping it out of the tree does buy is a rotation that costs a settings change rather than a commit, and a git history that never carried it.
The CSP names one exact host rather than *.supabase.co, so a tampered card dataset has nowhere to
post to.
One bar above every page, for the two cases where the site knows something the pages do not.
Derived. The app's own data ages at three rates by design: card data is fetched live, card art is a manual commit in a second repository, and card detail is a separate 4.4 MB table topped up per set. So for days after a release the app knows a card exists, cannot draw it, and cannot say what it does. The bar names the set and which of the two is missing — as one sentence, because a new set is usually missing both and two notices would mean dismissing one to reveal the other.
If the live database could not be reached at all it says that instead: the visible symptom of falling back to the bundled snapshot is that the newest set is simply absent, so it is worth stating rather than leaving to be discovered.
Pull rates deliberately are not on this bar, although they lag the same way. An unpriced set is
already solvable in the app — /packs offers to borrow the newest measured set's distributions and
every figure follows — so it is a setting, not something anyone is waiting on.
Screenshot recognition is not on it either, for a different reason: deciding whether it applies means parsing the 150 KB fingerprint table, which is the one piece of card data deliberately kept off the boot path, and the import page already says so above its own file picker.
Detail coverage is computed on the spot. Art coverage cannot be — the app cannot see a missing image
without requesting it, and probing 3,879 of them to decide whether to show one sentence is absurd —
so tools/vendor-gap-art.py writes what it found into art/index.json during the deploy, which is
the one place that already knows. That listing asks the art repository for a set's directory, so a
set it has started and not finished looks complete: this under-reports and never over-reports, which
is the right direction for a claim made on every page.
Authored, for the rest. An outage, a feature that has started failing, or a pack that launched before the card database published it — the last of which nothing derived can see, since the app cannot know about a set it has never heard of.
Set the repository variable PACKPROPHET_NOTICE_GIST to a gist's raw URL without the revision
SHA (https://gist.githubusercontent.com/<you>/<id>/raw/notice.json). The deploy workflow
substitutes it into appsettings.json and refuses a URL carrying a SHA, which would serve one
revision forever. Unset, no request is made. For local dotnet run, put it in the gitignored
appsettings.Development.json.
A gist rather than a file in this repository because the service worker precaches every .json in
the published output and then serves it cache-first: a committed notice would be frozen at whichever
build the visitor installed and could never announce anything.
The gist holds a list, even for one notice:
[
{
"id": "2026-09-18-b5",
"level": "info",
"text": "B5 launches today. The card database has not published it yet.",
"until": "2026-09-25",
"actionLabel": "Read More",
"actionHref": "https://example.com/notes"
}
]id is required and is what a dismissal is remembered against — reuse it to edit a notice, change
it to post a new one. level is info, warning or problem; anything unrecognised reads as
info, so a typo there still shows the notice. until is an inclusive yyyy-MM-dd and optional,
but a date that cannot be parsed drops the notice: that field is the only thing that makes one
stop on its own. The action is both halves or neither, and the href must be an absolute https URL
or a plain in-app path.
This feed is the only text in the app that neither the build nor the user wrote, so it is treated
that way. Every check is in NoticeFeedReader with an assertion against it, and every ambiguity
resolves to showing nothing.
One notice at a time. Two rows above every page is two rows on every page, and a reader told
two things at once acts on neither, so the most severe wins and an authored notice breaks a tie.
Dismissing is per subject rather than per feature — hiding one release's notice leaves the next
one's free to appear — and only a problem interrupts a screen reader.
PackProphet/
├── src/
│ ├── PackProphet.Core/ # Engine and domain. No UI reference; warnings are errors
│ │ ├── Data/ # Card index, facts, pull rates, rarity ladder, sets, notices
│ │ ├── Deck/ # Share-code codec, legality linter, QR render
│ │ ├── Domain/ # Cards, rarities, pack variants, game rules
│ │ ├── Engine/ # Odds, targets, ranking, allocation, trades, wonder picks
│ │ ├── Import/ # CSV/XLSX readers, tracker import and export
│ │ ├── State/ # Profiles, undo history, chase-list codec, serialisation
│ │ ├── Sync/ # Pairing code, three-way state merge
│ │ └── Vision/ # Fingerprints, screen classification, count reading
│ └── PackProphet.App/ # Blazor WebAssembly
│ ├── Components/ # Card grid, tile, picker, palette, shared controls
│ ├── Pages/ # One per route (see URLs below)
│ ├── Services/ # Session, data loader, storage, sync, notices, scanning, focus
│ └── wwwroot/
│ ├── js/ # Only what Blazor cannot reach (see Architecture)
│ └── data/ # Vendored snapshot + card-hashes.txt fingerprint table
├── tests/
│ ├── PackProphet.Tests/ # Engine suite, no browser — runs in about a second
│ └── PackProphet.App.Tests/ # Page renders under bUnit, against the real snapshot
├── tools/CardHashGen/ # Fingerprint generator (deliberately outside the solution)
├── .github/workflows/ # deploy-pages.yml, card-data.yml
└── PackProphet.slnx
Requires the .NET 10 SDK and the wasm-tools workload. On Fedora (including a distrobox
container) you also need libatomic, which emscripten's bundled node links against.
dotnet workload install wasm-tools
dotnet run --project src/PackProphet.App # http://localhost:5000
dotnet test # 961 testsInvariantGlobalization is on in Debug as well as Release. It changes string comparison and casing,
and card names are sorted and searched, so a Debug-only difference would mean search behaving
differently in production. The cost is a native relink on every build.
The dev server captures its static-asset manifest at startup, so restart it after a rebuild — otherwise the newly fingerprinted
_frameworkfiles 404.
| Path | Description |
|---|---|
/ , /collection |
Collection grid or list, with filters, counts and undo |
/collection/screenshot |
Read cards off a screenshot of the game |
/collection/import, /collection/export |
Import from another tracker; export a backup |
/card/{key} |
One card: printings, cheapest route, packs that can give it |
/progress |
Every unfinished set: what it still needs, and where it comes from |
/packs |
Which pack to open next, with the target advisor and points panel |
/log |
Log a pack, read one off a screenshot, or log a recent promo |
/history |
Packs over time, predicted against actual, and your best hits |
/chase, /chase/{id} |
Chase lists, and one chase list's own page |
/share |
A chase list opened from a share link (read-only) |
/decks, /decks/new, /decks/{id} |
Saved decks, and the builder |
/decks/import |
Import a deck from an in-game share code |
/trades |
Which trade deserves your next stamina |
/board |
The in-game 20-slot wishlist (the public trade board) |
/wonder |
Wonder Pick take-or-skip verdicts |
/resources |
Pack, Wonder and Trade currencies side by side |
/compare |
Compare two collections; shares and swaps between them |
/settings |
Targets, profiles, backup and restore |
Two suites, and dotnet test runs both. CI runs them before it will deploy.
tests/PackProphet.Tests— the engine. No UI, no browser, runs in about a secondtests/PackProphet.App.Tests— the pages, rendered with bUnit against the vendored data snapshot served over a fakeHttpClient. Every routable page is discovered by reflection and rendered twice: on a brand-new profile and on a populated one. The empty-profile case covers pages that index a list with nothing in it, which compiles cleanly and passes every engine test
Each regression test was checked against the defect it describes: the fix reverted, the suite confirmed red, the fix restored.
.github/workflows/deploy-pages.yml publishes on a push to main. Enable Pages for the repository
with GitHub Actions as the source; nothing else is needed.
The workflow handles three Blazor-on-Pages traps, each of which fails silently otherwise:
.nojekyll (Jekyll drops _framework), 404.html (no SPA rewrite, so deep links 404), and
rewriting <base href> before publish rather than after — the service worker pins index.html
by SHA-256, so a later edit makes its install fail and offline support never happens. A build step
re-hashes every precached asset and fails the deploy on a mismatch.
.github/workflows/card-data.yml checks every three hours whether a new card-data release is out.
If the live site was built against an older one it starts a deploy, which pulls the new set's art
out of the release archive, so a new set has its pictures on the site within hours and without a
commit. When the release is newer than the vendored snapshot, and weekly regardless, it also
refreshes the snapshot, fingerprints cards the table has never seen, and opens one pull request. The pull request body lists
every set the table still does not cover completely, with counts — a set far short of its card count
is artwork upstream has not published yet, and a later run picks it up.
It runs tools/vendor-gap-art.py first and hands the generator the directory, because the only set
the job has work to do for is the newest one and that is reliably the set the art CDN has not
published — card data ships in days, art is a manual commit weeks later. Without it B4a sat at 0 of
110 fingerprinted and 0 of 110 typed while every other set was complete, with its art available the
whole time in the release archive the deploy already reads.
tools/CardHashGen is the generator behind it, and is deliberately absent from
PackProphet.slnx: it needs a native WebP decoder, and dotnet test resolves the solution, so
including it would put SkiaSharp on the deploy path for no reason. Run it by path.
# everything, from scratch — about 3,700 downloads
dotnet run --project tools/CardHashGen
# only cards with no fingerprint yet, which is what CI does
dotnet run --project tools/CardHashGen -- --only-missing
# one set, to a scratch file
dotnet run --project tools/CardHashGen -- --set A1a --out /tmp/hashes.txt
# read art from a directory instead of the CDN, for a set the CDN does not have
tools/vendor-gap-art.py --out /tmp/gap-art
dotnet run --project tools/CardHashGen -- --art-dir /tmp/gap-art --only-missingA run merges rather than replaces: art that 404s today must not remove a card the app can currently recognise. A run that fetches less than two thirds of what the table already held is treated as an outage and writes nothing.
- The maths — a card's per-pack arrival rate is summed across pack variants and slots, then
expected packs to finish a target is a Poissonised integral,
E = ∫₀^∞ (1 − Π_u P(Poisson(λ_u·t) ≥ k_u)) dt, numerically integrated. For single-copy demand it collapses to the familiar1 − e^{−λt}form; multi-copy demand is the same integral rather than a separate algorithm, which is what lets a deck ask for two Pikachu ex - Where the JavaScript line falls —
js/cardshot.jslocates a repeating grid of card-shaped regions and measures each one; it does not know what a card is. Which card a slot holds, whether it is owned, which in-game screen this is and what to tell the user are all decided inPackProphet.Core.Vision, where they are asserted from hand-written measurements with no browser and no image. The fingerprint itself is computed on both sides — in Core for the generated table, in JavaScript for the screenshot — and the two are pinned to a shared golden vector, because a hash computed a different way is not a near miss, it is a different card - Bootstrap is vendored as exactly the two files
index.htmllinks. The Blazor.Bootstrap component package was dropped and nothing replaced it
Open problems, each with what has already been ruled out, live in KNOWN-ISSUES.md so a second attempt at one starts where the first stopped.
Phases 1 and 2 are complete. See CHANGELOG.md.
- Phase 3 — localisation remains. Done: an accessibility pass (contrast measured in both themes,
names on every control, a text alternative for the chart), importing a collection from another
tracker and exporting it again (Settings → import/export, or
/collection/import), budget allocation across packs ("Which pack" → splitting a budget), and shareable read-only chase-list links (a chase list's own page → share this list) — the recipient sees what is wanted and, if they have their own collection loaded, which of it they already own - Phase 4 — under way ahead of Phase 3's remaining localisation, since it is the larger feature.
Done: screenshot recognition of the card list, a pack's five cards and a Wonder Pick line-up
(Collection → import from a screenshot, or
/collection/screenshot), with the fingerprint table refreshed weekly by a workflow. The match thresholds are set from the shape of the problem and from synthetic art rather than from a corpus of real screenshots, so they are the first thing to revisit once there is one. Cloud sync last, since it is the first thing needing a backend
Out of scope: meta tier lists, matchup data, tournament results, a battle simulator, a trade marketplace, accounts and social features, collection value scores.
AGPL-3.0-or-later — see LICENSE.
The licence follows the data: the only community dataset carrying attack text, ability text and evolution stage for every set is AGPL.
Data sources, the reverse-engineered deck format, and bundled third-party code are credited in NOTICE.md. Pull rates come from flibustier/pokemon-tcg-pocket-database and are published nowhere else; card detail from chase-mew/pokemon-tcg-pocket-cards.
How much of this was written with an AI assistant, and which parts were not, is stated in AI-DECLARATION.md.
Pokémon and Pokémon TCG Pocket are trademarks of Nintendo, Creatures Inc. and GAME FREAK inc. This is an unofficial fan tool, unaffiliated with and unendorsed by any of them.